@audiofool/moondog 0.1.0 → 0.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +153 -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 +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -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 +123 -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 +321 -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 +8 -4
- 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 +407 -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 +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -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 +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -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 +2204 -605
- 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 +160 -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 +46 -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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Plays the agent's Apple Music previews from the terminal. Each 30-second preview is
|
|
2
|
+
// downloaded to a temporary file and played by the system's own player, one after another.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { access, constants, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
// Players that play one file without a window and exit at its end, in order of preference.
|
|
10
|
+
// None of them may read or write the terminal: the listening room owns it.
|
|
11
|
+
const systemPlayers = {
|
|
12
|
+
darwin: [{ command: "afplay", args: [] }],
|
|
13
|
+
linux: [
|
|
14
|
+
{ command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] },
|
|
15
|
+
{ command: "mpv", args: ["--no-video", "--no-terminal"] },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
const longestQueue = 30;
|
|
19
|
+
const largestPreview = 10 * 1024 * 1024;
|
|
20
|
+
// Some routes to Apple's audio host take over 20 seconds for one preview.
|
|
21
|
+
const downloadTimeoutMs = 60_000;
|
|
22
|
+
|
|
23
|
+
// The same addresses the website's player accepts.
|
|
24
|
+
const https = (value, allowed) => {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(value);
|
|
27
|
+
return url.protocol === "https:" && allowed(url.hostname) ? url.href : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const previewHost = (host) => host === "audio-ssl.itunes.apple.com";
|
|
33
|
+
const linkHost = (host) => host === "music.apple.com";
|
|
34
|
+
|
|
35
|
+
/** The first system player on PATH that can play a preview, or null. */
|
|
36
|
+
export async function findSystemPlayer({ platform = process.platform, environment = process.env } = {}) {
|
|
37
|
+
const folders = String(environment.PATH ?? "").split(path.delimiter).filter((folder) => path.isAbsolute(folder));
|
|
38
|
+
for (const { command, args } of systemPlayers[platform] ?? []) {
|
|
39
|
+
for (const folder of folders) {
|
|
40
|
+
const file = path.join(folder, command);
|
|
41
|
+
try {
|
|
42
|
+
if (!(await stat(file)).isFile()) continue;
|
|
43
|
+
await access(file, constants.X_OK);
|
|
44
|
+
return { command: file, args };
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The listening room's preview player, or null when this system has no player.
|
|
53
|
+
* play(tracks, { album }) replaces the queue. subscribe(listener) reports a new queue,
|
|
54
|
+
* a song still loading, a song that could not load, each song as it starts, and the
|
|
55
|
+
* end of playback.
|
|
56
|
+
*/
|
|
57
|
+
export async function createTerminalPreviewPlayer({
|
|
58
|
+
platform,
|
|
59
|
+
environment,
|
|
60
|
+
fetchImpl = globalThis.fetch,
|
|
61
|
+
temporaryRoot = tmpdir(),
|
|
62
|
+
} = {}) {
|
|
63
|
+
const player = await findSystemPlayer({ platform, environment });
|
|
64
|
+
if (!player) return null;
|
|
65
|
+
const listeners = new Set();
|
|
66
|
+
let folder = null;
|
|
67
|
+
let files = 0;
|
|
68
|
+
let queue = null;
|
|
69
|
+
let child = null;
|
|
70
|
+
let closed = false;
|
|
71
|
+
|
|
72
|
+
const emit = (event) => {
|
|
73
|
+
for (const listener of listeners) {
|
|
74
|
+
try { listener(event); } catch {}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const removeFolder = () => {
|
|
78
|
+
if (folder) rmSync(folder, { recursive: true, force: true });
|
|
79
|
+
folder = null;
|
|
80
|
+
};
|
|
81
|
+
// A process that ends without closing the player still stops the sound and leaves no files.
|
|
82
|
+
const onExit = () => {
|
|
83
|
+
child?.kill();
|
|
84
|
+
removeFolder();
|
|
85
|
+
};
|
|
86
|
+
process.once("exit", onExit);
|
|
87
|
+
|
|
88
|
+
const download = async (url, signal) => {
|
|
89
|
+
signal.throwIfAborted();
|
|
90
|
+
folder ??= mkdtempSync(path.join(temporaryRoot, "moondog-previews-"));
|
|
91
|
+
const file = path.join(folder, `${(files += 1)}.m4a`);
|
|
92
|
+
const response = await fetchImpl(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(downloadTimeoutMs)]) });
|
|
93
|
+
if (!response.ok || !response.body) throw new Error(`Apple Music answered ${response.status}`);
|
|
94
|
+
const chunks = [];
|
|
95
|
+
let size = 0;
|
|
96
|
+
for await (const chunk of response.body) {
|
|
97
|
+
if ((size += chunk.byteLength) > largestPreview) throw new Error("The preview is too large");
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
}
|
|
100
|
+
signal.throwIfAborted();
|
|
101
|
+
await writeFile(file, Buffer.concat(chunks));
|
|
102
|
+
return file;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const playFile = (file) => new Promise((resolve) => {
|
|
106
|
+
const playing = spawn(player.command, [...player.args, file], { stdio: "ignore" });
|
|
107
|
+
child = playing;
|
|
108
|
+
const finish = () => {
|
|
109
|
+
if (child === playing) child = null;
|
|
110
|
+
resolve();
|
|
111
|
+
};
|
|
112
|
+
playing.once("error", finish);
|
|
113
|
+
playing.once("exit", finish);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const start = (tracks) => {
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const downloads = new Map();
|
|
119
|
+
// skip() ends the song playing, or the download of the song about to play.
|
|
120
|
+
const current = { controller, skipDownload: null };
|
|
121
|
+
queue = current;
|
|
122
|
+
const fetchFile = (index) => {
|
|
123
|
+
if (!downloads.has(index)) {
|
|
124
|
+
const own = new AbortController();
|
|
125
|
+
const pending = { ready: false, abort: () => own.abort(), skipped: () => own.signal.aborted };
|
|
126
|
+
pending.file = download(tracks[index].preview, AbortSignal.any([controller.signal, own.signal]))
|
|
127
|
+
.catch(() => null)
|
|
128
|
+
.then((file) => { pending.ready = true; return file; });
|
|
129
|
+
downloads.set(index, pending);
|
|
130
|
+
}
|
|
131
|
+
return downloads.get(index);
|
|
132
|
+
};
|
|
133
|
+
void (async () => {
|
|
134
|
+
try {
|
|
135
|
+
for (let index = 0; index < tracks.length; index += 1) {
|
|
136
|
+
const { title, artist_credit, catalog_url } = tracks[index];
|
|
137
|
+
const song = { track: { title, artist_credit, catalog_url }, position: index + 1, count: tracks.length };
|
|
138
|
+
const pending = fetchFile(index);
|
|
139
|
+
if (!pending.ready) emit({ type: "loading", ...song });
|
|
140
|
+
current.skipDownload = pending.abort;
|
|
141
|
+
const file = await pending.file;
|
|
142
|
+
current.skipDownload = null;
|
|
143
|
+
if (controller.signal.aborted) break;
|
|
144
|
+
// The next preview downloads while this one plays.
|
|
145
|
+
if (index + 1 < tracks.length) void fetchFile(index + 1);
|
|
146
|
+
if (!file) {
|
|
147
|
+
// An error, a timeout, or an oversized file; a listener's /skip is not a failure.
|
|
148
|
+
if (!pending.skipped()) emit({ type: "failed", ...song });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
emit({ type: "playing", ...song });
|
|
152
|
+
await playFile(file);
|
|
153
|
+
await rm(file, { force: true });
|
|
154
|
+
if (controller.signal.aborted) break;
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
await Promise.all([...downloads.values()].map(async (pending) => {
|
|
158
|
+
const file = await pending.file;
|
|
159
|
+
if (file) await rm(file, { force: true });
|
|
160
|
+
}));
|
|
161
|
+
if (queue === current) {
|
|
162
|
+
queue = null;
|
|
163
|
+
emit({ type: "stopped" });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const stop = () => {
|
|
170
|
+
if (!queue) return false;
|
|
171
|
+
queue.controller.abort();
|
|
172
|
+
queue = null;
|
|
173
|
+
child?.kill();
|
|
174
|
+
child = null;
|
|
175
|
+
emit({ type: "stopped" });
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
// A new set of previews replaces the queue and starts from its first song.
|
|
181
|
+
play(tracks, { album } = {}) {
|
|
182
|
+
if (closed) return;
|
|
183
|
+
const accepted = (Array.isArray(tracks) ? tracks : []).map((track) => ({
|
|
184
|
+
title: String(track?.title ?? "").trim(),
|
|
185
|
+
artist_credit: String(track?.artist_credit ?? "").trim(),
|
|
186
|
+
catalog_url: https(track?.catalog_url, linkHost),
|
|
187
|
+
preview: https(track?.preview_url, previewHost),
|
|
188
|
+
})).filter((track) => track.title && track.catalog_url && track.preview).slice(0, longestQueue);
|
|
189
|
+
if (!accepted.length) return;
|
|
190
|
+
stop();
|
|
191
|
+
emit({
|
|
192
|
+
type: "queue",
|
|
193
|
+
tracks: accepted.map(({ title, artist_credit, catalog_url }) => ({ title, artist_credit, catalog_url })),
|
|
194
|
+
album: album?.title
|
|
195
|
+
? { title: String(album.title), artist_credit: String(album.artist_credit ?? ""), catalog_url: https(album.catalog_url, linkHost) }
|
|
196
|
+
: null,
|
|
197
|
+
});
|
|
198
|
+
start(accepted);
|
|
199
|
+
},
|
|
200
|
+
// Ends the song now playing, or still loading; the next one starts after it.
|
|
201
|
+
skip() {
|
|
202
|
+
if (!queue) return false;
|
|
203
|
+
if (child) child.kill();
|
|
204
|
+
else queue.skipDownload?.();
|
|
205
|
+
return true;
|
|
206
|
+
},
|
|
207
|
+
stop,
|
|
208
|
+
subscribe(listener) {
|
|
209
|
+
listeners.add(listener);
|
|
210
|
+
return () => listeners.delete(listener);
|
|
211
|
+
},
|
|
212
|
+
close() {
|
|
213
|
+
if (closed) return;
|
|
214
|
+
closed = true;
|
|
215
|
+
stop();
|
|
216
|
+
listeners.clear();
|
|
217
|
+
process.removeListener("exit", onExit);
|
|
218
|
+
removeFolder();
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
import { musicHash, musicText, prepareMusicBundle } from "../../profile/music-import-bundle.mjs";
|
|
4
|
+
import { readLastfmConfiguration } from "./settings.mjs";
|
|
5
|
+
|
|
6
|
+
// A listener's scrobbles (every play Last.fm counted) and loved tracks, read by username.
|
|
7
|
+
// Last.fm lists scrobbles newest first; the read stops at the moment it started, so new
|
|
8
|
+
// scrobbles can't shift the pages underneath it.
|
|
9
|
+
export const LASTFM_LIMITS = Object.freeze({
|
|
10
|
+
maximumScrobbles: 500_000,
|
|
11
|
+
maximumLovedTracks: 20_000,
|
|
12
|
+
pageSize: 200,
|
|
13
|
+
// Last.fm can suspend a key that keeps making several calls a second, so request starts
|
|
14
|
+
// are paced across all readers. A full page can take Last.fm 2 to 17 seconds to answer;
|
|
15
|
+
// three readers hide part of that wait, and more were not faster in practice.
|
|
16
|
+
requestIntervalMs: 250,
|
|
17
|
+
concurrency: 3,
|
|
18
|
+
attempts: 5,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export class LastfmError extends Error {
|
|
22
|
+
constructor(message, code) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "LastfmError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fail = (message, code) => { throw new LastfmError(message, code); };
|
|
30
|
+
|
|
31
|
+
/** A Last.fm username, or the profile link it appears in (last.fm/user/<name>). */
|
|
32
|
+
export function lastfmUsername(input) {
|
|
33
|
+
let value = String(input ?? "").trim();
|
|
34
|
+
const link = value.match(/^(?:https?:\/\/)?(?:www\.)?last\.fm\/(?:[a-z]{2}\/)?user\/([^/?#\s]+)/iu);
|
|
35
|
+
if (link) {
|
|
36
|
+
try { value = decodeURIComponent(link[1]); } catch { value = ""; }
|
|
37
|
+
}
|
|
38
|
+
value = value.replace(/^@/u, "");
|
|
39
|
+
if (!/^[^\s/\\?#\u0000-\u001f\u007f]{1,64}$/u.test(value)) {
|
|
40
|
+
fail("Type your Last.fm username, as in your profile link: last.fm/user/<username>.", "lastfm_username_invalid");
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const list = (value) => (Array.isArray(value) ? value : value && typeof value === "object" ? [value] : []);
|
|
46
|
+
// Last.fm writes names as plain strings, as {"#text"}, or as {"name"} depending on the method.
|
|
47
|
+
const name = (value) => musicText(typeof value === "string" ? value : value?.["#text"] ?? value?.name ?? "");
|
|
48
|
+
const fold = (value) => value.normalize("NFKC").toLocaleLowerCase("und");
|
|
49
|
+
// Last.fm identifies a track by its artist and title; the album changes from scrobble to scrobble.
|
|
50
|
+
const trackKey = (artist, title) => musicHash(JSON.stringify(["lastfm-track-v1", fold(artist), fold(title)]));
|
|
51
|
+
const retryable = new Set([8, 11, 16, 29]);
|
|
52
|
+
// The list each paged method must answer with.
|
|
53
|
+
const listFor = { "user.getrecenttracks": "recenttracks", "user.getlovedtracks": "lovedtracks" };
|
|
54
|
+
|
|
55
|
+
/** Spaces request starts at least `interval` apart, whichever reader asks. */
|
|
56
|
+
function requestPacer(interval, sleep, now, signal) {
|
|
57
|
+
let next = 0;
|
|
58
|
+
return async () => {
|
|
59
|
+
const at = now();
|
|
60
|
+
const wait = Math.max(0, next - at);
|
|
61
|
+
next = Math.max(at, next) + interval;
|
|
62
|
+
if (wait) await sleep(wait, undefined, { signal });
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function lastfmCall({ root, apiKey, method, params, fetchImpl, signal, sleep, pace, attempts }) {
|
|
67
|
+
const url = new URL(root);
|
|
68
|
+
url.search = new URLSearchParams({ method, ...params, api_key: apiKey, format: "json" }).toString();
|
|
69
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
await pace();
|
|
72
|
+
const timeout = AbortSignal.timeout(20_000);
|
|
73
|
+
let response;
|
|
74
|
+
let body = null;
|
|
75
|
+
try {
|
|
76
|
+
response = await fetchImpl(url, {
|
|
77
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
78
|
+
headers: { "User-Agent": "Moondog (+https://github.com/Audiofool934/moondog)" },
|
|
79
|
+
});
|
|
80
|
+
body = await response.json().catch(() => null);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
if (attempt < attempts) { await sleep(500 * 2 ** attempt, undefined, { signal }); continue; }
|
|
84
|
+
fail("Could not reach Last.fm. Check your connection and try again.", "lastfm_unreachable");
|
|
85
|
+
}
|
|
86
|
+
const code = Number(body?.error) || 0;
|
|
87
|
+
const list = listFor[method];
|
|
88
|
+
if (response.ok && body && !code && (!list || (body[list] && typeof body[list] === "object"))) return body;
|
|
89
|
+
// Last.fm sometimes answers 200 with a page that isn't its JSON; that is retried like a busy answer,
|
|
90
|
+
// so a broken first page can't pass for an empty history.
|
|
91
|
+
const unreadable = response.ok && !code;
|
|
92
|
+
if (unreadable || retryable.has(code) || response.status === 429 || response.status >= 500) {
|
|
93
|
+
if (attempt < attempts) { await sleep((code === 29 || response.status === 429 ? 2_000 : 500) * 2 ** attempt, undefined, { signal }); continue; }
|
|
94
|
+
if (code === 29 || response.status === 429) fail("Last.fm asked Moondog to slow down. Wait a minute, then try again.", "lastfm_rate_limited");
|
|
95
|
+
fail("Last.fm is having trouble right now. Try again in a little while.", "lastfm_unavailable");
|
|
96
|
+
}
|
|
97
|
+
if (code === 6) fail("Last.fm doesn't know that username. Check it against your profile link.", "lastfm_user_not_found");
|
|
98
|
+
if (code === 17) fail("This profile hides its recent listening. In Last.fm Settings > Privacy, turn off \"Hide recent listening information\", then try again.", "lastfm_user_private");
|
|
99
|
+
if (code === 10 || code === 26) fail("Last.fm didn't accept the API key. Set up Last.fm access again.", "lastfm_key_invalid");
|
|
100
|
+
fail(`Last.fm couldn't answer (${code ? `error ${code}` : `HTTP ${response.status}`}). Try again later.`, "lastfm_failed");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pages 2..pages of a paged method, a few at a time; page 1 is already in hand.
|
|
106
|
+
* The first page that fails stops the others, and the error waits until they have settled.
|
|
107
|
+
*/
|
|
108
|
+
async function remainingPages(pages, read, concurrency, stop) {
|
|
109
|
+
let next = 2;
|
|
110
|
+
let failure;
|
|
111
|
+
const worker = async () => {
|
|
112
|
+
while (next <= pages && !failure) {
|
|
113
|
+
try {
|
|
114
|
+
await read(next++);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
failure ??= error;
|
|
117
|
+
stop.abort(failure);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
await Promise.all(Array.from({ length: Math.max(0, Math.min(concurrency, pages - 1)) }, worker));
|
|
122
|
+
if (failure) throw failure;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readLastfmHistory({
|
|
126
|
+
username,
|
|
127
|
+
subjectId,
|
|
128
|
+
capturedAt = new Date().toISOString(),
|
|
129
|
+
configuration,
|
|
130
|
+
environment = process.env,
|
|
131
|
+
fetchImpl = fetch,
|
|
132
|
+
signal,
|
|
133
|
+
onProgress = () => {},
|
|
134
|
+
sleep = delay,
|
|
135
|
+
now = Date.now,
|
|
136
|
+
limits = LASTFM_LIMITS,
|
|
137
|
+
} = {}) {
|
|
138
|
+
const user = lastfmUsername(username);
|
|
139
|
+
const access = configuration ?? await readLastfmConfiguration(environment);
|
|
140
|
+
if (!access?.apiKey) fail("Moondog needs a Last.fm API key before it can read your history.", "lastfm_key_missing");
|
|
141
|
+
// One read shares one pace and one stop, so a failed page also stops its siblings.
|
|
142
|
+
const stop = new AbortController();
|
|
143
|
+
const readSignal = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
|
|
144
|
+
const pace = requestPacer(limits.requestIntervalMs, sleep, now, readSignal);
|
|
145
|
+
const call = (method, params) => lastfmCall({
|
|
146
|
+
root: access.root, apiKey: access.apiKey, method, params: { user, ...params },
|
|
147
|
+
fetchImpl, signal: readSignal, sleep, pace, attempts: limits.attempts,
|
|
148
|
+
});
|
|
149
|
+
const until = Math.floor(Date.parse(capturedAt) / 1_000);
|
|
150
|
+
const pageSize = limits.pageSize;
|
|
151
|
+
|
|
152
|
+
const scrobbles = [];
|
|
153
|
+
const takeScrobbles = (body) => {
|
|
154
|
+
for (const track of list(body?.recenttracks?.track)) {
|
|
155
|
+
const at = Number(track?.date?.uts);
|
|
156
|
+
// The track playing right now has no date and isn't a scrobble yet.
|
|
157
|
+
if (track?.["@attr"]?.nowplaying === "true" || !Number.isSafeInteger(at) || at <= 0 || at > until) continue;
|
|
158
|
+
const title = name(track.name);
|
|
159
|
+
const artist = name(track.artist);
|
|
160
|
+
if (title && artist) scrobbles.push({ at, title, artist, album: name(track.album) });
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const first = await call("user.getrecenttracks", { limit: pageSize, page: 1, to: until });
|
|
164
|
+
const attributes = first?.recenttracks?.["@attr"] ?? {};
|
|
165
|
+
const total = Number(attributes.total) || 0;
|
|
166
|
+
const pages = Math.min(Number(attributes.totalPages) || 1, Math.ceil(limits.maximumScrobbles / pageSize));
|
|
167
|
+
const expected = Math.min(total, limits.maximumScrobbles);
|
|
168
|
+
takeScrobbles(first);
|
|
169
|
+
onProgress({ stage: "scrobbles", count: scrobbles.length, total: expected });
|
|
170
|
+
await remainingPages(pages, async (page) => {
|
|
171
|
+
takeScrobbles(await call("user.getrecenttracks", { limit: pageSize, page, to: until }));
|
|
172
|
+
onProgress({ stage: "scrobbles", count: scrobbles.length, total: expected });
|
|
173
|
+
}, limits.concurrency, stop);
|
|
174
|
+
|
|
175
|
+
const loved = [];
|
|
176
|
+
const takeLoved = (body) => {
|
|
177
|
+
for (const track of list(body?.lovedtracks?.track)) {
|
|
178
|
+
const title = name(track?.name);
|
|
179
|
+
const artist = name(track?.artist);
|
|
180
|
+
if (title && artist) loved.push({ title, artist });
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const lovedFirst = await call("user.getlovedtracks", { limit: pageSize, page: 1 });
|
|
184
|
+
const lovedPages = Math.min(Number(lovedFirst?.lovedtracks?.["@attr"]?.totalPages) || 1, Math.ceil(limits.maximumLovedTracks / pageSize));
|
|
185
|
+
takeLoved(lovedFirst);
|
|
186
|
+
onProgress({ stage: "loved", count: loved.length });
|
|
187
|
+
await remainingPages(lovedPages, async (page) => {
|
|
188
|
+
takeLoved(await call("user.getlovedtracks", { limit: pageSize, page }));
|
|
189
|
+
onProgress({ stage: "loved", count: loved.length });
|
|
190
|
+
}, limits.concurrency, stop);
|
|
191
|
+
|
|
192
|
+
if (!scrobbles.length && !loved.length) {
|
|
193
|
+
fail("This Last.fm profile has no scrobbles or loved tracks yet.", "lastfm_history_empty");
|
|
194
|
+
}
|
|
195
|
+
scrobbles.sort((left, right) => left.at - right.at || trackKey(left.artist, left.title).localeCompare(trackKey(right.artist, right.title)));
|
|
196
|
+
const rows = [
|
|
197
|
+
...scrobbles.map((row) => ({
|
|
198
|
+
id: trackKey(row.artist, row.title), title: row.title, artist: row.artist, album: row.album,
|
|
199
|
+
occurredAt: new Date(row.at * 1_000).toISOString(), member: "lastfm-scrobbles.json",
|
|
200
|
+
})),
|
|
201
|
+
...loved.map((row) => ({ id: trackKey(row.artist, row.title), title: row.title, artist: row.artist, member: "lastfm-loved-tracks.json" })),
|
|
202
|
+
];
|
|
203
|
+
// The fingerprint covers exactly what was read, so the same history imports once.
|
|
204
|
+
const bytes = Buffer.from(JSON.stringify({ user, until, scrobbles, loved }));
|
|
205
|
+
const truncated = total > scrobbles.length && pages * pageSize >= limits.maximumScrobbles;
|
|
206
|
+
const prepared = prepareMusicBundle({
|
|
207
|
+
provider: "lastfm", subjectId, capturedAt, rows,
|
|
208
|
+
digest: musicHash(bytes), size: bytes.length,
|
|
209
|
+
members: ["lastfm-scrobbles.json", ...(loved.length ? ["lastfm-loved-tracks.json"] : [])],
|
|
210
|
+
fileName: `last.fm/user/${user}`,
|
|
211
|
+
scopeNote: truncated
|
|
212
|
+
? `Your latest ${limits.maximumScrobbles.toLocaleString("en-US")} scrobbles, the most Moondog reads at once, and your loved tracks.`
|
|
213
|
+
: "Every scrobble Last.fm counted, and your loved tracks.",
|
|
214
|
+
});
|
|
215
|
+
prepared.preview.source = "lastfm";
|
|
216
|
+
prepared.preview.lovedTracks = loved.length;
|
|
217
|
+
prepared.preview.totalScrobbles = total;
|
|
218
|
+
prepared.preview.truncated = truncated;
|
|
219
|
+
prepared.preview.profileUrl = `https://www.last.fm/user/${encodeURIComponent(user)}`;
|
|
220
|
+
return prepared;
|
|
221
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { resolveMoondogAuthFile } from "../../runtime/pi/persistent-credential-store.mjs";
|
|
5
|
+
|
|
6
|
+
const SETTINGS_VERSION = 1;
|
|
7
|
+
// Last.fm API keys are 32 hexadecimal characters. Reading public history needs only the key, never the shared secret.
|
|
8
|
+
const apiKeyPattern = /^[a-f0-9]{32}$/u;
|
|
9
|
+
|
|
10
|
+
export const LASTFM_API_ROOT = "https://ws.audioscrobbler.com/2.0/";
|
|
11
|
+
|
|
12
|
+
// Moondog's own key, registered to the project. Reading public listening needs only this
|
|
13
|
+
// key, so it ships with the source; a listener's own key in settings or the environment wins.
|
|
14
|
+
export const LASTFM_PROJECT_API_KEY = "396133c344735725f65b322ece17a2da";
|
|
15
|
+
|
|
16
|
+
function settingsError(message, cause) {
|
|
17
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
18
|
+
error.name = "MoondogLastfmSettingsError";
|
|
19
|
+
error.code = "lastfm_settings_invalid";
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeLastfmApiKey(value) {
|
|
24
|
+
const key = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
25
|
+
if (!apiKeyPattern.test(key)) {
|
|
26
|
+
throw settingsError("A Last.fm API key is 32 letters and numbers. Copy the API key, not the shared secret.");
|
|
27
|
+
}
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveLastfmSettingsFile(environment = process.env) {
|
|
32
|
+
return path.join(path.dirname(resolveMoondogAuthFile(environment)), "lastfm.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Tests and local mirrors may point Moondog at another API root; only https, or http on this machine.
|
|
36
|
+
function apiRoot(environment) {
|
|
37
|
+
const configured = environment.MOONDOG_LASTFM_API_ROOT?.trim();
|
|
38
|
+
if (!configured) return LASTFM_API_ROOT;
|
|
39
|
+
let url;
|
|
40
|
+
try { url = new URL(configured); } catch { throw settingsError("MOONDOG_LASTFM_API_ROOT must be a URL."); }
|
|
41
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) {
|
|
42
|
+
throw settingsError("MOONDOG_LASTFM_API_ROOT must use https, or http on this machine.");
|
|
43
|
+
}
|
|
44
|
+
return url.href;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The key Moondog reads Last.fm with: the environment, then this machine's settings, then the project's own. */
|
|
48
|
+
export async function readLastfmConfiguration(environment = process.env) {
|
|
49
|
+
const root = apiRoot(environment);
|
|
50
|
+
const environmentKey = environment.MOONDOG_LASTFM_API_KEY?.trim();
|
|
51
|
+
if (environmentKey) return { apiKey: normalizeLastfmApiKey(environmentKey), source: "environment", root };
|
|
52
|
+
let source;
|
|
53
|
+
try {
|
|
54
|
+
source = await readFile(resolveLastfmSettingsFile(environment), "utf8");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error?.code !== "ENOENT") throw settingsError("Moondog could not read its Last.fm settings.", error);
|
|
57
|
+
}
|
|
58
|
+
if (source !== undefined) {
|
|
59
|
+
let document;
|
|
60
|
+
try { document = JSON.parse(source); } catch (error) { throw settingsError("Moondog's Last.fm settings are not valid JSON.", error); }
|
|
61
|
+
if (!document || typeof document !== "object" || document.version !== SETTINGS_VERSION) {
|
|
62
|
+
throw settingsError("Moondog's Last.fm settings are in an unknown format.");
|
|
63
|
+
}
|
|
64
|
+
return { apiKey: normalizeLastfmApiKey(document.api_key), source: "settings", root };
|
|
65
|
+
}
|
|
66
|
+
return LASTFM_PROJECT_API_KEY ? { apiKey: LASTFM_PROJECT_API_KEY, source: "moondog", root } : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function writeLastfmConfiguration(apiKey, environment = process.env) {
|
|
70
|
+
const normalized = normalizeLastfmApiKey(apiKey);
|
|
71
|
+
const settingsFile = resolveLastfmSettingsFile(environment);
|
|
72
|
+
await mkdir(path.dirname(settingsFile), { recursive: true, mode: 0o700 });
|
|
73
|
+
await writeFile(settingsFile, `${JSON.stringify({ version: SETTINGS_VERSION, api_key: normalized }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
74
|
+
return { apiKey: normalized, source: "settings", root: apiRoot(environment) };
|
|
75
|
+
}
|