@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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// NetEase Cloud Music through its official command line tool, ncm-cli (npm @music163/ncm-cli).
|
|
2
|
+
// ncm-cli keeps its own App ID, key and sign-in and plays through mpv; Moondog only runs its
|
|
3
|
+
// commands and reads their JSON. It serves listeners in mainland China: elsewhere, and for many
|
|
4
|
+
// original recordings, NetEase marks songs as not playable (`visible: false`), most often for
|
|
5
|
+
// copyright, and no membership changes that.
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { access, constants, stat } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const COMMAND_TIMEOUT_MS = 20_000;
|
|
11
|
+
// Where Homebrew and npm put ncm-cli, the node it runs on, and mpv, for a PATH that lacks them.
|
|
12
|
+
const EXTRA_FOLDERS = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
13
|
+
// play returns before mpv runs, so Moondog waits for the player to report the song.
|
|
14
|
+
const START_WAIT_MS = 8_000;
|
|
15
|
+
const START_POLL_MS = 250;
|
|
16
|
+
// ncm-cli's player daemon quits about three seconds after its last client leaves, even in the
|
|
17
|
+
// middle of a song, so Moondog reads its state this often while a song plays or is paused.
|
|
18
|
+
// state is read locally and sends NetEase nothing.
|
|
19
|
+
const KEEP_ALIVE_MS = 1_500;
|
|
20
|
+
const KNOWN_SONGS = 300;
|
|
21
|
+
|
|
22
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
23
|
+
|
|
24
|
+
const searchPath = (environment) => [...new Set([
|
|
25
|
+
...String(environment.PATH ?? "").split(path.delimiter).filter((folder) => path.isAbsolute(folder)),
|
|
26
|
+
...EXTRA_FOLDERS,
|
|
27
|
+
])];
|
|
28
|
+
|
|
29
|
+
/** The ncm-cli executable on PATH or in Homebrew's folders, or null. */
|
|
30
|
+
export async function findNcmCli({ environment = process.env } = {}) {
|
|
31
|
+
for (const folder of searchPath(environment)) {
|
|
32
|
+
const file = path.join(folder, "ncm-cli");
|
|
33
|
+
try {
|
|
34
|
+
if (!(await stat(file)).isFile()) continue;
|
|
35
|
+
await access(file, constants.X_OK);
|
|
36
|
+
return file;
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Runs one ncm-cli command and parses its JSON. ncm-cli reports most failures as
|
|
43
|
+
// { success: false, message } with exit status 0. play prints no JSON when it starts a song,
|
|
44
|
+
// so its answer is {}; the player's state says whether the song started.
|
|
45
|
+
function cliRunner({ environment }) {
|
|
46
|
+
let found;
|
|
47
|
+
return async (args) => {
|
|
48
|
+
found ??= findNcmCli({ environment });
|
|
49
|
+
const command = await found;
|
|
50
|
+
if (!command) throw failure("netease_cli_missing", "ncm-cli is not installed. Install it with: npm install -g @music163/ncm-cli");
|
|
51
|
+
const env = { ...environment, PATH: searchPath(environment).join(path.delimiter) };
|
|
52
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
53
|
+
execFile(command, args, { env, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, out) =>
|
|
54
|
+
(error && !out ? reject(failure("netease_failed", `ncm-cli ${args[0]} failed: ${String(error.message).slice(0, 200)}`)) : resolve(String(out ?? ""))));
|
|
55
|
+
});
|
|
56
|
+
const start = stdout.indexOf("{");
|
|
57
|
+
if (start < 0 && args[0] === "play") return {};
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(start >= 0 ? stdout.slice(start) : stdout);
|
|
60
|
+
} catch {
|
|
61
|
+
throw failure("netease_failed", `ncm-cli ${args[0]} returned no JSON.`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checked(result, what) {
|
|
67
|
+
const message = String(result?.message ?? "").trim();
|
|
68
|
+
if (result?.success === false || (result?.code !== undefined && result.code !== 200)) {
|
|
69
|
+
if (/请求总量超限/u.test(message)) throw failure("netease_rate_limited", "NetEase says today's request limit is used up (请求总量超限). Stop and tell the listener.");
|
|
70
|
+
if (/登录|login|token|授权/iu.test(message)) throw failure("netease_signed_out", `NetEase is not signed in (${message}). The listener can run: ncm-cli login`);
|
|
71
|
+
throw failure("netease_failed", `NetEase could not ${what}${message ? `: ${message}` : "."}`);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const https = (url) => (typeof url === "string" && /^http:\/\/[a-z0-9-]+\.music\.126\.net\//u.test(url) ? `https://${url.slice(7)}` : url ?? null);
|
|
77
|
+
|
|
78
|
+
function songOf(record) {
|
|
79
|
+
return {
|
|
80
|
+
id: String(record.id),
|
|
81
|
+
original_id: String(record.originalId),
|
|
82
|
+
title: String(record.name ?? ""),
|
|
83
|
+
artists: (Array.isArray(record.artists) ? record.artists : []).map((artist) => String(artist?.name ?? "")).filter(Boolean),
|
|
84
|
+
album: record.album?.name ? String(record.album.name) : null,
|
|
85
|
+
duration_ms: Number.isFinite(record.duration) ? Math.round(record.duration) : null,
|
|
86
|
+
image: https(record.coverImgUrl),
|
|
87
|
+
// NetEase's own word for whether the listener may play it: false most often means copyright.
|
|
88
|
+
playable: record.visible === true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fold = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("en-US").replace(/[\s\p{P}\p{S}]+/gu, "");
|
|
93
|
+
|
|
94
|
+
// The best playable recording of a named song: by the named artist before another artist's, and
|
|
95
|
+
// the exact title before a longer one such as "稻香(治愈版)".
|
|
96
|
+
function pick(songs, { title, artist }) {
|
|
97
|
+
const wantTitle = fold(title);
|
|
98
|
+
const wantArtist = fold(artist);
|
|
99
|
+
const exactTitle = (song) => fold(song.title) === wantTitle;
|
|
100
|
+
const titled = songs.filter((song) => exactTitle(song) || fold(song.title).startsWith(wantTitle));
|
|
101
|
+
const byArtist = (song) => !wantArtist || song.artists.some((name) => fold(name) === wantArtist);
|
|
102
|
+
const playable = titled.filter((song) => song.playable);
|
|
103
|
+
const own = playable.filter(byArtist);
|
|
104
|
+
const found = own.find(exactTitle) ?? own[0];
|
|
105
|
+
if (found) return { song: found, other_artist: false };
|
|
106
|
+
const other = playable.find(exactTitle) ?? playable[0];
|
|
107
|
+
if (other) return { song: other, other_artist: true };
|
|
108
|
+
return { song: null, unplayable: titled.length > 0 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const shortIntent = (intent) => String(intent ?? "").replace(/\s+/gu, " ").trim().slice(0, 60) || "搜索歌曲";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The NetEase player. check() reads whether ncm-cli is installed and signed in, and ready()
|
|
115
|
+
* answers from that. Moondog keeps the queue itself and plays each song with ncm-cli's play:
|
|
116
|
+
* ncm-cli's own queue needs the play client that its start handshake sometimes loses, and its
|
|
117
|
+
* queue file breaks under concurrent commands. While a song is loaded, Moondog reads the
|
|
118
|
+
* player's state every keepAliveMs, which keeps ncm-cli's daemon running and starts the next
|
|
119
|
+
* queued song when one ends.
|
|
120
|
+
*/
|
|
121
|
+
export function createNeteasePlayer({
|
|
122
|
+
environment = process.env,
|
|
123
|
+
run: runCli = cliRunner({ environment }),
|
|
124
|
+
wait = (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
125
|
+
keepAliveMs = KEEP_ALIVE_MS,
|
|
126
|
+
} = {}) {
|
|
127
|
+
// ncm-cli's commands share its state files, and two at once can corrupt them ("队列文件损坏"),
|
|
128
|
+
// so they run one at a time.
|
|
129
|
+
let line = Promise.resolve();
|
|
130
|
+
let running = 0;
|
|
131
|
+
const run = (args) => {
|
|
132
|
+
running += 1;
|
|
133
|
+
const result = line.then(() => runCli(args)).finally(() => { running -= 1; });
|
|
134
|
+
line = result.catch(() => {});
|
|
135
|
+
return result;
|
|
136
|
+
};
|
|
137
|
+
let status = { state: "unknown", message: null };
|
|
138
|
+
let queue = [];
|
|
139
|
+
let index = -1;
|
|
140
|
+
// The last state read while a song played, to tell the end of a song from a pause.
|
|
141
|
+
let last = null;
|
|
142
|
+
const known = new Map();
|
|
143
|
+
const remember = (songs) => {
|
|
144
|
+
for (const song of songs) {
|
|
145
|
+
known.delete(song.id);
|
|
146
|
+
known.set(song.id, song);
|
|
147
|
+
}
|
|
148
|
+
while (known.size > KNOWN_SONGS) known.delete(known.keys().next().value);
|
|
149
|
+
};
|
|
150
|
+
const titleOf = (now) => (typeof now?.title === "string" && now.title.trim() ? now.title.trim() : null);
|
|
151
|
+
const isSong = (now, song) => Boolean(song && titleOf(now)?.startsWith(song.title));
|
|
152
|
+
|
|
153
|
+
async function state() {
|
|
154
|
+
const result = checked(await run(["state"]), "read what is playing");
|
|
155
|
+
return result.state ?? {};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let keepAlive = null;
|
|
159
|
+
function stayConnected() {
|
|
160
|
+
if (keepAlive) return;
|
|
161
|
+
let idle = 0;
|
|
162
|
+
keepAlive = setInterval(async () => {
|
|
163
|
+
// A command already running keeps the daemon company.
|
|
164
|
+
if (running) return;
|
|
165
|
+
const now = await state().catch(() => null);
|
|
166
|
+
if (!now) return;
|
|
167
|
+
// The song that played to its end has stopped: the next queued one starts.
|
|
168
|
+
const ended = last && now.status !== "playing" && Number.isFinite(last.duration) && last.position >= last.duration - keepAliveMs / 1000 - 1.5;
|
|
169
|
+
if (ended && index + 1 < queue.length) {
|
|
170
|
+
await playAt(index + 1).catch(() => {});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (now.status === "playing") last = { position: Number(now.position) || 0, duration: Number(now.duration) };
|
|
174
|
+
idle = now.status === "playing" || titleOf(now) ? 0 : idle + 1;
|
|
175
|
+
if (idle >= 2) letGo();
|
|
176
|
+
}, keepAliveMs);
|
|
177
|
+
keepAlive.unref?.();
|
|
178
|
+
}
|
|
179
|
+
function letGo() {
|
|
180
|
+
clearInterval(keepAlive);
|
|
181
|
+
keepAlive = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Plays one queued song and waits until the player reports it from its start, so a song of
|
|
185
|
+
// the same title still finishing is not taken for it.
|
|
186
|
+
async function playAt(position) {
|
|
187
|
+
const song = queue[position];
|
|
188
|
+
checked(await run(["play", "--song", "--encrypted-id", song.id, "--original-id", song.original_id]), `play ${song.title}`);
|
|
189
|
+
index = position;
|
|
190
|
+
last = null;
|
|
191
|
+
for (let waited = 0; waited < START_WAIT_MS; waited += START_POLL_MS) {
|
|
192
|
+
const now = await state();
|
|
193
|
+
if (now.status === "playing" && isSong(now, song) && (Number(now.position) || 0) < 5) {
|
|
194
|
+
stayConnected();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
await wait(START_POLL_MS);
|
|
198
|
+
}
|
|
199
|
+
throw failure("netease_not_started", `NetEase did not start ${song.title}. It may not be playable here, most often for copyright.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const player = {
|
|
203
|
+
status: () => ({ ...status }),
|
|
204
|
+
ready: () => status.state === "ready",
|
|
205
|
+
|
|
206
|
+
async check() {
|
|
207
|
+
try {
|
|
208
|
+
const result = await run(["login", "--check"]);
|
|
209
|
+
status = result?.success === true ? { state: "ready", message: null } : { state: "signed_out", message: String(result?.message ?? "") || null };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
status = { state: error.code === "netease_cli_missing" ? "missing" : "unavailable", message: error.message };
|
|
212
|
+
}
|
|
213
|
+
return player.status();
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/** Up to 20 songs for one search, playable or not. */
|
|
217
|
+
async search({ query, limit = 10, intent } = {}) {
|
|
218
|
+
const keyword = String(query ?? "").trim().slice(0, 100);
|
|
219
|
+
if (!keyword) throw failure("netease_failed", "Give a song, artist or album to search for.");
|
|
220
|
+
const count = Math.min(20, Math.max(1, Math.round(Number(limit) || 10)));
|
|
221
|
+
const result = checked(await run(["search", "song", "--keyword", keyword, "--limit", String(count), "--userInput", shortIntent(intent)]), "search");
|
|
222
|
+
const songs = (Array.isArray(result.data?.records) ? result.data.records : []).map(songOf);
|
|
223
|
+
remember(songs);
|
|
224
|
+
return songs;
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Finds and plays songs: each item is a search result's id, or a title and artist to look
|
|
229
|
+
* up. mode "play" replaces the queue and starts the first; "queue" adds after what is
|
|
230
|
+
* playing. Songs NetEase marks as not playable are never sent to the player, nor songs
|
|
231
|
+
* allow(song) refuses, such as the listener's Avoids; those are only counted. Before a lookup,
|
|
232
|
+
* allow gets the title and artist asked for, as { title, artist_credit }.
|
|
233
|
+
*/
|
|
234
|
+
async play({ items = [], mode = "play", intent, allow = () => true } = {}) {
|
|
235
|
+
const found = [];
|
|
236
|
+
const unplayable = [];
|
|
237
|
+
const notFound = [];
|
|
238
|
+
let avoided = 0;
|
|
239
|
+
for (const item of items.slice(0, 20)) {
|
|
240
|
+
if (item?.id) {
|
|
241
|
+
const song = known.get(String(item.id));
|
|
242
|
+
if (!song) notFound.push({ id: String(item.id) });
|
|
243
|
+
else if (!song.playable) unplayable.push(song);
|
|
244
|
+
else if (!allow(song)) avoided += 1;
|
|
245
|
+
else found.push({ song, other_artist: false });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const wanted = { title: String(item?.title ?? ""), artist: String(item?.artist ?? "") };
|
|
249
|
+
if (!allow({ title: wanted.title, artist_credit: wanted.artist })) { avoided += 1; continue; }
|
|
250
|
+
const result = pick(await player.search({ query: `${wanted.title} ${wanted.artist}`.trim(), limit: 10, intent }), wanted);
|
|
251
|
+
if (result.song && !allow(result.song)) avoided += 1;
|
|
252
|
+
else if (result.song) found.push(result);
|
|
253
|
+
else if (result.unplayable) unplayable.push({ title: wanted.title, artists: [wanted.artist].filter(Boolean) });
|
|
254
|
+
else notFound.push(wanted);
|
|
255
|
+
}
|
|
256
|
+
const songs = found.map((entry) => entry.song);
|
|
257
|
+
let outcome = "none";
|
|
258
|
+
if (songs.length) {
|
|
259
|
+
// A paused song still has its title; with nothing loaded, a queue request plays.
|
|
260
|
+
const loaded = mode === "queue" && index >= 0 && titleOf(await state()) !== null;
|
|
261
|
+
if (loaded) {
|
|
262
|
+
queue = [...queue, ...songs];
|
|
263
|
+
outcome = "queued";
|
|
264
|
+
} else {
|
|
265
|
+
queue = songs;
|
|
266
|
+
await playAt(0);
|
|
267
|
+
outcome = "playing";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
mode: outcome,
|
|
272
|
+
songs: found.map(({ song, other_artist: otherArtist }) => ({ ...song, other_artist: otherArtist })),
|
|
273
|
+
unplayable,
|
|
274
|
+
not_found: notFound,
|
|
275
|
+
...(avoided ? { skipped_avoided_count: avoided } : {}),
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async pause() { checked(await run(["pause"]), "pause"); },
|
|
280
|
+
async resume() {
|
|
281
|
+
checked(await run(["resume"]), "resume");
|
|
282
|
+
stayConnected();
|
|
283
|
+
},
|
|
284
|
+
async next() {
|
|
285
|
+
if (index + 1 >= queue.length) throw failure("netease_failed", "Nothing is queued after this song.");
|
|
286
|
+
await playAt(index + 1);
|
|
287
|
+
},
|
|
288
|
+
// Back to the start of the song, or to the one before when it has only just begun.
|
|
289
|
+
async previous() {
|
|
290
|
+
const now = await state();
|
|
291
|
+
if (index > 0 && (Number(now.position) || 0) <= 3) await playAt(index - 1);
|
|
292
|
+
else checked(await run(["seek", "0"]), "go back to the start");
|
|
293
|
+
},
|
|
294
|
+
async seek({ positionMs = 0 } = {}) {
|
|
295
|
+
checked(await run(["seek", String(Math.max(0, Math.round(positionMs / 1000)))]), "move in the song");
|
|
296
|
+
last = null;
|
|
297
|
+
},
|
|
298
|
+
async stop() {
|
|
299
|
+
letGo();
|
|
300
|
+
queue = [];
|
|
301
|
+
index = -1;
|
|
302
|
+
last = null;
|
|
303
|
+
checked(await run(["stop"]), "stop");
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* What is playing: "playing", "paused" (ncm-cli reports a paused song as stopped, with its
|
|
308
|
+
* title still set) or "stopped", the song, its position, and the songs queued after it.
|
|
309
|
+
*/
|
|
310
|
+
async nowPlaying() {
|
|
311
|
+
const now = await state();
|
|
312
|
+
const title = titleOf(now);
|
|
313
|
+
if (!title) return { state: "stopped", song: null, position_ms: 0, next: [] };
|
|
314
|
+
const queued = queue[index];
|
|
315
|
+
// ncm-cli names the song "title - artist"; a song played from elsewhere is shown by that name.
|
|
316
|
+
const ours = isSong(now, queued);
|
|
317
|
+
const song = ours ? queued : { id: null, original_id: null, title: title.replace(/\s+-\s+[^-]+$/u, ""),
|
|
318
|
+
artists: title.includes(" - ") ? [title.split(" - ").pop()] : [], album: null, duration_ms: null, image: null, playable: true };
|
|
319
|
+
return {
|
|
320
|
+
state: now.status === "playing" ? "playing" : "paused",
|
|
321
|
+
song: { ...song, duration_ms: Number.isFinite(now.duration) ? Math.round(now.duration * 1000) : song.duration_ms },
|
|
322
|
+
position_ms: Number.isFinite(now.position) ? Math.round(now.position * 1000) : 0,
|
|
323
|
+
next: ours ? queue.slice(index + 1) : [],
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
return player;
|
|
328
|
+
}
|
|
@@ -221,7 +221,8 @@ export function createWikidataArtistResolver({
|
|
|
221
221
|
if (!agent) throw new TypeError("A descriptive Wikidata user agent is required.");
|
|
222
222
|
const cache = new Map();
|
|
223
223
|
|
|
224
|
-
async function request(parameters) {
|
|
224
|
+
async function request(parameters, { signal } = {}) {
|
|
225
|
+
signal?.throwIfAborted();
|
|
225
226
|
const url = new URL("/w/api.php", wikidataOrigin);
|
|
226
227
|
for (const [key, value] of Object.entries(parameters)) {
|
|
227
228
|
url.searchParams.set(key, String(value));
|
|
@@ -232,12 +233,11 @@ export function createWikidataArtistResolver({
|
|
|
232
233
|
accept: "application/json",
|
|
233
234
|
"user-agent": agent,
|
|
234
235
|
},
|
|
235
|
-
signal: AbortSignal.timeout(
|
|
236
|
-
OPEN_MUSIC_SIMILARITY_LIMITS.requestTimeoutMs,
|
|
237
|
-
),
|
|
236
|
+
signal: AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(OPEN_MUSIC_SIMILARITY_LIMITS.requestTimeoutMs)]),
|
|
238
237
|
});
|
|
239
238
|
return await responseJson(response, "Wikidata", "wikidata");
|
|
240
239
|
} catch (error) {
|
|
240
|
+
signal?.throwIfAborted();
|
|
241
241
|
if (error instanceof OpenMusicSimilarityError) throw error;
|
|
242
242
|
fail(
|
|
243
243
|
"wikidata_request_failed",
|
|
@@ -248,7 +248,8 @@ export function createWikidataArtistResolver({
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
return Object.freeze({
|
|
251
|
-
async resolveArtist(artistName) {
|
|
251
|
+
async resolveArtist(artistName, { signal } = {}) {
|
|
252
|
+
signal?.throwIfAborted();
|
|
252
253
|
const artist = requiredArtistName(artistName);
|
|
253
254
|
const normalized = normalizeForMatch(artist);
|
|
254
255
|
const language = languageForArtist(artist);
|
|
@@ -262,7 +263,7 @@ export function createWikidataArtistResolver({
|
|
|
262
263
|
type: "item",
|
|
263
264
|
limit: OPEN_MUSIC_SIMILARITY_LIMITS.wikidataSearchResultsMax,
|
|
264
265
|
format: "json",
|
|
265
|
-
});
|
|
266
|
+
}, { signal });
|
|
266
267
|
const search = Array.isArray(searchPayload?.search)
|
|
267
268
|
? searchPayload.search
|
|
268
269
|
: [];
|
|
@@ -300,7 +301,7 @@ export function createWikidataArtistResolver({
|
|
|
300
301
|
props: "claims|labels|descriptions",
|
|
301
302
|
languages: language === "en" ? "en" : `${language}|en`,
|
|
302
303
|
format: "json",
|
|
303
|
-
});
|
|
304
|
+
}, { signal });
|
|
304
305
|
const entities = isPlainObject(entityPayload?.entities)
|
|
305
306
|
? entityPayload.entities
|
|
306
307
|
: {};
|
|
@@ -449,7 +450,8 @@ export function createListenBrainzArtistRadioClient({
|
|
|
449
450
|
const cache = new Map();
|
|
450
451
|
let rateLimitedUntil = 0;
|
|
451
452
|
|
|
452
|
-
async function request(pathname, parameters) {
|
|
453
|
+
async function request(pathname, parameters, { signal } = {}) {
|
|
454
|
+
signal?.throwIfAborted();
|
|
453
455
|
const current = now();
|
|
454
456
|
if (!Number.isFinite(current)) {
|
|
455
457
|
fail(
|
|
@@ -476,9 +478,7 @@ export function createListenBrainzArtistRadioClient({
|
|
|
476
478
|
accept: "application/json",
|
|
477
479
|
"user-agent": agent,
|
|
478
480
|
},
|
|
479
|
-
signal: AbortSignal.timeout(
|
|
480
|
-
OPEN_MUSIC_SIMILARITY_LIMITS.requestTimeoutMs,
|
|
481
|
-
),
|
|
481
|
+
signal: AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(OPEN_MUSIC_SIMILARITY_LIMITS.requestTimeoutMs)]),
|
|
482
482
|
});
|
|
483
483
|
const resetIn = headerInteger(response, "x-ratelimit-reset-in");
|
|
484
484
|
const retryAfter = headerInteger(response, "retry-after") ?? resetIn;
|
|
@@ -491,6 +491,7 @@ export function createListenBrainzArtistRadioClient({
|
|
|
491
491
|
}
|
|
492
492
|
return await responseJson(response, "ListenBrainz", "listenbrainz");
|
|
493
493
|
} catch (error) {
|
|
494
|
+
signal?.throwIfAborted();
|
|
494
495
|
if (error instanceof OpenMusicSimilarityError) {
|
|
495
496
|
if (error.code === "listenbrainz_rate_limited") {
|
|
496
497
|
rateLimitedUntil = Math.max(rateLimitedUntil, current + 1_000);
|
|
@@ -513,7 +514,8 @@ export function createListenBrainzArtistRadioClient({
|
|
|
513
514
|
maxRecordingsPerArtist,
|
|
514
515
|
popBegin,
|
|
515
516
|
popEnd,
|
|
516
|
-
}) {
|
|
517
|
+
}, { signal } = {}) {
|
|
518
|
+
signal?.throwIfAborted();
|
|
517
519
|
const cleanedMbid = safeUuid(artistMbid);
|
|
518
520
|
if (!cleanedMbid) {
|
|
519
521
|
fail(
|
|
@@ -538,12 +540,13 @@ export function createListenBrainzArtistRadioClient({
|
|
|
538
540
|
max_recordings_per_artist: maxRecordingsPerArtist,
|
|
539
541
|
pop_begin: popBegin,
|
|
540
542
|
pop_end: popEnd,
|
|
541
|
-
});
|
|
543
|
+
}, { signal });
|
|
542
544
|
return parseRadioPayload(payload);
|
|
543
545
|
});
|
|
544
546
|
},
|
|
545
547
|
|
|
546
|
-
async recordingMetadata(recordingMbids) {
|
|
548
|
+
async recordingMetadata(recordingMbids, { signal } = {}) {
|
|
549
|
+
signal?.throwIfAborted();
|
|
547
550
|
if (
|
|
548
551
|
!Array.isArray(recordingMbids) ||
|
|
549
552
|
recordingMbids.length < 1 ||
|
|
@@ -568,7 +571,7 @@ export function createListenBrainzArtistRadioClient({
|
|
|
568
571
|
const payload = await request("/1/metadata/recording/", {
|
|
569
572
|
recording_mbids: cleaned.join(","),
|
|
570
573
|
inc: "artist release",
|
|
571
|
-
});
|
|
574
|
+
}, { signal });
|
|
572
575
|
return [...parseRecordingMetadata(payload, cleaned).entries()];
|
|
573
576
|
}).then((entries) => new Map(entries));
|
|
574
577
|
},
|
|
@@ -664,7 +667,8 @@ export function createOpenMusicSimilarity({
|
|
|
664
667
|
}
|
|
665
668
|
|
|
666
669
|
return Object.freeze({
|
|
667
|
-
async discoverSimilarTracks({ artistName, mode = "medium", limit = 8 } = {}) {
|
|
670
|
+
async discoverSimilarTracks({ artistName, mode = "medium", limit = 8 } = {}, { signal } = {}) {
|
|
671
|
+
signal?.throwIfAborted();
|
|
668
672
|
const artist = requiredArtistName(artistName);
|
|
669
673
|
if (!modes.has(mode)) {
|
|
670
674
|
fail(
|
|
@@ -686,7 +690,8 @@ export function createOpenMusicSimilarity({
|
|
|
686
690
|
}
|
|
687
691
|
const range = popularityRanges[mode];
|
|
688
692
|
const source = sourceMetadata(now, mode, range, artist);
|
|
689
|
-
const identity = await identityResolver.resolveArtist(artist);
|
|
693
|
+
const identity = await identityResolver.resolveArtist(artist, { signal });
|
|
694
|
+
signal?.throwIfAborted();
|
|
690
695
|
if (identity.state !== "resolved") {
|
|
691
696
|
return {
|
|
692
697
|
state:
|
|
@@ -710,7 +715,8 @@ export function createOpenMusicSimilarity({
|
|
|
710
715
|
maxRecordingsPerArtist: 5,
|
|
711
716
|
popBegin: range.begin,
|
|
712
717
|
popEnd: range.end,
|
|
713
|
-
});
|
|
718
|
+
}, { signal });
|
|
719
|
+
signal?.throwIfAborted();
|
|
714
720
|
const adjacentGroups = groups.filter(
|
|
715
721
|
(group) => group.artist_mbid !== identity.artist_mbid,
|
|
716
722
|
);
|
|
@@ -741,7 +747,9 @@ export function createOpenMusicSimilarity({
|
|
|
741
747
|
);
|
|
742
748
|
const chunkMetadata = await radioClient.recordingMetadata(
|
|
743
749
|
chunk.map((recording) => recording.recording_mbid),
|
|
750
|
+
{ signal },
|
|
744
751
|
);
|
|
752
|
+
signal?.throwIfAborted();
|
|
745
753
|
for (const [recordingMbid, details] of chunkMetadata) {
|
|
746
754
|
metadata.set(recordingMbid, details);
|
|
747
755
|
}
|
|
@@ -6,7 +6,7 @@ const numericId = /^[1-9][0-9]{0,19}$/u;
|
|
|
6
6
|
|
|
7
7
|
function allowedUrl(value) {
|
|
8
8
|
let url;
|
|
9
|
-
try { url = new URL(value); } catch { throw new Error("Paste a QQ Music or NetEase
|
|
9
|
+
try { url = new URL(value); } catch { throw new Error("Paste a QQ Music or NetEase Music playlist share link."); }
|
|
10
10
|
if (!hosts.has(url.hostname) || !["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port) {
|
|
11
11
|
throw new Error("Use a playlist link from y.qq.com, music.163.com or their official share links.");
|
|
12
12
|
}
|
|
@@ -98,6 +98,7 @@ export async function readPublicPlaylist({ input, subjectId, capturedAt, provide
|
|
|
98
98
|
digest: musicHash(bytes), size: bytes.length, members: ["public-playlist.json"], fileName: name, skipped: songs.length - rows.length,
|
|
99
99
|
scopeNote: `${rows.length} of ${total ?? "an unknown number of"} 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.`,
|
|
100
100
|
});
|
|
101
|
+
prepared.preview.source = "public-playlist";
|
|
101
102
|
prepared.preview.availableTracks = rows.length;
|
|
102
103
|
prepared.preview.totalTracks = total;
|
|
103
104
|
return prepared;
|
|
@@ -91,6 +91,11 @@ function hashParts(...parts) {
|
|
|
91
91
|
return hash.digest("hex");
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** One key per logical item, so an export and a live library read describe the same save. */
|
|
95
|
+
export function spotifyProfileEvidenceKey(subjectId, kind, logicalKey) {
|
|
96
|
+
return hashParts("spotify-account-profile-evidence-key-v1", subjectId, kind, logicalKey);
|
|
97
|
+
}
|
|
98
|
+
|
|
94
99
|
function normalizedText(value) {
|
|
95
100
|
return value.normalize("NFKC").toLocaleLowerCase("und");
|
|
96
101
|
}
|
|
@@ -110,6 +115,14 @@ function optionalUri(value) {
|
|
|
110
115
|
return match ? { type: match[1], id: match[2] } : null;
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
export function spotifyEntityRefId(type, externalId) {
|
|
119
|
+
return entityRefId(type, externalId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function spotifyResolvedTrackRef(value) {
|
|
123
|
+
return resolvedTrackRef(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
function entityRefId(type, externalId) {
|
|
114
127
|
if (type === "track") return uuidV5(externalId, SPOTIFY_TRACK_NAMESPACE);
|
|
115
128
|
return uuidV5(
|
|
@@ -184,12 +197,7 @@ function profileBuilder({ subjectId, archiveSha256, capturedAt, profileImportId
|
|
|
184
197
|
sourceMember,
|
|
185
198
|
attributes = {},
|
|
186
199
|
}) {
|
|
187
|
-
const evidenceKey =
|
|
188
|
-
"spotify-account-profile-evidence-key-v1",
|
|
189
|
-
subjectId,
|
|
190
|
-
kind,
|
|
191
|
-
logicalKey,
|
|
192
|
-
);
|
|
200
|
+
const evidenceKey = spotifyProfileEvidenceKey(subjectId, kind, logicalKey);
|
|
193
201
|
records.push({
|
|
194
202
|
schema_version: 1,
|
|
195
203
|
profile_evidence_id: uuidV5(
|