@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,14 +3,19 @@ import {
3
3
  CombinedAutocompleteProvider,
4
4
  CURSOR_MARKER,
5
5
  Editor,
6
+ getCapabilities,
6
7
  getKeybindings,
8
+ hyperlink,
7
9
  truncateToWidth,
8
10
  visibleWidth,
9
11
  wrapTextWithAnsi,
10
12
  } from "@earendil-works/pi-tui";
11
13
 
14
+ import { FreshCompletionEditor } from "./brand-components.mjs";
12
15
  import { sanitizeTerminalText } from "./format-output.mjs";
13
16
  import { IMPORT_GUIDE_URLS } from "./import-guides.mjs";
17
+ import { SPOTIFY_LIBRARY_SCOPES } from "../../integrations/spotify/library-snapshot.mjs";
18
+ import { screenTranslator } from "../../i18n/index.mjs";
14
19
 
15
20
  function clean(value) {
16
21
  return sanitizeTerminalText(stripVTControlCharacters(String(value ?? "")))
@@ -36,10 +41,6 @@ function safeEditorLine(value) {
36
41
  .join("")).join(CURSOR_MARKER);
37
42
  }
38
43
 
39
- function count(value) {
40
- return Number.isFinite(value) && value >= 0 ? value.toLocaleString("en-US") : "Unknown";
41
- }
42
-
43
44
  function day(value) {
44
45
  if (value === undefined || value === null || value === "") return null;
45
46
  const date = new Date(value);
@@ -48,8 +49,9 @@ function day(value) {
48
49
 
49
50
  /** Acquisition guidance and file review; all effects belong to the host. */
50
51
  export class HistoryImportView {
51
- constructor({ tui, getTheme, getRows, profileReady = false, onAction }) {
52
+ constructor({ tui, getTheme, getRows, profileReady = false, onAction, getText = () => screenTranslator("en") }) {
52
53
  this.getTheme = getTheme;
54
+ this.getText = getText;
53
55
  this.getRows = getRows;
54
56
  this.profileReady = profileReady;
55
57
  this.onAction = onAction;
@@ -65,7 +67,7 @@ export class HistoryImportView {
65
67
  ["selectedPrefix", "selectedText", "description", "scrollInfo", "noMatch"]
66
68
  .map((key) => [key, (text) => this.getTheme().selectListTheme[key](text)]),
67
69
  );
68
- this.editor = new Editor(tui, {
70
+ this.editor = new FreshCompletionEditor(tui, {
69
71
  borderColor: (text) => this.getTheme().faint(text), selectList,
70
72
  }, { paddingX: 1, autocompleteMaxVisible: 3 });
71
73
  const completion = new CombinedAutocompleteProvider([], process.cwd(), null);
@@ -100,17 +102,36 @@ export class HistoryImportView {
100
102
  this.clientEditor.setText(text);
101
103
  this.emit("configureSpotify", { clientId: text.trim() });
102
104
  };
105
+ // Last.fm takes a username to read and, once, an API key to read it with.
106
+ this.lastfmUserEditor = new Editor(tui, { borderColor: (text) => this.getTheme().faint(text), selectList }, { paddingX: 1 });
107
+ this.lastfmUserEditor.onSubmit = (text) => {
108
+ this.lastfmUserEditor.setText(text);
109
+ this.emit("inspectLastfm", { username: text.trim() });
110
+ };
111
+ this.lastfmKeyEditor = new Editor(tui, { borderColor: (text) => this.getTheme().faint(text), selectList }, { paddingX: 1 });
112
+ this.lastfmKeyEditor.onSubmit = (text) => {
113
+ this.lastfmKeyEditor.setText(text);
114
+ this.emit("configureLastfm", { apiKey: text.trim() });
115
+ };
116
+ }
117
+
118
+ // The line editor for the current page, if it has one.
119
+ pageEditor(page = this.state.page) {
120
+ return { file: this.editor, client: this.clientEditor, lastfmUser: this.lastfmUserEditor, lastfmKey: this.lastfmKeyEditor }[page] ?? null;
121
+ }
122
+
123
+ focusEditors() {
124
+ for (const page of ["file", "client", "lastfmUser", "lastfmKey"]) this.pageEditor(page).focused = this._focused && this.state.page === page;
103
125
  }
104
126
 
105
127
  get focused() { return this._focused; }
106
128
  set focused(value) {
107
129
  this._focused = Boolean(value);
108
- this.editor.focused = this._focused && this.state.page === "file";
109
- this.clientEditor.focused = this._focused && this.state.page === "client";
130
+ this.focusEditors();
110
131
  if (!value) this.editor.setText(this.getPath());
111
132
  }
112
133
 
113
- invalidate() { this.editor.invalidate(); this.clientEditor.invalidate(); }
134
+ invalidate() { for (const page of ["file", "client", "lastfmUser", "lastfmKey"]) this.pageEditor(page).invalidate(); }
114
135
  getPath() { return this.editor.getExpandedText(); }
115
136
  setPath(value) { this.editor.setText(String(value ?? "")); }
116
137
 
@@ -122,212 +143,323 @@ export class HistoryImportView {
122
143
  this.pasteActive = false;
123
144
  this.pasteTail = "";
124
145
  this.editor.setText(this.getPath());
146
+ // A pasted Client ID or API key must replace the old one, not join it. A failed save keeps the text to fix.
147
+ if (next.page === "client" && this.state.page !== "working") this.clientEditor.setText("");
148
+ if (next.page === "lastfmKey" && this.state.page !== "working") this.lastfmKeyEditor.setText("");
125
149
  }
126
150
  this.state = next;
127
151
  if (typeof state?.profileReady === "boolean") this.profileReady = state.profileReady;
128
- this.editor.focused = this._focused && next.page === "file";
129
- this.clientEditor.focused = this._focused && next.page === "client";
152
+ this.focusEditors();
130
153
  }
131
154
 
132
155
  emit(type, detail = {}) { this.onAction?.({ type, ...detail }); }
133
156
 
157
+ // A preview's source decides its label and note; brand names stay as they are.
158
+ previewSource(preview) {
159
+ const tr = this.getText();
160
+ switch (preview.source) {
161
+ case "spotify-quick-library": return [tr("Your Spotify library and recent plays"),
162
+ tr("Your library shows what you keep and what Spotify ranks highest for you. Recent plays are your latest 50. A history ZIP can add years of listening later.")];
163
+ case "spotify-quick-recent": return [tr("Spotify recent listening"),
164
+ tr("Your latest plays, up to 50. Spotify doesn't say how long you listened here. A history ZIP can add the older ones later.")];
165
+ case "apple-library": return [tr("Apple Music library"),
166
+ tr("Your library with loves, ratings, play counts and when you last played each song. It doesn't list every play.")];
167
+ case "listenbrainz": return [tr("ListenBrainz history"),
168
+ tr("Your saved listens. Some don't say how long you listened, so they count as plays without minutes.")];
169
+ case "spotify-extended": return [tr("Spotify Extended Streaming History"),
170
+ tr("Every play in this file, with how long you listened and how each song started and ended.")];
171
+ case "spotify-account": return [tr("Spotify Account Data"),
172
+ tr("Your plays from the past year, plus your library and playlists. For older plays and listening time, use Extended streaming history.")];
173
+ case "youtube-music": return [clean(preview.sourceLabel),
174
+ tr("Your library songs and YouTube Music plays, read in English or Chinese. Regular YouTube videos are left out. Google doesn't say how long you listened, and history you deleted or paused can't come back.")];
175
+ case "lastfm": return [tr("Last.fm scrobbles"), preview.truncated
176
+ ? tr("Your latest {count} scrobbles, the most Moondog reads at once, and your loved tracks.", { count: tr.number(preview.listeningEvents) })
177
+ : tr("Every scrobble Last.fm counted, and your loved tracks.")];
178
+ case "public-playlist": return [clean(preview.sourceLabel), Number.isFinite(preview.totalTracks)
179
+ ? tr("{read} of {total} songs could be read; the service hides some. Only this playlist is added. It shows what you keep, not what you played, and it may not be one you made.", { read: tr.number(preview.availableTracks), total: tr.number(preview.totalTracks) })
180
+ : tr("{read} songs could be read; the service hides some. Only this playlist is added. It shows what you keep, not what you played, and it may not be one you made.", { read: tr.number(preview.availableTracks) })];
181
+ default: return [clean(preview.sourceLabel), clean(preview.scopeNote)];
182
+ }
183
+ }
184
+
185
+ spotifyQuickPreview(preview) {
186
+ const tr = this.getText();
187
+ const library = preview.library;
188
+ const first = day(preview.earliestListeningAt);
189
+ const last = day(preview.latestListeningAt);
190
+ const [label, note] = this.previewSource(preview);
191
+ return {
192
+ title: tr("Review your Spotify"),
193
+ notice: tr("Nothing is added until you import it."),
194
+ paragraphs: [
195
+ label,
196
+ ...(library ? [
197
+ [tr.n(library.savedTracks, "{count} saved song", "{count} saved songs"), tr.n(library.savedAlbums, "{count} saved album", "{count} saved albums"),
198
+ tr.n(library.followedArtists, "{count} followed artist", "{count} followed artists")].join(" · "),
199
+ tr("{songs} across {playlists}", { songs: tr.n(library.playlistTracks, "{count} song", "{count} songs"),
200
+ playlists: tr.n(library.playlists, "{count} playlist you made", "{count} playlists you made") }),
201
+ tr("{artists} and {tracks} from Spotify", { artists: tr.n(library.topArtists, "{count} top artist", "{count} top artists"),
202
+ tracks: tr.n(library.topTracks, "{count} top track", "{count} top tracks") }),
203
+ ...(library.truncated?.length ? [tr("A very large library: Moondog read up to its limit and stopped there.")] : []),
204
+ ...(library.skippedPlaylists ? [tr.n(library.skippedPlaylists, "{count} playlist couldn't be read and will be left out.", "{count} playlists couldn't be read and will be left out.")] : []),
205
+ ] : preview.libraryUnavailable ? [tr("Your library isn't included: Reconnect Spotify to allow it.")] : []),
206
+ preview.listeningEvents
207
+ ? first && last
208
+ ? tr("{plays}, {first} to {last}", { plays: tr.n(preview.listeningEvents, "{count} recent play", "{count} recent plays"), first, last })
209
+ : tr.n(preview.listeningEvents, "{count} recent play", "{count} recent plays")
210
+ : tr("No recent plays from Spotify right now."),
211
+ note,
212
+ ].filter(Boolean),
213
+ actions: [{ type: "commit", label: tr("Add to my profile") }, { type: "recent", label: tr("Read Spotify again") }, { type: "back", label: tr("Back") }],
214
+ };
215
+ }
216
+
134
217
  pageContent() {
135
- const profile = this.profileReady ? [{ type: "profile", label: "View my profile" }] : [];
136
- const file = { type: "file", label: "Choose a file" };
137
- const back = { type: "back", label: "Back" };
218
+ const tr = this.getText();
219
+ const profile = this.profileReady ? [{ type: "profile", label: tr("View my profile") }] : [];
220
+ const file = { type: "file", label: tr("Choose a file") };
221
+ const back = { type: "back", label: tr("Back") };
222
+ const website = url => tr("Website: {url}", { url });
138
223
  switch (this.state.page) {
139
224
  case "youtube_music": return {
140
- title: "Import from YouTube Music",
141
- paragraphs: ["Start with the songs in your library, then add what you've played from Google Takeout.", "Already have an export? Bring it in now. A new export takes a while to arrive, but there's nothing to set up."],
142
- actions: [{ type: "youtubeQuick", label: "Start with my music library" }, { type: "youtubeHistory", label: "Add past listening history" }, back],
225
+ title: tr("Import from YouTube Music"),
226
+ paragraphs: [tr("Start with the songs in your library, then add what you've played from Google Takeout."),
227
+ tr("Already have an export? Bring it in now. A new export takes a while to arrive, but there's nothing to set up.")],
228
+ actions: [{ type: "youtubeQuick", label: tr("Start with my music library") }, { type: "youtubeHistory", label: tr("Add past listening history") }, back],
143
229
  };
144
230
  case "youtubeQuick":
145
231
  case "youtubeHistory": return {
146
- title: this.state.page === "youtubeQuick" ? "YouTube Music: saved library" : "YouTube Music: past listening",
232
+ title: this.state.page === "youtubeQuick" ? tr("YouTube Music: saved library") : tr("YouTube Music: past listening"),
147
233
  paragraphs: [
148
- `Website: ${IMPORT_GUIDE_URLS.youtube}`,
149
- "1. In Google Takeout, deselect all products, then select YouTube and YouTube Music.",
150
- "2. Under included data, select music library songs and history. In format options, set history to JSON (not HTML).",
151
- "3. Create a one-time ZIP export with a download link. Google emails you when it is ready; save it in Downloads.",
152
- "4. Return here and choose the ZIP, or an extracted music-library-songs.csv or watch-history.json.",
153
- "Library songs show what you keep. Only YouTube Music plays count as plays; regular YouTube videos are left out. Google doesn't say how long you listened.",
154
- "Regular YouTube playlist CSVs and uploaded audio don't work yet. If the ZIP is over 256 MB, unzip it and bring in the music files one at a time.",
234
+ website(IMPORT_GUIDE_URLS.youtube),
235
+ tr("1. In Google Takeout, deselect all products, then select YouTube and YouTube Music."),
236
+ tr("2. Under included data, select music library songs and history. In format options, set history to JSON (not HTML)."),
237
+ tr("3. Create a one-time ZIP export with a download link. Google emails you when it is ready; save it in Downloads."),
238
+ tr("4. Return here and choose the ZIP, or an extracted music-library-songs.csv or watch-history.json."),
239
+ tr("Library songs show what you keep. Only YouTube Music plays count as plays; regular YouTube videos are left out. Google doesn't say how long you listened."),
240
+ tr("Regular YouTube playlist CSVs and uploaded audio don't work yet. If the ZIP is over 256 MB, unzip it and bring in the music files one at a time."),
155
241
  ],
156
- actions: [{ type: "file", label: "Choose my Takeout file" }, { type: "openYouTube", label: "Open Google Takeout" }, back],
242
+ actions: [{ type: "file", label: tr("Choose my Takeout file") }, { type: "openYouTube", label: tr("Open Google Takeout") }, back],
243
+ };
244
+ case "lastfm": {
245
+ const ready = Boolean(this.state.lastfm?.configured);
246
+ return {
247
+ title: tr("Import from Last.fm"),
248
+ paragraphs: [
249
+ tr("Type your Last.fm username. Moondog reads every scrobble Last.fm counted, going back years, and your loved tracks."),
250
+ tr("Your profile's recent listening has to be public. A long history can take several minutes."),
251
+ tr("Listening data comes from Last.fm. Nothing is added until you say so."),
252
+ website(IMPORT_GUIDE_URLS.lastfm),
253
+ ...(ready ? [] : [tr("Reading Last.fm needs a free API key, made once on this computer.")]),
254
+ ],
255
+ actions: [
256
+ ready ? { type: "lastfmUser", label: tr("Enter my Last.fm username") } : { type: "lastfmSetup", label: tr("Set up Last.fm access") },
257
+ { type: "openLastfm", label: tr("Open Last.fm") }, back,
258
+ ],
259
+ };
260
+ }
261
+ case "lastfmSetup": return {
262
+ title: tr("Set up Last.fm access"),
263
+ paragraphs: [
264
+ tr("For now, Moondog reads Last.fm with an API key you make yourself. It takes a minute."),
265
+ `1. ${tr("Sign in to Last.fm and create an API account. Any name works, such as Moondog.")}`,
266
+ `2. ${tr("Copy the API key it shows. You don't need the shared secret.")}`,
267
+ `3. ${tr("Paste the key here. It stays on this computer.")}`,
268
+ website(IMPORT_GUIDE_URLS.lastfmApi),
269
+ ],
270
+ actions: [{ type: "openLastfmApi", label: tr("Open the Last.fm API page") }, { type: "lastfmKey", label: tr("Enter my API key") }, back],
157
271
  };
158
272
  case "qq_music":
159
273
  case "netease": {
160
274
  const qq = this.state.page === "qq_music";
161
275
  return {
162
- title: `Import from ${qq ? "QQ Music / QQ 音乐" : "NetEase / 网易云音乐"}`,
276
+ title: tr("Import from {service}", { service: qq ? "QQ Music / QQ 音乐" : "NetEase / 网易云音乐" }),
163
277
  paragraphs: [
164
- "Quick start: open a playlist that sounds like you, choose Share > Copy link, and paste it below.",
165
- "Moondog reads the public song list without signing in. You'll see the playlist and its songs before anything is added.",
166
- "Private playlists can't be read, and some songs may be unavailable; the preview shows how many made it. A playlist shows what you keep, not what you played, and not necessarily one you made.",
167
- "Past listening: there's no reliable way to export your full history from this service yet, so playlists are the way in for now.",
278
+ tr("Quick start: open a playlist that sounds like you, choose Share > Copy link, and paste it below."),
279
+ tr("Moondog reads the public song list without signing in. You'll see the playlist and its songs before anything is added."),
280
+ tr("Private playlists can't be read, and some songs may be unavailable; the preview shows how many made it. A playlist shows what you keep, not what you played, and not necessarily one you made."),
281
+ tr("Past listening: there's no reliable way to export your full history from this service yet, so playlists are the way in for now."),
168
282
  ],
169
- actions: [{ type: "file", label: "Paste a playlist share link" }, { type: qq ? "openQQ" : "openNetEase", label: "Open my music service" }, back],
283
+ actions: [{ type: "file", label: tr("Paste a playlist share link") }, { type: qq ? "openQQ" : "openNetEase", label: tr("Open my music service") }, back],
170
284
  };
171
285
  }
172
286
  case "spotify": return {
173
- title: "Import from Spotify",
287
+ title: tr("Import from Spotify"),
174
288
  paragraphs: [
175
- "Quick start: connect Spotify and bring in what you've played lately.",
176
- "Past history: bring in the history ZIP Spotify sends you, going back years.",
177
- "Either way, it all ends up in the same profile.",
289
+ tr("Quick start: connect Spotify and bring in your library, your top artists, and what you've played lately."),
290
+ tr("Past history: bring in the history ZIP Spotify sends you, going back years."),
291
+ tr("Either way, it all ends up in the same profile."),
178
292
  ],
179
- actions: [{ type: "quick", label: "Quick start" }, { type: "spotifyHistory", label: "Add past listening history" }, back],
293
+ actions: [{ type: "quick", label: tr("Quick start") }, { type: "spotifyHistory", label: tr("Add past listening history") }, back],
180
294
  };
181
295
  case "apple": return {
182
- title: "Import from Apple Music",
296
+ title: tr("Import from Apple Music"),
183
297
  paragraphs: [
184
- "Quick start: export your library from Music on Mac and bring in the XML file.",
185
- "Past history: Apple can send you your data, but Moondog can't read that archive yet.",
298
+ tr("Quick start: export your library from Music on Mac and bring in the XML file."),
299
+ tr("Past history: Apple can send you your data, but Moondog can't read that archive yet."),
186
300
  ],
187
- actions: [{ type: "appleQuick", label: "Quick start with library XML" }, { type: "appleHistory", label: "Past listening history guide" }, back],
301
+ actions: [{ type: "appleQuick", label: tr("Quick start with library XML") }, { type: "appleHistory", label: tr("Past listening history guide") }, back],
188
302
  };
189
303
  case "appleQuick": return {
190
- title: "Quick start with Apple Music",
304
+ title: tr("Quick start with Apple Music"),
191
305
  paragraphs: [
192
- "1. In Music on Mac, choose File > Library > Export Library.",
193
- "2. Save Library.xml somewhere easy to find, such as Downloads.",
194
- "3. Choose the XML below. Preview it before adding it to your profile.",
195
- "Moondog reads the file on this machine, including your loves, ratings and play counts. Nothing to sign in to.",
196
- "A library shows what you keep and how often, but not when you played each song. Apple's privacy download is a different format.",
306
+ tr("1. In Music on Mac, choose File > Library > Export Library."),
307
+ tr("2. Save Library.xml somewhere easy to find, such as Downloads."),
308
+ tr("3. Choose the XML below. Preview it before adding it to your profile."),
309
+ tr("Moondog reads the file on this machine, including your loves, ratings and play counts. Nothing to sign in to."),
310
+ tr("A library shows what you keep and how often, but not when you played each song. Apple's privacy download is a different format."),
197
311
  ],
198
- actions: [{ type: "file", label: "Choose my Library.xml" }, { type: "openAppleHelp", label: "Open Apple XML export guide" }, back],
312
+ actions: [{ type: "file", label: tr("Choose my Library.xml") }, { type: "openAppleHelp", label: tr("Open Apple XML export guide") }, back],
199
313
  };
200
314
  case "appleHistory": return {
201
- title: "Apple Music: past listening history",
315
+ title: tr("Apple Music: past listening history"),
202
316
  paragraphs: [
203
- `Website: ${IMPORT_GUIDE_URLS.applePrivacy}`,
204
- "1. Sign in to Apple's Data & Privacy website and choose Request a copy of your data.",
205
- "2. Select Apple Media Services information, which includes Apple Music activity, then complete the request.",
206
- "3. Apple notifies you when it is ready. Return to Data & Privacy to download the prepared files within 14 days.",
207
- "4. Save the download on your computer. Your browser usually uses Downloads, or asks you to choose a folder.",
208
- "What's included depends on your account and region. Opening the link doesn't request or import anything by itself.",
209
- "Moondog can't read Apple's privacy download yet. Keep it safe, and start with Library.xml for now.",
317
+ website(IMPORT_GUIDE_URLS.applePrivacy),
318
+ tr("1. Sign in to Apple's Data & Privacy website and choose Request a copy of your data."),
319
+ tr("2. Select Apple Media Services information, which includes Apple Music activity, then complete the request."),
320
+ tr("3. Apple notifies you when it is ready. Return to Data & Privacy to download the prepared files within 14 days."),
321
+ tr("4. Save the download on your computer. Your browser usually uses Downloads, or asks you to choose a folder."),
322
+ tr("What's included depends on your account and region. Opening the link doesn't request or import anything by itself."),
323
+ tr("Moondog can't read Apple's privacy download yet. Keep it safe, and start with Library.xml for now."),
210
324
  ],
211
- actions: [{ type: "openApplePrivacy", label: "Open privacy.apple.com" }, { type: "appleQuick", label: "Start with library XML" }, back],
325
+ actions: [{ type: "openApplePrivacy", label: tr("Open privacy.apple.com") }, { type: "appleQuick", label: tr("Start with library XML") }, back],
212
326
  };
213
327
  case "quick": {
214
328
  const status = this.state.spotify ?? {};
215
- const ready = status.state === "ready" && status.scopes?.granted?.includes("user-read-recently-played");
329
+ const granted = status.state === "ready" ? status.scopes?.granted ?? [] : [];
330
+ const recentOnly = granted.includes("user-read-recently-played");
331
+ const ready = recentOnly && SPOTIFY_LIBRARY_SCOPES.every((scope) => granted.includes(scope));
216
332
  const configured = status.client_id_configured;
217
333
  return {
218
- title: "Quick start with Spotify",
334
+ title: tr("Quick start with Spotify"),
219
335
  paragraphs: [
220
- "Start your profile from up to 50 of your latest plays.",
221
- "You'll see them before anything is saved. No model or download needed.",
222
- "Older listening can come later with a history ZIP.",
223
- ready ? "Spotify is connected. Go ahead when you're ready."
224
- : configured ? "Connect Spotify so Moondog can read your recent plays."
225
- : "Connecting Spotify takes a one-time setup on this computer. Or start with a downloaded ZIP instead.",
336
+ tr("Start your profile from your Spotify library: saved songs and albums, the artists you follow, your own playlists, and the artists and tracks Spotify ranks highest for you. Your latest 50 plays come along too."),
337
+ tr("You'll see it all before anything is saved. No model or download needed."),
338
+ tr("Years of listening can come later with a history ZIP."),
339
+ ready ? tr("Spotify is connected. Go ahead when you're ready.")
340
+ : recentOnly ? tr("Your Spotify sign-in only covers recent plays. Reconnect once so Moondog can read your library too.")
341
+ : configured ? tr("Connect Spotify so Moondog can read your library and recent plays.")
342
+ : tr("Connecting Spotify takes a one-time setup on this computer. Or start with a downloaded ZIP instead."),
226
343
  ],
227
344
  actions: [
228
- { type: ready ? "recent" : configured ? "connectSpotify" : "setup", label: ready ? "Preview recent listening" : configured ? "Connect Spotify" : "Set up Spotify connection" },
229
- ...(ready ? [{ type: "connectSpotify", label: "Reconnect Spotify" }] : []),
230
- { type: "spotifyHistory", label: "Add past listening history" }, back,
345
+ ready ? { type: "recent", label: tr("Preview my Spotify") }
346
+ : recentOnly ? { type: "connectSpotify", label: tr("Reconnect Spotify") }
347
+ : configured ? { type: "connectSpotify", label: tr("Connect Spotify") } : { type: "setup", label: tr("Set up Spotify connection") },
348
+ ...(ready ? [{ type: "connectSpotify", label: tr("Reconnect Spotify") }] : recentOnly ? [{ type: "recent", label: tr("Just my recent plays") }] : []),
349
+ // A mistyped Client ID only shows up at sign-in, so keep a way to replace it.
350
+ ...(configured && !ready && !recentOnly ? [{ type: "client", label: tr("Change Client ID") }] : []),
351
+ { type: "spotifyHistory", label: tr("Add past listening history") }, back,
231
352
  ],
232
353
  };
233
354
  }
234
355
  case "setup": return {
235
- title: "Set up Spotify connection",
356
+ title: tr("Set up Spotify connection"),
236
357
  paragraphs: [
237
- "For now, Moondog connects through a Spotify app you create yourself. It takes a few minutes.",
238
- "In the Spotify Dashboard, create an app with Web API turned on.",
239
- "Add redirect URI: http://127.0.0.1:43821/callback",
240
- "Copy the Client ID from the app's settings. You don't need the client secret.",
241
- "The app owner needs Premium, and up to 5 people can use it. If you're not the owner, get added under User Management.",
358
+ tr("For now, Moondog connects through a Spotify app you create yourself. It takes a few minutes."),
359
+ `1. ${tr("In the Spotify Dashboard, create an app with Web API turned on.")}`,
360
+ `2. ${tr("Add redirect URI: {uri}", { uri: "http://127.0.0.1:43821/callback" })}`,
361
+ `3. ${tr("Copy the Client ID from the app's settings. You don't need the client secret.")}`,
362
+ tr("The app owner needs Premium, and up to 5 people can use it. If you're not the owner, get added under User Management."),
242
363
  ],
243
364
  actions: [
244
- { type: "openSpotifySetup", label: "Open Spotify Dashboard" },
245
- { type: "client", label: "Enter my Client ID" },
246
- { type: "spotifyHistory", label: "Use a history ZIP instead" }, back,
365
+ { type: "openSpotifySetup", label: tr("Open Spotify Dashboard") },
366
+ { type: "client", label: tr("Enter my Client ID") },
367
+ { type: "spotifyHistory", label: tr("Use a history ZIP instead") }, back,
247
368
  ],
248
369
  };
249
370
  case "empty": return {
250
- title: "Silence from Spotify",
371
+ title: tr("Silence from Spotify"),
251
372
  paragraphs: [
252
- "Spotify didn't return any recent plays, so nothing was imported.",
253
- "Play something in Spotify and try again, or bring a history ZIP.",
254
- "This only covers the last few plays. Your full history is still there.",
373
+ tr("Spotify didn't return any recent plays, so nothing was imported."),
374
+ tr("Play something in Spotify and try again, or bring a history ZIP."),
375
+ tr("This only covers the last few plays. Your full history is still there."),
255
376
  ],
256
- actions: [{ type: "recent", label: "Try recent listening again" }, { type: "spotifyHistory", label: "Add past listening history" }, back],
377
+ actions: [{ type: "recent", label: tr("Try recent listening again") }, { type: "spotifyHistory", label: tr("Add past listening history") }, back],
257
378
  };
258
379
  case "spotifyHistory": return {
259
- title: "Spotify: past listening history",
380
+ title: tr("Spotify: past listening history"),
260
381
  paragraphs: [
261
- "Already have your Spotify ZIP? Choose it below. No need to unzip it.",
262
- `Website: ${IMPORT_GUIDE_URLS.spotify}`,
263
- "1. Sign in, find Download your data, and select Extended streaming history. It goes back the furthest.",
264
- "2. Confirm the request. Spotify emails you when it's ready, which can take up to 30 days.",
265
- "3. Download the ZIP. It usually lands in your Downloads folder.",
266
- "4. Come back to /import > Spotify > Add past listening history and choose that ZIP.",
267
- "The quicker Account data download works too. It covers the past year plus your library. Neither needs a Spotify app or sign-in.",
268
- "Opening the website doesn't request or import anything by itself.",
382
+ tr("Already have your Spotify ZIP? Choose it below. No need to unzip it."),
383
+ website(IMPORT_GUIDE_URLS.spotify),
384
+ tr("1. Sign in, find Download your data, and select Extended streaming history. It goes back the furthest."),
385
+ tr("2. Confirm the request. Spotify emails you when it's ready, which can take up to 30 days."),
386
+ tr("3. Download the ZIP. It usually lands in your Downloads folder."),
387
+ tr("4. Come back to /import > Spotify > Add past listening history and choose that ZIP."),
388
+ tr("The quicker Account data download works too. It covers the past year plus your library. Neither needs a Spotify app or sign-in."),
389
+ tr("Opening the website doesn't request or import anything by itself."),
269
390
  ],
270
391
  actions: [
271
- { type: "file", label: "Choose my Spotify ZIP" },
272
- { type: "openSpotify", label: "Open Spotify data export website" },
273
- { type: "waiting", label: "I'm waiting for my download" }, back,
392
+ { type: "file", label: tr("Choose my Spotify ZIP") },
393
+ { type: "openSpotify", label: tr("Open Spotify data export website") },
394
+ { type: "waiting", label: tr("I'm waiting for my download") }, back,
274
395
  ],
275
396
  };
276
397
  case "waiting": return {
277
- title: "Wish you were here",
398
+ title: tr("Wish you were here"),
278
399
  paragraphs: [
279
- "When Spotify's email arrives, save the ZIP, usually in Downloads.",
280
- "Then come back to /import > Spotify > Add past listening history and choose it.",
281
- this.profileReady ? "Your profile is still here to explore in the meantime." : "You can still talk music while you wait.",
282
- "Connecting Spotify now brings in your latest plays, not your full history.",
400
+ tr("When Spotify's email arrives, save the ZIP, usually in Downloads."),
401
+ tr("Then come back to /import > Spotify > Add past listening history and choose it."),
402
+ this.profileReady ? tr("Your profile is still here to explore in the meantime.") : tr("You can still talk music while you wait."),
403
+ tr("Connecting Spotify now brings in your latest plays, not your full history."),
283
404
  ],
284
- actions: [{ type: "quick", label: "Quick start while I wait" }, file, ...profile, back],
405
+ actions: [{ type: "quick", label: tr("Quick start while I wait") }, file, ...profile, back],
285
406
  };
286
407
  case "other": return {
287
- title: "Other listening sources",
408
+ title: tr("Other listening sources"),
288
409
  paragraphs: [
289
- "ListenBrainz: choose a listens JSON file saved from ListenBrainz, either a GET export or a single/import payload.",
410
+ tr("ListenBrainz: choose a listens JSON file saved from ListenBrainz, either a GET export or a single/import payload."),
290
411
  ],
291
412
  actions: [file, back],
292
413
  };
293
414
  case "preview": {
294
415
  const preview = this.state.preview ?? {};
295
416
  const recent = this.state.origin === "quick";
417
+ if (preview.kind === "spotify-quick") return this.spotifyQuickPreview(preview);
296
418
  const library = preview.kind === "library";
297
419
  const collection = preview.kind === "collection";
298
420
  const first = day(preview.earliestListeningAt);
299
421
  const last = day(preview.latestListeningAt);
422
+ const [label, note] = this.previewSource(preview);
423
+ const known = value => Number.isFinite(value) && value >= 0;
300
424
  return {
301
- title: library ? "Review your Apple Music library" : recent ? "Review recent listening" : "Review this import",
302
- notice: "Nothing is added until you import it.",
425
+ title: library ? tr("Review your Apple Music library") : recent ? tr("Review recent listening") : tr("Review this import"),
426
+ notice: tr("Nothing is added until you import it."),
303
427
  paragraphs: [
304
- clean(preview.sourceLabel) || "Your history",
428
+ label || tr("Your history"),
305
429
  clean(preview.fileName),
306
- library || collection ? `${count(preview.tracks)} ${preview.tracks === 1 ? "song" : "songs"}` : `${count(preview.listeningEvents)} ${preview.listeningEvents === 1 ? "play" : "plays"} · ${count(preview.tracks)} ${preview.tracks === 1 ? "track" : "tracks"}`,
307
- library || collection ? `As of ${day(preview.capturedAt) ?? "an unknown date"}` : first && last ? `${first} to ${last}` : "No dates in this file.",
308
- library || collection ? "This shows what you keep, so no plays will be added." : recent
309
- ? "Spotify doesn't say how long you listened to recent plays."
430
+ library || collection
431
+ ? known(preview.tracks) ? tr.n(preview.tracks, "{count} song", "{count} songs") : tr("Unknown number of songs")
432
+ : `${known(preview.listeningEvents) ? tr.n(preview.listeningEvents, "{count} play", "{count} plays") : tr("Unknown number of plays")} · ${known(preview.tracks) ? tr.n(preview.tracks, "{count} track", "{count} tracks") : tr("Unknown number of tracks")}`,
433
+ library || collection ? tr("As of {date}", { date: day(preview.capturedAt) ?? tr("an unknown date") }) : first && last ? tr("{first} to {last}", { first, last }) : tr("No dates in this file."),
434
+ library || collection ? tr("This shows what you keep, so no plays will be added.") : recent
435
+ ? tr("Spotify doesn't say how long you listened to recent plays.")
436
+ : preview.source === "lastfm"
437
+ ? tr("Last.fm counts a play after half a song or four minutes. It doesn't say how long you listened.")
310
438
  : preview.eventsWithPlayedMs === preview.listeningEvents
311
- ? "Listening time is known for every play."
312
- : `Listening time is known for ${count(preview.eventsWithPlayedMs)} of ${count(preview.listeningEvents)} plays.`,
313
- ...(preview.profileEvidence > 0 ? [`Also ${count(preview.profileEvidence)} saved songs, follows, or playlist entries`] : []),
314
- ...(preview.skippedRecords > 0 ? [`Left out ${count(preview.skippedRecords)} podcasts, videos, or incomplete entries`] : []),
315
- clean(preview.scopeNote),
439
+ ? tr("Listening time is known for every play.")
440
+ : tr("Listening time is known for {known} of {total} plays.", { known: tr.number(preview.eventsWithPlayedMs), total: tr.number(preview.listeningEvents) }),
441
+ ...(preview.source === "lastfm" && preview.lovedTracks > 0
442
+ ? [tr.n(preview.lovedTracks, "Also {count} loved track", "Also {count} loved tracks")]
443
+ : preview.profileEvidence > 0 ? [tr.n(preview.profileEvidence, "Also {count} saved song, follow, or playlist entry", "Also {count} saved songs, follows, or playlist entries")] : []),
444
+ ...(preview.skippedRecords > 0 ? [tr.n(preview.skippedRecords, "Left out {count} podcast, video, or incomplete entry", "Left out {count} podcasts, videos, or incomplete entries")] : []),
445
+ note,
316
446
  ...(preview.sampleTracks ?? []).map((track) => ` ${clean(track)}`),
317
447
  ].filter(Boolean),
318
448
  actions: [
319
- { type: "commit", label: "Add to my profile" },
320
- recent ? { type: "recent", label: "Refresh recent listening" } : { type: "file", label: "Choose a different file" }, back,
449
+ { type: "commit", label: tr("Add to my profile") },
450
+ recent ? { type: "recent", label: tr("Refresh recent listening") }
451
+ : preview.source === "lastfm" ? { type: "lastfmUser", label: tr("Use a different username") }
452
+ : { type: "file", label: tr("Choose a different file") }, back,
321
453
  ],
322
454
  };
323
455
  }
324
456
  default: return {
325
- title: "Bring your music",
457
+ title: tr("Bring your music"),
326
458
  paragraphs: [
327
- "Pick your music service. Start with what you've played lately, or go all the way back.",
328
- this.profileReady ? "Anything you bring in joins your profile. Your choices stay as they are." : "You'll see what's inside before anything is added. It all stays on this machine.",
459
+ tr("Pick your music service. Start with what you've played lately, or go all the way back."),
460
+ this.profileReady ? tr("Anything you bring in joins your profile. Your choices stay as they are.") : tr("You'll see what's inside before anything is added. It all stays on this machine."),
329
461
  ],
330
- actions: [{ type: "spotify", label: "Spotify" }, { type: "apple", label: "Apple Music" }, { type: "youtube_music", label: "YouTube Music" }, { type: "qq_music", label: "QQ Music / QQ 音乐" }, { type: "netease", label: "NetEase / 网易云音乐" }, { type: "other", label: "Other sources" }, ...profile],
462
+ actions: [{ type: "spotify", label: "Spotify" }, { type: "apple", label: "Apple Music" }, { type: "youtube_music", label: "YouTube Music" }, { type: "lastfm", label: "Last.fm" }, { type: "qq_music", label: "QQ Music / QQ 音乐" }, { type: "netease", label: "NetEase / 网易云音乐" }, { type: "other", label: tr("Other sources") }, ...profile],
331
463
  };
332
464
  }
333
465
  }
@@ -336,8 +468,7 @@ export class HistoryImportView {
336
468
  if (this.state.page === "working") return;
337
469
  if (data.includes("\x1b[200~")) this.pasteActive = true;
338
470
  if (this.pasteActive) {
339
- if (this.state.page === "file") this.editor.handleInput(data);
340
- if (this.state.page === "client") this.clientEditor.handleInput(data);
471
+ this.pageEditor()?.handleInput(data);
341
472
  const paste = this.pasteTail + data;
342
473
  this.pasteActive = !paste.includes("\x1b[201~");
343
474
  this.pasteTail = this.pasteActive ? paste.slice(-5) : "";
@@ -354,8 +485,8 @@ export class HistoryImportView {
354
485
  } else this.editor.handleInput(data);
355
486
  return;
356
487
  }
357
- if (this.state.page === "client") {
358
- this.clientEditor.handleInput(data);
488
+ if (["client", "lastfmUser", "lastfmKey"].includes(this.state.page)) {
489
+ this.pageEditor().handleInput(data);
359
490
  return;
360
491
  }
361
492
  const { actions } = this.pageContent();
@@ -373,15 +504,21 @@ export class HistoryImportView {
373
504
  renderFile(width, height, theme) {
374
505
  const client = this.state.page === "client";
375
506
  const playlist = ["qq_music", "netease"].includes(this.state.provider);
376
- const input = client ? this.clientEditor : this.editor;
507
+ const input = this.pageEditor() ?? this.editor;
377
508
  const error = clean(this.state.error?.message ?? this.state.error);
378
509
  const errorLines = error ? wrapTextWithAnsi(error, width).slice(0, Math.min(3, Math.max(1, height - 6))).map(theme.error) : [];
379
- const header = client
380
- ? [theme.bold("Enter your Spotify Client ID"), theme.muted("From your app settings. No client secret."), theme.text("Client ID")]
381
- : playlist ? [theme.bold("Paste your playlist share link"), theme.muted(this.state.provider === "qq_music" ? "QQ Music public playlist" : "NetEase Cloud Music public playlist"), theme.text("Link (or the copied share text)")]
382
- : [theme.bold("Choose your music file"), theme.muted(this.state.provider === "apple" ? "Apple Music library XML" : this.state.provider === "spotify" ? "Original Spotify history ZIP" : this.state.provider === "youtube_music" ? "Takeout ZIP, music library CSV or watch-history JSON" : this.state.provider === "other" ? "ListenBrainz JSON" : "Music export file or public playlist link"), theme.text("File path")];
383
- const hint = client ? theme.muted("Enter save · Esc back") : playlist ? theme.muted("Paste link · Enter preview · Esc back") : theme.muted(width >= 64 ? "Paste or drag one path · Tab completes · Enter inspect · Esc back"
384
- : width >= 38 ? "Paste/drag · Tab path · ↵ inspect · Esc" : "Tab path · ↵ inspect · Esc");
510
+ const tr = this.getText();
511
+ const fileKind = { apple: tr("Apple Music library XML"), spotify: tr("Original Spotify history ZIP"),
512
+ youtube_music: tr("Takeout ZIP, music library CSV or watch-history JSON"), other: "ListenBrainz JSON" }[this.state.provider] ?? tr("Music export file or public playlist link");
513
+ const lastfm = { lastfmUser: [theme.bold(tr("Your Last.fm username")), theme.muted(tr("Or your profile link: last.fm/user/<username>")), theme.text(tr("Username"))],
514
+ lastfmKey: [theme.bold(tr("Paste your Last.fm API key")), theme.muted(tr("32 letters and numbers. Not the shared secret.")), theme.text("API key")] }[this.state.page];
515
+ const header = lastfm ?? (client
516
+ ? [theme.bold(tr("Enter your Spotify Client ID")), theme.muted(tr("From your app settings. No client secret.")), theme.text("Client ID")]
517
+ : playlist ? [theme.bold(tr("Paste your playlist share link")), theme.muted(this.state.provider === "qq_music" ? tr("QQ Music public playlist") : tr("NetEase Music public playlist")), theme.text(tr("Link (or the copied share text)"))]
518
+ : [theme.bold(tr("Choose your music file")), theme.muted(fileKind), theme.text(tr("File path"))]);
519
+ const hint = this.state.page === "lastfmUser" ? theme.muted(tr("Enter read · Esc back"))
520
+ : client || this.state.page === "lastfmKey" ? theme.muted(tr("Enter save · Esc back")) : playlist ? theme.muted(tr("Paste link · Enter preview · Esc back")) : theme.muted(width >= 64 ? tr("Paste or drag one path · Tab completes · Enter inspect · Esc back")
521
+ : width >= 38 ? tr("Paste/drag · Tab path · ↵ inspect · Esc") : tr("Tab path · ↵ inspect · Esc"));
385
522
  const budget = Math.max(1, height - header.length - errorLines.length - 1);
386
523
  input.setAutocompleteMaxVisible(Math.max(1, Math.min(4, budget - 8)));
387
524
  let editor = input.render(width);
@@ -406,13 +543,29 @@ export class HistoryImportView {
406
543
  const inset = columns >= 8 ? 1 : 0;
407
544
  const inner = Math.max(1, columns - inset * 2);
408
545
  const theme = this.getTheme();
546
+ const tr = this.getText();
409
547
  let lines;
410
- if (["file", "client"].includes(this.state.page)) {
548
+ if (["file", "client", "lastfmUser", "lastfmKey"].includes(this.state.page)) {
411
549
  lines = this.renderFile(inner, height, theme);
412
550
  } else if (this.state.page === "working") {
413
- const message = clean(this.state.message) || "Working on it…";
414
- lines = [theme.bold("Your listening history"), "", ...wrapTextWithAnsi(message, inner).map(theme.text)];
415
- if (height >= 5) lines.push("", theme.muted("One moment."));
551
+ const message = clean(this.state.message) || tr("Working on it…");
552
+ lines = [theme.bold(tr("Your listening history")), "", ...wrapTextWithAnsi(message, inner).map(theme.text)];
553
+ if (height >= 5) lines.push("", theme.muted(this.state.cancellable ? tr("Esc cancels") : tr("One moment.")));
554
+ } else if (this.state.page === "signin") {
555
+ // Spotify reports a wrong Client ID or redirect URI only in the browser, so say what to look for here.
556
+ const url = clean(this.state.url);
557
+ // A link split across rows cannot be copied whole, so prefer one clickable label.
558
+ const link = url && getCapabilities().hyperlinks ? hyperlink(tr("Open the Spotify sign-in page"), url) : url;
559
+ const paragraphs = [
560
+ tr("Your browser opened Spotify. Sign in, then choose Agree."),
561
+ tr("If Spotify shows INVALID_CLIENT or \"Invalid redirect URI\", your Spotify app doesn't match yet. Cancel, check the redirect URI, then choose Change Client ID."),
562
+ tr("Add redirect URI: {uri}", { uri: "http://127.0.0.1:43821/callback" }),
563
+ ...(link ? [tr("Browser didn't open? Use this link:"), link] : []),
564
+ ];
565
+ lines = [theme.bold(tr("Sign in to Spotify")), "",
566
+ ...paragraphs.flatMap((text, index) => [...(index ? [""] : []), ...wrapTextWithAnsi(text, inner).map(text === link ? theme.text : theme.muted)]),
567
+ "", theme.faint(tr("Esc cancels"))];
568
+ if (lines.length > height) lines = [...lines.slice(0, height - 1), lines.at(-1)];
416
569
  } else {
417
570
  const page = this.pageContent();
418
571
  const spacious = height >= 17;
@@ -431,12 +584,12 @@ export class HistoryImportView {
431
584
  this.selected = Math.min(this.selected, page.actions.length - 1);
432
585
  const actions = page.actions.map(({ label }, index) => {
433
586
  const selected = index === this.selected;
434
- const text = fit((selected ? "› " : " ") + label, inner);
435
- return selected ? theme.inverse(theme.bold(text)) : theme.text(text);
587
+ const text = fit((selected ? "◉ " : " ") + label, inner);
588
+ return selected ? theme.selected(text) : theme.text(text);
436
589
  });
437
590
  const hint = this.contentMaxOffset > 0
438
- ? inner >= 36 ? "↑↓ ↵ choose · PgUp/Dn read · Esc back" : "↑↓ ↵ · PgUp/Dn read · Esc"
439
- : inner >= 34 ? "↑↓ choose · Enter select · Esc back" : "↑↓ choose · ↵ select · Esc";
591
+ ? inner >= 36 ? tr("↑↓ ↵ choose · PgUp/Dn read · Esc back") : tr("↑↓ ↵ · PgUp/Dn read · Esc")
592
+ : inner >= 34 ? tr("↑↓ choose · Enter select · Esc back") : tr("↑↓ choose · ↵ select · Esc");
440
593
  lines = [...header, ...paragraphs.slice(this.contentOffset, this.contentOffset + capacity),
441
594
  ...(spacious ? [""] : []), ...actions, theme.faint(hint)];
442
595
  }