@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.
- package/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- 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
|
|
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.
|
|
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() {
|
|
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.
|
|
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
|
|
136
|
-
const
|
|
137
|
-
const
|
|
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.",
|
|
142
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
221
|
-
"You'll see
|
|
222
|
-
"
|
|
223
|
-
ready ? "Spotify is connected. Go ahead when you're ready."
|
|
224
|
-
:
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
+
label || tr("Your history"),
|
|
305
429
|
clean(preview.fileName),
|
|
306
|
-
library || collection
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
:
|
|
313
|
-
...(preview.
|
|
314
|
-
|
|
315
|
-
|
|
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" }
|
|
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
|
-
|
|
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
|
|
358
|
-
this.
|
|
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 =
|
|
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
|
|
380
|
-
|
|
381
|
-
:
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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 ? "
|
|
435
|
-
return selected ? theme.
|
|
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
|
}
|