@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.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -0,0 +1,321 @@
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.
231
+ */
232
+ async play({ items = [], mode = "play", intent } = {}) {
233
+ const found = [];
234
+ const unplayable = [];
235
+ const notFound = [];
236
+ for (const item of items.slice(0, 20)) {
237
+ if (item?.id) {
238
+ const song = known.get(String(item.id));
239
+ if (!song) notFound.push({ id: String(item.id) });
240
+ else if (!song.playable) unplayable.push(song);
241
+ else found.push({ song, other_artist: false });
242
+ continue;
243
+ }
244
+ const wanted = { title: String(item?.title ?? ""), artist: String(item?.artist ?? "") };
245
+ const result = pick(await player.search({ query: `${wanted.title} ${wanted.artist}`.trim(), limit: 10, intent }), wanted);
246
+ if (result.song) found.push(result);
247
+ else if (result.unplayable) unplayable.push({ title: wanted.title, artists: [wanted.artist].filter(Boolean) });
248
+ else notFound.push(wanted);
249
+ }
250
+ const songs = found.map((entry) => entry.song);
251
+ let outcome = "none";
252
+ if (songs.length) {
253
+ // A paused song still has its title; with nothing loaded, a queue request plays.
254
+ const loaded = mode === "queue" && index >= 0 && titleOf(await state()) !== null;
255
+ if (loaded) {
256
+ queue = [...queue, ...songs];
257
+ outcome = "queued";
258
+ } else {
259
+ queue = songs;
260
+ await playAt(0);
261
+ outcome = "playing";
262
+ }
263
+ }
264
+ return {
265
+ mode: outcome,
266
+ songs: found.map(({ song, other_artist: otherArtist }) => ({ ...song, other_artist: otherArtist })),
267
+ unplayable,
268
+ not_found: notFound,
269
+ };
270
+ },
271
+
272
+ async pause() { checked(await run(["pause"]), "pause"); },
273
+ async resume() {
274
+ checked(await run(["resume"]), "resume");
275
+ stayConnected();
276
+ },
277
+ async next() {
278
+ if (index + 1 >= queue.length) throw failure("netease_failed", "Nothing is queued after this song.");
279
+ await playAt(index + 1);
280
+ },
281
+ // Back to the start of the song, or to the one before when it has only just begun.
282
+ async previous() {
283
+ const now = await state();
284
+ if (index > 0 && (Number(now.position) || 0) <= 3) await playAt(index - 1);
285
+ else checked(await run(["seek", "0"]), "go back to the start");
286
+ },
287
+ async seek({ positionMs = 0 } = {}) {
288
+ checked(await run(["seek", String(Math.max(0, Math.round(positionMs / 1000)))]), "move in the song");
289
+ last = null;
290
+ },
291
+ async stop() {
292
+ letGo();
293
+ queue = [];
294
+ index = -1;
295
+ last = null;
296
+ checked(await run(["stop"]), "stop");
297
+ },
298
+
299
+ /**
300
+ * What is playing: "playing", "paused" (ncm-cli reports a paused song as stopped, with its
301
+ * title still set) or "stopped", the song, its position, and the songs queued after it.
302
+ */
303
+ async nowPlaying() {
304
+ const now = await state();
305
+ const title = titleOf(now);
306
+ if (!title) return { state: "stopped", song: null, position_ms: 0, next: [] };
307
+ const queued = queue[index];
308
+ // ncm-cli names the song "title - artist"; a song played from elsewhere is shown by that name.
309
+ const ours = isSong(now, queued);
310
+ const song = ours ? queued : { id: null, original_id: null, title: title.replace(/\s+-\s+[^-]+$/u, ""),
311
+ artists: title.includes(" - ") ? [title.split(" - ").pop()] : [], album: null, duration_ms: null, image: null, playable: true };
312
+ return {
313
+ state: now.status === "playing" ? "playing" : "paused",
314
+ song: { ...song, duration_ms: Number.isFinite(now.duration) ? Math.round(now.duration * 1000) : song.duration_ms },
315
+ position_ms: Number.isFinite(now.position) ? Math.round(now.position * 1000) : 0,
316
+ next: ours ? queue.slice(index + 1) : [],
317
+ };
318
+ },
319
+ };
320
+ return player;
321
+ }
@@ -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 Cloud Music playlist share link."); }
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 = hashParts(
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(