@audiofool/moondog 0.1.0
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/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import {
|
|
3
|
+
CombinedAutocompleteProvider,
|
|
4
|
+
CURSOR_MARKER,
|
|
5
|
+
Editor,
|
|
6
|
+
getKeybindings,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
13
|
+
import { IMPORT_GUIDE_URLS } from "./import-guides.mjs";
|
|
14
|
+
|
|
15
|
+
function clean(value) {
|
|
16
|
+
return sanitizeTerminalText(stripVTControlCharacters(String(value ?? "")))
|
|
17
|
+
.replace(/\s+/gu, " ").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fit(value, width) {
|
|
21
|
+
const clipped = truncateToWidth(value, width, "");
|
|
22
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function paint(value, width, theme) {
|
|
26
|
+
const line = fit(value, width);
|
|
27
|
+
// Keep Pi's zero-width hardware cursor marker in NO_COLOR terminals.
|
|
28
|
+
return theme.plain ? line.replace(/\u001b\[[\d;:]*m/g, "") : theme.background(theme.text(line));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeEditorLine(value) {
|
|
32
|
+
// Preserve Pi's styling and cursor protocol, but remove controls from paths.
|
|
33
|
+
return value.split(CURSOR_MARKER).map((part) => part.split(/(\u001b\[[\d;:]*m)/gu)
|
|
34
|
+
.map((segment) => /^\u001b\[[\d;:]*m$/u.test(segment) ? segment
|
|
35
|
+
: sanitizeTerminalText(stripVTControlCharacters(segment)).replace(/[\r\n\t]/gu, " "))
|
|
36
|
+
.join("")).join(CURSOR_MARKER);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function count(value) {
|
|
40
|
+
return Number.isFinite(value) && value >= 0 ? value.toLocaleString("en-US") : "Unknown";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function day(value) {
|
|
44
|
+
if (value === undefined || value === null || value === "") return null;
|
|
45
|
+
const date = new Date(value);
|
|
46
|
+
return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Acquisition guidance and file review; all effects belong to the host. */
|
|
50
|
+
export class HistoryImportView {
|
|
51
|
+
constructor({ tui, getTheme, getRows, profileReady = false, onAction }) {
|
|
52
|
+
this.getTheme = getTheme;
|
|
53
|
+
this.getRows = getRows;
|
|
54
|
+
this.profileReady = profileReady;
|
|
55
|
+
this.onAction = onAction;
|
|
56
|
+
this.state = { page: "start" };
|
|
57
|
+
this.selected = 0;
|
|
58
|
+
this.contentOffset = 0;
|
|
59
|
+
this.contentPageSize = 1;
|
|
60
|
+
this.contentMaxOffset = 0;
|
|
61
|
+
this.pasteActive = false;
|
|
62
|
+
this.pasteTail = "";
|
|
63
|
+
this._focused = false;
|
|
64
|
+
const selectList = Object.fromEntries(
|
|
65
|
+
["selectedPrefix", "selectedText", "description", "scrollInfo", "noMatch"]
|
|
66
|
+
.map((key) => [key, (text) => this.getTheme().selectListTheme[key](text)]),
|
|
67
|
+
);
|
|
68
|
+
this.editor = new Editor(tui, {
|
|
69
|
+
borderColor: (text) => this.getTheme().faint(text), selectList,
|
|
70
|
+
}, { paddingX: 1, autocompleteMaxVisible: 3 });
|
|
71
|
+
const completion = new CombinedAutocompleteProvider([], process.cwd(), null);
|
|
72
|
+
// This editor contains one path, so absolute paths are not slash commands.
|
|
73
|
+
// A temporary quote also lets Pi complete a literal path containing spaces.
|
|
74
|
+
const quotedInput = (lines, line, column) => {
|
|
75
|
+
const quoted = !String(lines[line] ?? "").startsWith('"');
|
|
76
|
+
const input = [...lines];
|
|
77
|
+
if (quoted) input[line] = `"${input[line] ?? ""}`;
|
|
78
|
+
return { lines: input, column: column + Number(quoted), quoted };
|
|
79
|
+
};
|
|
80
|
+
this.editor.setAutocompleteProvider({
|
|
81
|
+
async getSuggestions(lines, line, column, options) {
|
|
82
|
+
const input = quotedInput(lines, line, column);
|
|
83
|
+
const result = await completion.getSuggestions(input.lines, line, input.column, { ...options, force: true });
|
|
84
|
+
return result && { ...result, prefix: input.quoted ? result.prefix.slice(1) : result.prefix };
|
|
85
|
+
},
|
|
86
|
+
applyCompletion(lines, line, column, item, prefix) {
|
|
87
|
+
const input = quotedInput(lines, line, column);
|
|
88
|
+
return completion.applyCompletion(input.lines, line, input.column, item, input.quoted ? `"${prefix}` : prefix);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
// Pi clears its editor on submit, including after accepting some completions.
|
|
92
|
+
this.editor.onSubmit = (text) => {
|
|
93
|
+
this.editor.setText(text);
|
|
94
|
+
this.emit("inspect", { path: this.getPath() });
|
|
95
|
+
};
|
|
96
|
+
this.clientEditor = new Editor(tui, {
|
|
97
|
+
borderColor: (text) => this.getTheme().faint(text), selectList,
|
|
98
|
+
}, { paddingX: 1 });
|
|
99
|
+
this.clientEditor.onSubmit = (text) => {
|
|
100
|
+
this.clientEditor.setText(text);
|
|
101
|
+
this.emit("configureSpotify", { clientId: text.trim() });
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get focused() { return this._focused; }
|
|
106
|
+
set focused(value) {
|
|
107
|
+
this._focused = Boolean(value);
|
|
108
|
+
this.editor.focused = this._focused && this.state.page === "file";
|
|
109
|
+
this.clientEditor.focused = this._focused && this.state.page === "client";
|
|
110
|
+
if (!value) this.editor.setText(this.getPath());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
invalidate() { this.editor.invalidate(); this.clientEditor.invalidate(); }
|
|
114
|
+
getPath() { return this.editor.getExpandedText(); }
|
|
115
|
+
setPath(value) { this.editor.setText(String(value ?? "")); }
|
|
116
|
+
|
|
117
|
+
setState(state) {
|
|
118
|
+
const next = { page: "start", ...state };
|
|
119
|
+
if (next.page !== this.state.page) {
|
|
120
|
+
this.selected = 0;
|
|
121
|
+
this.contentOffset = 0;
|
|
122
|
+
this.pasteActive = false;
|
|
123
|
+
this.pasteTail = "";
|
|
124
|
+
this.editor.setText(this.getPath());
|
|
125
|
+
}
|
|
126
|
+
this.state = next;
|
|
127
|
+
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";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
emit(type, detail = {}) { this.onAction?.({ type, ...detail }); }
|
|
133
|
+
|
|
134
|
+
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" };
|
|
138
|
+
switch (this.state.page) {
|
|
139
|
+
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],
|
|
143
|
+
};
|
|
144
|
+
case "youtubeQuick":
|
|
145
|
+
case "youtubeHistory": return {
|
|
146
|
+
title: this.state.page === "youtubeQuick" ? "YouTube Music: saved library" : "YouTube Music: past listening",
|
|
147
|
+
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.",
|
|
155
|
+
],
|
|
156
|
+
actions: [{ type: "file", label: "Choose my Takeout file" }, { type: "openYouTube", label: "Open Google Takeout" }, back],
|
|
157
|
+
};
|
|
158
|
+
case "qq_music":
|
|
159
|
+
case "netease": {
|
|
160
|
+
const qq = this.state.page === "qq_music";
|
|
161
|
+
return {
|
|
162
|
+
title: `Import from ${qq ? "QQ Music / QQ 音乐" : "NetEase / 网易云音乐"}`,
|
|
163
|
+
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.",
|
|
168
|
+
],
|
|
169
|
+
actions: [{ type: "file", label: "Paste a playlist share link" }, { type: qq ? "openQQ" : "openNetEase", label: "Open my music service" }, back],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case "spotify": return {
|
|
173
|
+
title: "Import from Spotify",
|
|
174
|
+
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.",
|
|
178
|
+
],
|
|
179
|
+
actions: [{ type: "quick", label: "Quick start" }, { type: "spotifyHistory", label: "Add past listening history" }, back],
|
|
180
|
+
};
|
|
181
|
+
case "apple": return {
|
|
182
|
+
title: "Import from Apple Music",
|
|
183
|
+
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.",
|
|
186
|
+
],
|
|
187
|
+
actions: [{ type: "appleQuick", label: "Quick start with library XML" }, { type: "appleHistory", label: "Past listening history guide" }, back],
|
|
188
|
+
};
|
|
189
|
+
case "appleQuick": return {
|
|
190
|
+
title: "Quick start with Apple Music",
|
|
191
|
+
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.",
|
|
197
|
+
],
|
|
198
|
+
actions: [{ type: "file", label: "Choose my Library.xml" }, { type: "openAppleHelp", label: "Open Apple XML export guide" }, back],
|
|
199
|
+
};
|
|
200
|
+
case "appleHistory": return {
|
|
201
|
+
title: "Apple Music: past listening history",
|
|
202
|
+
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.",
|
|
210
|
+
],
|
|
211
|
+
actions: [{ type: "openApplePrivacy", label: "Open privacy.apple.com" }, { type: "appleQuick", label: "Start with library XML" }, back],
|
|
212
|
+
};
|
|
213
|
+
case "quick": {
|
|
214
|
+
const status = this.state.spotify ?? {};
|
|
215
|
+
const ready = status.state === "ready" && status.scopes?.granted?.includes("user-read-recently-played");
|
|
216
|
+
const configured = status.client_id_configured;
|
|
217
|
+
return {
|
|
218
|
+
title: "Quick start with Spotify",
|
|
219
|
+
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.",
|
|
226
|
+
],
|
|
227
|
+
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,
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
case "setup": return {
|
|
235
|
+
title: "Set up Spotify connection",
|
|
236
|
+
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.",
|
|
242
|
+
],
|
|
243
|
+
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,
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
case "empty": return {
|
|
250
|
+
title: "Silence from Spotify",
|
|
251
|
+
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.",
|
|
255
|
+
],
|
|
256
|
+
actions: [{ type: "recent", label: "Try recent listening again" }, { type: "spotifyHistory", label: "Add past listening history" }, back],
|
|
257
|
+
};
|
|
258
|
+
case "spotifyHistory": return {
|
|
259
|
+
title: "Spotify: past listening history",
|
|
260
|
+
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.",
|
|
269
|
+
],
|
|
270
|
+
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,
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
case "waiting": return {
|
|
277
|
+
title: "Wish you were here",
|
|
278
|
+
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.",
|
|
283
|
+
],
|
|
284
|
+
actions: [{ type: "quick", label: "Quick start while I wait" }, file, ...profile, back],
|
|
285
|
+
};
|
|
286
|
+
case "other": return {
|
|
287
|
+
title: "Other listening sources",
|
|
288
|
+
paragraphs: [
|
|
289
|
+
"ListenBrainz: choose a listens JSON file saved from ListenBrainz, either a GET export or a single/import payload.",
|
|
290
|
+
],
|
|
291
|
+
actions: [file, back],
|
|
292
|
+
};
|
|
293
|
+
case "preview": {
|
|
294
|
+
const preview = this.state.preview ?? {};
|
|
295
|
+
const recent = this.state.origin === "quick";
|
|
296
|
+
const library = preview.kind === "library";
|
|
297
|
+
const collection = preview.kind === "collection";
|
|
298
|
+
const first = day(preview.earliestListeningAt);
|
|
299
|
+
const last = day(preview.latestListeningAt);
|
|
300
|
+
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.",
|
|
303
|
+
paragraphs: [
|
|
304
|
+
clean(preview.sourceLabel) || "Your history",
|
|
305
|
+
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."
|
|
310
|
+
: 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),
|
|
316
|
+
...(preview.sampleTracks ?? []).map((track) => ` ${clean(track)}`),
|
|
317
|
+
].filter(Boolean),
|
|
318
|
+
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,
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
default: return {
|
|
325
|
+
title: "Bring your music",
|
|
326
|
+
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.",
|
|
329
|
+
],
|
|
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],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
handleInput(data) {
|
|
336
|
+
if (this.state.page === "working") return;
|
|
337
|
+
if (data.includes("\x1b[200~")) this.pasteActive = true;
|
|
338
|
+
if (this.pasteActive) {
|
|
339
|
+
if (this.state.page === "file") this.editor.handleInput(data);
|
|
340
|
+
if (this.state.page === "client") this.clientEditor.handleInput(data);
|
|
341
|
+
const paste = this.pasteTail + data;
|
|
342
|
+
this.pasteActive = !paste.includes("\x1b[201~");
|
|
343
|
+
this.pasteTail = this.pasteActive ? paste.slice(-5) : "";
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const keys = getKeybindings();
|
|
347
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
348
|
+
this.emit(this.state.page === "start" ? "close" : "back");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (this.state.page === "file") {
|
|
352
|
+
if (keys.matches(data, "tui.input.submit") && !this.editor.isShowingAutocomplete()) {
|
|
353
|
+
this.emit("inspect", { path: this.getPath() });
|
|
354
|
+
} else this.editor.handleInput(data);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (this.state.page === "client") {
|
|
358
|
+
this.clientEditor.handleInput(data);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const { actions } = this.pageContent();
|
|
362
|
+
if (keys.matches(data, "tui.select.up") || keys.matches(data, "tui.select.down")) {
|
|
363
|
+
this.selected = (this.selected + (keys.matches(data, "tui.select.up") ? -1 : 1) + actions.length) % actions.length;
|
|
364
|
+
} else if (keys.matches(data, "tui.select.confirm")) {
|
|
365
|
+
this.emit(actions[this.selected]?.type ?? actions[0].type);
|
|
366
|
+
} else if (keys.matches(data, "tui.select.pageUp") || keys.matches(data, "tui.select.pageDown")) {
|
|
367
|
+
const direction = keys.matches(data, "tui.select.pageUp") ? -1 : 1;
|
|
368
|
+
this.contentOffset = Math.max(0, Math.min(this.contentMaxOffset,
|
|
369
|
+
this.contentOffset + direction * Math.max(1, this.contentPageSize - 1)));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
renderFile(width, height, theme) {
|
|
374
|
+
const client = this.state.page === "client";
|
|
375
|
+
const playlist = ["qq_music", "netease"].includes(this.state.provider);
|
|
376
|
+
const input = client ? this.clientEditor : this.editor;
|
|
377
|
+
const error = clean(this.state.error?.message ?? this.state.error);
|
|
378
|
+
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");
|
|
385
|
+
const budget = Math.max(1, height - header.length - errorLines.length - 1);
|
|
386
|
+
input.setAutocompleteMaxVisible(Math.max(1, Math.min(4, budget - 8)));
|
|
387
|
+
let editor = input.render(width);
|
|
388
|
+
if (editor.length > budget) {
|
|
389
|
+
const tailSize = input.isShowingAutocomplete()
|
|
390
|
+
? Math.min(input.getAutocompleteMaxVisible() + 1, Math.max(0, budget - 1)) : 0;
|
|
391
|
+
const tail = tailSize ? editor.slice(-tailSize) : [];
|
|
392
|
+
const core = tailSize ? editor.slice(0, -tailSize) : editor;
|
|
393
|
+
const available = budget - tail.length;
|
|
394
|
+
const cursor = core.findIndex((line) => line.includes(CURSOR_MARKER));
|
|
395
|
+
const start = Math.max(0, Math.min(core.length - available, cursor - available + 1));
|
|
396
|
+
editor = [...core.slice(start, start + available), ...tail];
|
|
397
|
+
}
|
|
398
|
+
return [...header, ...editor.map(safeEditorLine), ...errorLines, hint];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
render(width) {
|
|
402
|
+
const columns = Math.max(1, Math.floor(width));
|
|
403
|
+
const requestedRows = Number(this.getRows());
|
|
404
|
+
const height = Number.isFinite(requestedRows) ? Math.max(0, Math.floor(requestedRows)) : 0;
|
|
405
|
+
if (!height) return [];
|
|
406
|
+
const inset = columns >= 8 ? 1 : 0;
|
|
407
|
+
const inner = Math.max(1, columns - inset * 2);
|
|
408
|
+
const theme = this.getTheme();
|
|
409
|
+
let lines;
|
|
410
|
+
if (["file", "client"].includes(this.state.page)) {
|
|
411
|
+
lines = this.renderFile(inner, height, theme);
|
|
412
|
+
} 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."));
|
|
416
|
+
} else {
|
|
417
|
+
const page = this.pageContent();
|
|
418
|
+
const spacious = height >= 17;
|
|
419
|
+
const header = [theme.bold(page.title), ...(page.notice ? [theme.accent(page.notice)] : []), ...(spacious ? [""] : [])];
|
|
420
|
+
const error = clean(this.state.error?.message ?? this.state.error);
|
|
421
|
+
const message = clean(this.state.message);
|
|
422
|
+
const paragraphs = [
|
|
423
|
+
...(error ? wrapTextWithAnsi(error, inner).map(theme.error) : []),
|
|
424
|
+
...(message ? wrapTextWithAnsi(message, inner).map(theme.accent) : []),
|
|
425
|
+
...page.paragraphs.flatMap((text) => wrapTextWithAnsi(clean(text), inner).map(theme.muted)),
|
|
426
|
+
];
|
|
427
|
+
const capacity = Math.max(0, height - header.length - page.actions.length - 1 - (spacious ? 1 : 0));
|
|
428
|
+
this.contentPageSize = Math.max(1, capacity);
|
|
429
|
+
this.contentMaxOffset = Math.max(0, paragraphs.length - capacity);
|
|
430
|
+
this.contentOffset = Math.min(this.contentOffset, this.contentMaxOffset);
|
|
431
|
+
this.selected = Math.min(this.selected, page.actions.length - 1);
|
|
432
|
+
const actions = page.actions.map(({ label }, index) => {
|
|
433
|
+
const selected = index === this.selected;
|
|
434
|
+
const text = fit((selected ? "› " : " ") + label, inner);
|
|
435
|
+
return selected ? theme.inverse(theme.bold(text)) : theme.text(text);
|
|
436
|
+
});
|
|
437
|
+
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";
|
|
440
|
+
lines = [...header, ...paragraphs.slice(this.contentOffset, this.contentOffset + capacity),
|
|
441
|
+
...(spacious ? [""] : []), ...actions, theme.faint(hint)];
|
|
442
|
+
}
|
|
443
|
+
return Array.from({ length: height }, (_, row) => paint(" ".repeat(inset) + (lines[row] ?? ""), columns, theme));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const IMPORT_GUIDE_URLS = Object.freeze({
|
|
2
|
+
spotify: "https://www.spotify.com/account/privacy/",
|
|
3
|
+
spotifySetup: "https://developer.spotify.com/dashboard",
|
|
4
|
+
apple: "https://support.apple.com/guide/music/mus27cd5060f/mac",
|
|
5
|
+
applePrivacy: "https://privacy.apple.com/",
|
|
6
|
+
youtube: "https://takeout.google.com/",
|
|
7
|
+
qq: "https://y.qq.com/",
|
|
8
|
+
netease: "https://music.163.com/",
|
|
9
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readListenBrainzHistoryFile } from "../../integrations/listenbrainz/history-file.mjs";
|
|
2
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
3
|
+
|
|
4
|
+
export function listenBrainzHelpText() {
|
|
5
|
+
return `# Moondog ListenBrainz
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
|
|
9
|
+
- \`moondog listenbrainz import-history <listen-history.json> [--json]\`
|
|
10
|
+
|
|
11
|
+
Accepted files are official ListenBrainz GET-listens responses or \`single\` and \`import\` submission JSON.
|
|
12
|
+
The import is local and offline.
|
|
13
|
+
Moondog does not retain the ListenBrainz username, client metadata, URLs, tags, or raw JSON.`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commandError(message) {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.code = "listenbrainz_command_invalid";
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeLine(stream, value = "") {
|
|
23
|
+
stream.write(`${sanitizeTerminalText(value)}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArguments(args, inheritedJson) {
|
|
27
|
+
const values = [...(args ?? [])];
|
|
28
|
+
const jsonIndexes = values
|
|
29
|
+
.map((value, index) => (value === "--json" ? index : -1))
|
|
30
|
+
.filter((index) => index !== -1);
|
|
31
|
+
if (
|
|
32
|
+
jsonIndexes.length > 1 ||
|
|
33
|
+
(jsonIndexes.length === 1 && jsonIndexes[0] !== values.length - 1)
|
|
34
|
+
) {
|
|
35
|
+
throw commandError(
|
|
36
|
+
"The --json option must appear once at the end of the ListenBrainz command.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const json = Boolean(inheritedJson || jsonIndexes.length === 1);
|
|
40
|
+
if (jsonIndexes.length === 1) values.pop();
|
|
41
|
+
const action = values[0];
|
|
42
|
+
const rest = values.slice(1);
|
|
43
|
+
if (action === "-h" || action === "--help") {
|
|
44
|
+
return { action: "help", rest, json };
|
|
45
|
+
}
|
|
46
|
+
return { action, rest, json };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function exactArguments(action, rest, count, usage) {
|
|
50
|
+
if (rest.length !== count) {
|
|
51
|
+
throw commandError(`Usage: moondog listenbrainz ${usage ?? action}.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatImport(value) {
|
|
56
|
+
return [
|
|
57
|
+
"# Your ListenBrainz history is in",
|
|
58
|
+
`- ${value.input_records ?? 0} listens in the file, from ${value.earliest_occurred_at?.slice(0, 10) ?? "an unknown date"} to ${value.latest_occurred_at?.slice(0, 10) ?? "an unknown date"}`,
|
|
59
|
+
value.already_imported
|
|
60
|
+
? "- You imported this file before, so nothing new was added"
|
|
61
|
+
: `- ${value.inserted_events ?? 0} new${value.duplicate_events ? `, ${value.duplicate_events} I already had` : ""}`,
|
|
62
|
+
`- ${value.inserted_track_refs ?? 0} songs new to your profile; ListenBrainz matched ${value.mapped_track_refs ?? 0} to MusicBrainz`,
|
|
63
|
+
`- Listening time is known for ${value.events_with_played_ms ?? 0} of them`,
|
|
64
|
+
"- The file itself wasn't copied, and no account details were kept",
|
|
65
|
+
`- File fingerprint: ${value.archive_sha256 ?? "unavailable"}`,
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runListenBrainzCommand({
|
|
70
|
+
args = [],
|
|
71
|
+
json = false,
|
|
72
|
+
stdout = process.stdout,
|
|
73
|
+
historyStore = null,
|
|
74
|
+
subjectId = null,
|
|
75
|
+
now = Date.now,
|
|
76
|
+
historyFileImporter = readListenBrainzHistoryFile,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const options = parseArguments(args, json);
|
|
79
|
+
const action = options.action;
|
|
80
|
+
if (!action || action === "help") {
|
|
81
|
+
exactArguments("help", options.rest, 0, "help");
|
|
82
|
+
writeLine(stdout, listenBrainzHelpText());
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action !== "import-history") {
|
|
86
|
+
throw commandError(`Unknown ListenBrainz command: ${action}`);
|
|
87
|
+
}
|
|
88
|
+
exactArguments(
|
|
89
|
+
action,
|
|
90
|
+
options.rest,
|
|
91
|
+
1,
|
|
92
|
+
"import-history <listen-history.json>",
|
|
93
|
+
);
|
|
94
|
+
if (!historyStore || typeof historyStore.ingestImport !== "function") {
|
|
95
|
+
throw commandError("ListenBrainz history storage is unavailable.");
|
|
96
|
+
}
|
|
97
|
+
if (typeof subjectId !== "string" || !subjectId.trim()) {
|
|
98
|
+
throw commandError(
|
|
99
|
+
"A trusted local music subject is required for ListenBrainz history import.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const currentTime = now();
|
|
103
|
+
if (!Number.isFinite(currentTime)) {
|
|
104
|
+
throw commandError("ListenBrainz import time is unavailable.");
|
|
105
|
+
}
|
|
106
|
+
const bundle = await historyFileImporter({
|
|
107
|
+
filePath: options.rest[0],
|
|
108
|
+
subjectId,
|
|
109
|
+
capturedAt: new Date(currentTime).toISOString(),
|
|
110
|
+
});
|
|
111
|
+
const receipt = historyStore.ingestImport(bundle);
|
|
112
|
+
const result = {
|
|
113
|
+
provider: "listenbrainz",
|
|
114
|
+
...receipt,
|
|
115
|
+
mapped_track_refs: bundle.track_refs.filter(
|
|
116
|
+
(track) => track.identity_status === "resolved",
|
|
117
|
+
).length,
|
|
118
|
+
events_with_played_ms: bundle.listening_events.filter(
|
|
119
|
+
(event) => Number.isSafeInteger(event.played_ms),
|
|
120
|
+
).length,
|
|
121
|
+
source_file_retained: false,
|
|
122
|
+
direct_account_identifiers_retained: false,
|
|
123
|
+
};
|
|
124
|
+
writeLine(
|
|
125
|
+
stdout,
|
|
126
|
+
options.json ? JSON.stringify(result, null, 2) : formatImport(result),
|
|
127
|
+
);
|
|
128
|
+
return result;
|
|
129
|
+
}
|