@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.10

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 (46) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/scripts/moondog.mjs +138 -10
  4. package/src/core/capability-catalog.mjs +49 -0
  5. package/src/core/library-store.mjs +500 -0
  6. package/src/core/listening-now.mjs +80 -6
  7. package/src/core/moondog-application.mjs +570 -32
  8. package/src/i18n/index.mjs +6 -3
  9. package/src/i18n/messages/de.mjs +1 -0
  10. package/src/i18n/messages/en.mjs +2 -0
  11. package/src/i18n/messages/es.mjs +1 -0
  12. package/src/i18n/messages/fr.mjs +1 -0
  13. package/src/i18n/messages/it.mjs +1 -0
  14. package/src/i18n/messages/ja.mjs +1 -0
  15. package/src/i18n/messages/ko.mjs +1 -0
  16. package/src/i18n/messages/pt.mjs +1 -0
  17. package/src/i18n/messages/zh-Hant.mjs +1 -0
  18. package/src/i18n/messages/zh.mjs +1 -0
  19. package/src/i18n/preferences.mjs +24 -0
  20. package/src/i18n/screen/es.mjs +92 -0
  21. package/src/i18n/screen/ja.mjs +92 -0
  22. package/src/i18n/screen/pt.mjs +92 -0
  23. package/src/i18n/screen/zh.mjs +92 -0
  24. package/src/integrations/apple-music/music-app.mjs +9 -3
  25. package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
  26. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  27. package/src/integrations/playlist-links.mjs +96 -0
  28. package/src/integrations/public-playlists.mjs +10 -4
  29. package/src/integrations/spotify/service.mjs +22 -1
  30. package/src/integrations/stable-audio/cover.mjs +324 -0
  31. package/src/integrations/stable-audio/local-generator.mjs +259 -87
  32. package/src/integrations/stable-audio/setup.mjs +653 -0
  33. package/src/integrations/stable-audio/sources.mjs +106 -0
  34. package/src/profile/listening-history-store.mjs +1 -1
  35. package/src/profile/listening-profile-projection.mjs +1 -1
  36. package/src/profile/music-import-bundle.mjs +3 -1
  37. package/src/profile/music-providers.mjs +3 -0
  38. package/src/profile/taste-compiler.mjs +14 -5
  39. package/src/runtime/pi/agent-runtime.mjs +324 -31
  40. package/src/runtime/pi/model-switch.mjs +26 -0
  41. package/src/runtime/pi/model-transport.mjs +34 -4
  42. package/src/runtime/pi/record-name.mjs +80 -0
  43. package/src/runtime/pi/reply-language.mjs +45 -0
  44. package/src/runtime/pi/tool-arguments.mjs +89 -0
  45. package/src/surfaces/cli/command-completions.mjs +15 -1
  46. package/src/surfaces/cli/tui.mjs +404 -18
@@ -874,6 +874,7 @@ export default {
874
874
  "See what you're listening to": "查看你在听什么",
875
875
  "Control the Music app": "控制“音乐”App",
876
876
  "Choose a music service": "选择音乐服务",
877
+ "Read or change Moondog's settings": "读取或更改 Moondog 的设置",
877
878
  "Search outside your library": "在你的曲库之外搜索",
878
879
  "Find music nearby": "找附近的音乐",
879
880
  "Remember what you've told me": "记住你告诉我的",
@@ -1053,4 +1054,95 @@ export default {
1053
1054
  "Play a track Moondog made": "再放一首 Moondog 做的曲子",
1054
1055
  "Start a stream": "开始音乐流",
1055
1056
  "Stop the stream": "停止音乐流",
1057
+ "Go on air: Moondog keeps making music from your taste": "开播:Moondog 照着你的口味一直做音乐",
1058
+ "on air until {time}": "播出到 {time}",
1059
+ "Cutting the next record": "正在刻下一张",
1060
+ "Off air: the next record couldn't be made.": "停播了:下一张没刻成。",
1061
+ "The next record is still being cut.": "下一张还在刻。",
1062
+ "Off air.": "已停播。",
1063
+ "Turn on music making first: /generation on.": "先开启音乐生成:/generation on。",
1064
+ "Download a Stable Audio 3 model from Stability AI": "从 Stability AI 下载一个 Stable Audio 3 模型",
1065
+ "Make music with a downloaded model": "用已下载的模型做音乐",
1066
+ "Delete a downloaded model from this Mac": "从这台 Mac 删除一个已下载的模型",
1067
+ "Stop the download": "停止下载",
1068
+ "Medium: best sound, about 5.2 GB": "Medium:音质最好,约 5.2 GB",
1069
+ "Small Music: faster, lighter, about 1.7 GB": "Small Music:更快更轻,约 1.7 GB",
1070
+ "getting ready": "准备中",
1071
+ "There isn't enough free space on this Mac for the download. Free some up, then use /generation download again.": "这台 Mac 的可用空间不够下载。腾出一些空间,再用一次 /generation download。",
1072
+ "A downloaded file didn't match the one Stability AI publishes, so Moondog deleted it. Use /generation download to try again.": "有个下载的文件和 Stability AI 发布的不一致,Moondog 已把它删除。用 /generation download 重试。",
1073
+ "The download couldn't reach its source. Check your connection, then use /generation download to try again.": "下载连不上来源。检查网络连接,再用 /generation download 重试。",
1074
+ "Another Moondog window or terminal is already downloading. /generation shows how far it is.": "另一个 Moondog 窗口或终端已经在下载。/generation 可以看进度。",
1075
+ "The download didn't finish: {error}. Use /generation download to try again.": "下载没有完成:{error}。用 /generation download 重试。",
1076
+ "unknown error": "未知错误",
1077
+ "Stable Audio 3 {model} is ready. Ask for a track.": "Stable Audio 3 {model} 已就绪。想听就让它做一首。",
1078
+ "Stable Audio 3 {model} is downloaded. Use it with /generation use {choice}.": "Stable Audio 3 {model} 已下载。用 /generation use {choice} 来使用它。",
1079
+ "Download stopped. /generation download {choice} picks it up where it left off.": "下载已停止。/generation download {choice} 会从停下的地方接着下。",
1080
+ "download stopped on an error": "下载因错误停止",
1081
+ "downloading": "下载中",
1082
+ "downloading, {percent}%": "下载中,{percent}%",
1083
+ "installed, in use": "已安装,使用中",
1084
+ "installed": "已安装",
1085
+ "partly downloaded": "已下载一部分",
1086
+ "not downloaded": "未下载",
1087
+ "{size} GB": "{size} GB",
1088
+ "In use: your own install at {folder}.": "使用中:你自己在 {folder} 的安装。",
1089
+ "Get a model with /generation download medium or small. /generation use, remove and cancel manage them.": "用 /generation download medium 或 small 获取模型。/generation use、remove 和 cancel 用来管理它们。",
1090
+ "Making music on this Mac is off. Get Stable Audio 3 with /generation download medium, or small for a lighter model; music making turns on when it's done.": "在这台 Mac 上做音乐已关闭。用 /generation download medium 获取 Stable Audio 3,想要更轻的模型就用 small;下载完成后会开启音乐生成。",
1091
+ "Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.": "Stable Audio 3 {model} 还没下载。用 /generation download {choice} 获取。",
1092
+ "Making music on this Mac is off. Stable Audio 3 {model} is ready; turn it on with /generation on.": "在这台 Mac 上做音乐已关闭。Stable Audio 3 {model} 已准备好,用 /generation on 开启。",
1093
+ "Use /generation download, use or remove with medium or small, or /generation cancel.": "用 /generation download、use 或 remove 加上 medium 或 small,或用 /generation cancel。",
1094
+ "No download is running.": "没有正在进行的下载。",
1095
+ "Stable Audio 3 {model} is already downloading.": "Stable Audio 3 {model} 已经在下载了。",
1096
+ "Another download is running. /generation cancel stops it.": "另一个下载正在进行。/generation cancel 可以停止它。",
1097
+ "Stable Audio 3 {model} is already on this Mac. Use it with /generation use {choice}.": "Stable Audio 3 {model} 已经在这台 Mac 上。用 /generation use {choice} 来使用它。",
1098
+ "Already downloaded.": "已经下载过了。",
1099
+ "Stable Audio 3 comes under the Stability AI Community License ({license}), and its text encoder, T5Gemma, under the Gemma Terms of Use ({gemma}). Moondog downloads it only from Stability AI's official sources and checks every file.\n\nTo agree and download {model} ({size} GB): /generation download {choice} --agree": "Stable Audio 3 适用 Stability AI Community License({license}),它的文本编码器 T5Gemma 适用 Gemma Terms of Use({gemma})。Moondog 只从 Stability AI 的官方来源下载,并校验每个文件。\n\n同意并下载 {model}({size} GB):/generation download {choice} --agree",
1100
+ "Waiting for your agreement.": "等你同意。",
1101
+ "The download didn't finish.": "下载没有完成。",
1102
+ "Downloading Stable Audio 3 {model} ({size} GB) from Stability AI's official sources. It runs in the background; /generation cancel stops it, and a later download picks up where it left off.": "正在从 Stability AI 的官方来源下载 Stable Audio 3 {model}({size} GB)。它在后台进行;/generation cancel 可以停止,之后再下载会从停下的地方接着下。",
1103
+ "Downloading Stable Audio 3 {model}.": "正在下载 Stable Audio 3 {model}。",
1104
+ "Wait for the download to finish, or stop it with /generation cancel, before removing a model.": "删除模型前,先等下载完成,或用 /generation cancel 停止它。",
1105
+ "A download is running.": "有下载正在进行。",
1106
+ "Stable Audio 3 {model} isn't on this Mac.": "这台 Mac 上没有 Stable Audio 3 {model}。",
1107
+ "Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. It's the last model, so the rest of Moondog's Stable Audio 3 setup goes too. To go ahead: /generation remove {choice} --yes": "删除 Stable Audio 3 {model} 会从这台 Mac 删掉它的 {size} GB。这是最后一个模型,所以 Moondog 的 Stable Audio 3 其余部分也会一并删除。确定的话:/generation remove {choice} --yes",
1108
+ "Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. To go ahead: /generation remove {choice} --yes": "删除 Stable Audio 3 {model} 会从这台 Mac 删掉它的 {size} GB。确定的话:/generation remove {choice} --yes",
1109
+ "Nothing removed yet.": "还没有删除任何东西。",
1110
+ "Removed Stable Audio 3 {model} from this Mac.": "已从这台 Mac 删除 Stable Audio 3 {model}。",
1111
+ "Removed.": "已删除。",
1112
+ "Removed. Music making isn't ready.": "已删除。音乐生成还没准备好。",
1113
+ "Use /generation, /generation on [folder] or off, or /generation download, use or remove with medium or small.": "用 /generation、/generation on [文件夹] 或 off,或用 /generation download、use 或 remove 加上 medium 或 small。",
1114
+ "best sound": "音质最好",
1115
+ "faster, lighter": "更快更轻",
1116
+ "getting uv": "正在获取 uv",
1117
+ "getting Stable Audio 3's code": "正在获取 Stable Audio 3 的代码",
1118
+ "setting up Python": "正在准备 Python",
1119
+ "downloading the model": "正在下载模型",
1120
+ "checking the files": "正在校验文件",
1121
+ "Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.": "没能准备好 Stable Audio 3 的 Python 环境。检查网络连接,再用 /generation download 重试。",
1122
+ "The library isn't available here.": "这里用不了资料库。",
1123
+ "Saved": "收藏",
1124
+ "Play one with `/library play <name>`, or keep the song playing now with `/library save`.": "用 `/library play <名字>` 播放一个,或者用 `/library save` 收藏正在播放的歌。",
1125
+ "No playlist is called “{name}”.": "没有叫“{name}”的播放列表。",
1126
+ "{name} has nothing in it yet.": "{name} 里还是空的。",
1127
+ "None of its songs could be found.": "里面的歌都没有找到。",
1128
+ "Playing {name}.": "正在播放 {name}。",
1129
+ "Moondog's tracks were left out. They play with Apple Music chosen.": "Moondog 做的曲子没有放。选择 Apple Music 时才会播放它们。",
1130
+ "Some songs couldn't be found.": "有些歌没有找到。",
1131
+ "Already in {name}.": "已经在“{name}”里了。",
1132
+ "Saved.": "已收藏。",
1133
+ "Saved to {name}.": "已收藏到“{name}”。",
1134
+ "Your saved songs and playlists": "你收藏的歌和播放列表",
1135
+ "Look in the library": "翻翻资料库",
1136
+ "Save to the library": "收进资料库",
1137
+ "Play from the library": "播放资料库里的歌",
1138
+ "Paste a playlist link after /library import.": "在 /library import 后面粘贴歌单链接。",
1139
+ "Reading the playlist…": "正在读取歌单…",
1140
+ "{added} of {total} songs came in.": "{total} 首中导入了 {added} 首。",
1141
+ "{count} songs came in.": { one: "导入了 {count} 首。", other: "导入了 {count} 首。" },
1142
+ "It now shapes your taste.": "它现在会影响你的口味画像。",
1143
+ "It couldn't shape your taste this time.": "这次没能把它加入你的口味画像。",
1144
+ "It doesn't shape your taste; add taste after the link if it should.": "它不会影响你的口味;如果需要,在链接后加 taste。",
1145
+ "Imported {name}.": "已导入“{name}”。",
1146
+ "Use /library, /library play <name>, /library save [playlist], or /library import <link>.": "用 /library、/library play <名字>、/library save [播放列表] 或 /library import <链接>。",
1147
+ "Import a playlist": "导入歌单",
1056
1148
  };
@@ -12,6 +12,8 @@ const READ_TIMEOUT_MS = 15_000;
12
12
  // A playlist longer than this is read as a window around the playing song.
13
13
  const PLAYLIST_WINDOW = 50;
14
14
  const PLAYLIST_BEFORE = 5;
15
+ // Keeping the rest of a playlist reads up to as many songs as a library playlist holds.
16
+ const REST_MAX = 2_000;
15
17
  const COLUMN_READ_MAX = 2_000;
16
18
  // Covers already drawn, by album, or by song for one without an album.
17
19
  const KNOWN_COVERS = 40;
@@ -47,13 +49,15 @@ function run(argv) {
47
49
  const playlist = music.currentPlaylist;
48
50
  answer.playlist = { id: playlist.persistentID(), name: playlist.name() };
49
51
  try { answer.playlist.special = playlist.specialKind(); } catch (error) {}
50
- if (action === "tracks") {
52
+ if (action === "tracks" || action === "rest") {
51
53
  const tracks = playlist.tracks;
52
54
  const ids = tracks.persistentID();
53
55
  answer.playlist.count = ids.length;
54
56
  const at = answer.track ? ids.indexOf(answer.track.id) : -1;
55
- const start = ids.length <= ${PLAYLIST_WINDOW} || at < 0 ? 0 : Math.max(0, Math.min(at - ${PLAYLIST_BEFORE}, ids.length - ${PLAYLIST_WINDOW}));
56
- const end = Math.min(ids.length, start + ${PLAYLIST_WINDOW});
57
+ // "rest" reads from the playing song to the playlist's end, for keeping it in the library.
58
+ const start = action === "rest" ? Math.max(0, at)
59
+ : ids.length <= ${PLAYLIST_WINDOW} || at < 0 ? 0 : Math.max(0, Math.min(at - ${PLAYLIST_BEFORE}, ids.length - ${PLAYLIST_WINDOW}));
60
+ const end = Math.min(ids.length, start + (action === "rest" ? ${REST_MAX} : ${PLAYLIST_WINDOW}));
57
61
  answer.playlist.index = at;
58
62
  answer.playlist.start = start;
59
63
  answer.playlist.tracks = [];
@@ -197,6 +201,8 @@ export function createMusicAppPlayer({ platform = process.platform, run = osascr
197
201
  return {
198
202
  available,
199
203
  nowPlaying: ({ tracks = false } = {}) => read(tracks ? "tracks" : "read"),
204
+ // The playing song and the rest of its playlist, past the window nowPlaying reads, for keeping in the library.
205
+ remainingPlaylist: () => read("rest"),
200
206
  pause: () => read("pause"),
201
207
  resume: () => read("resume"),
202
208
  next: () => read("next"),
@@ -7,6 +7,8 @@ import { access, constants, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import path from "node:path";
9
9
 
10
+ import { createPlayerNow } from "../../core/listening-now.mjs";
11
+
10
12
  // Players that play one file without a window and exit at its end, in order of preference.
11
13
  // None of them may read or write the terminal: the listening room owns it.
12
14
  const systemPlayers = {
@@ -17,6 +19,8 @@ const systemPlayers = {
17
19
  ],
18
20
  };
19
21
  const longestQueue = 30;
22
+ // A library playlist plays as many entries as the application sends it, as the window does.
23
+ const longestPlaylist = 50;
20
24
  const largestPreview = 10 * 1024 * 1024;
21
25
  // Some routes to Apple's audio host take over 20 seconds for one preview.
22
26
  const downloadTimeoutMs = 60_000;
@@ -69,8 +73,11 @@ export async function createTerminalPreviewPlayer({
69
73
  let queue = null;
70
74
  let child = null;
71
75
  let closed = false;
76
+ // What it plays, for the agent: the queue it holds, moved along by its own events.
77
+ const now = createPlayerNow();
72
78
 
73
79
  const emit = (event) => {
80
+ now.observe(event);
74
81
  for (const listener of listeners) {
75
82
  try { listener(event); } catch {}
76
83
  }
@@ -208,13 +215,29 @@ export async function createTerminalPreviewPlayer({
208
215
  });
209
216
  start(accepted);
210
217
  },
218
+ // A playlist from the listener's library: Apple Music previews and tracks Moondog made, in one queue.
219
+ // A song is { title, artist_credit, catalog_url, preview_url }; a made track { generated: true, id, title, file }.
220
+ playList(tracks) {
221
+ if (closed) return;
222
+ const accepted = (Array.isArray(tracks) ? tracks : []).map((track) => (track?.generated === true
223
+ ? { title: String(track.title ?? "").trim(), artist_credit: "", generated: true, id: String(track.id ?? ""),
224
+ file: typeof track.file === "string" && path.isAbsolute(track.file) ? track.file : null }
225
+ : { title: String(track?.title ?? "").trim(), artist_credit: String(track?.artist_credit ?? "").trim(),
226
+ catalog_url: https(track?.catalog_url, linkHost), preview: https(track?.preview_url, previewHost) }))
227
+ .filter((track) => track.title && (track.generated ? track.file : track.catalog_url && track.preview)).slice(0, longestPlaylist);
228
+ if (!accepted.length) return;
229
+ stop();
230
+ emit({ type: "queue", tracks: accepted.map(({ title, artist_credit, catalog_url, generated, id }) => (generated
231
+ ? { title, artist_credit: "", generated: true, id } : { title, artist_credit, catalog_url })), album: null });
232
+ start(accepted);
233
+ },
211
234
  // A track Moondog made on this Mac: { id, title, file }, played whole and kept after it ends.
212
235
  playGenerated({ id, title, file } = {}) {
213
236
  const name = String(title ?? "").trim();
214
237
  if (closed || !name || typeof file !== "string" || !path.isAbsolute(file)) return;
215
238
  stop();
216
239
  const track = { title: name, artist_credit: "", generated: true, id: String(id ?? "") };
217
- emit({ type: "queue", tracks: [{ title: track.title, artist_credit: "", generated: true }], album: null });
240
+ emit({ type: "queue", tracks: [{ title: track.title, artist_credit: "", generated: true, id: track.id }], album: null });
218
241
  start([{ ...track, file }]);
219
242
  },
220
243
  // A made track queued behind the one playing, as Moondog's stream does; with nothing playing, it plays now.
@@ -223,6 +246,7 @@ export async function createTerminalPreviewPlayer({
223
246
  if (closed || !name || typeof file !== "string" || !path.isAbsolute(file)) return;
224
247
  if (!queue || !queue.tracks.every((track) => track.generated)) return this.playGenerated({ id, title, file });
225
248
  queue.tracks.push({ title: name, artist_credit: "", generated: true, id: String(id ?? ""), file });
249
+ now.appended({ title: name, generated: true, id: String(id ?? "") });
226
250
  },
227
251
  // Ends the song now playing, or still loading; the next one starts after it.
228
252
  skip() {
@@ -232,6 +256,12 @@ export async function createTerminalPreviewPlayer({
232
256
  return true;
233
257
  },
234
258
  stop,
259
+ // What plays now, as createPlayerNow reads it: null, or { state, song, position, count, next }.
260
+ nowPlaying: () => (closed ? null : now.read()),
261
+ // The song playing and every one after it, for keeping the whole queue in the library.
262
+ remaining: () => (closed ? [] : now.remaining()),
263
+ // Leaving Apple Music stops its previews here, never the tracks Moondog made.
264
+ holdsCatalogSongs: () => Boolean(queue?.tracks.some((track) => !track.generated)),
235
265
  subscribe(listener) {
236
266
  listeners.add(listener);
237
267
  return () => listeners.delete(listener);
@@ -245,9 +245,10 @@ export function createNeteasePlayer({
245
245
  * up. mode "play" replaces the queue and starts the first; "queue" adds after what is
246
246
  * playing. Songs NetEase marks as not playable are never sent to the player, nor songs
247
247
  * allow(song) refuses, such as the listener's Avoids; those are only counted. Before a lookup,
248
- * allow gets the title and artist asked for, as { title, artist_credit }.
248
+ * allow gets the title and artist asked for, as { title, artist_credit }. beforePlay runs just
249
+ * before a song starts, which a queue with nothing loaded does too.
249
250
  */
250
- async play({ items = [], mode = "play", intent, allow = () => true } = {}) {
251
+ async play({ items = [], mode = "play", intent, allow = () => true, beforePlay } = {}) {
251
252
  const found = [];
252
253
  const unplayable = [];
253
254
  const notFound = [];
@@ -278,6 +279,7 @@ export function createNeteasePlayer({
278
279
  queue = [...queue, ...songs];
279
280
  outcome = "queued";
280
281
  } else {
282
+ await beforePlay?.();
281
283
  queue = songs;
282
284
  await playAt(0);
283
285
  outcome = "playing";
@@ -297,15 +299,19 @@ export function createNeteasePlayer({
297
299
  checked(await run(["resume"]), "resume");
298
300
  stayConnected();
299
301
  },
300
- async next() {
302
+ // beforePlay runs just before a song starts, so the other players go quiet only for one.
303
+ async next({ beforePlay } = {}) {
301
304
  if (index + 1 >= queue.length) throw failure("netease_failed", "Nothing is queued after this song.");
305
+ await beforePlay?.();
302
306
  await playAt(index + 1);
303
307
  },
304
308
  // Back to the start of the song, or to the one before when it has only just begun.
305
- async previous() {
309
+ async previous({ beforePlay } = {}) {
306
310
  const now = await state();
307
- if (index > 0 && (Number(now.position) || 0) <= 3) await playAt(index - 1);
308
- else checked(await run(["seek", "0"]), "go back to the start");
311
+ if (index > 0 && (Number(now.position) || 0) <= 3) {
312
+ await beforePlay?.();
313
+ await playAt(index - 1);
314
+ } else checked(await run(["seek", "0"]), "go back to the start");
309
315
  },
310
316
  async seek({ positionMs = 0 } = {}) {
311
317
  checked(await run(["seek", String(Math.max(0, Math.round(positionMs / 1000)))]), "move in the song");
@@ -0,0 +1,96 @@
1
+ // Playlist links the listener pastes into the library: Spotify, Apple Music, NetEase Music and QQ Music.
2
+ // Each link is read for its name and songs only; nothing signs in to a service it doesn't already have,
3
+ // and nothing but the official hosts is ever fetched.
4
+ import { fetchPublicPlaylist } from "./public-playlists.mjs";
5
+
6
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
7
+ const PUBLIC_HOSTS = new Set(["y.qq.com", "c.y.qq.com", "i.y.qq.com", "m.y.qq.com", "music.163.com", "y.music.163.com", "163cn.tv"]);
8
+ const APPLE_PAGE_MAX = 12 * 1024 * 1024;
9
+
10
+ /** The one playlist link in some text: { provider, id, url } for Spotify and Apple Music, or { provider: "public", url } for NetEase and QQ. */
11
+ export function parsePlaylistLink(text) {
12
+ const value = String(text ?? "").trim();
13
+ const uri = /^spotify:playlist:([A-Za-z0-9]{10,40})$/u.exec(value);
14
+ if (uri) return { provider: "spotify", id: uri[1], url: `https://open.spotify.com/playlist/${uri[1]}` };
15
+ const links = value.match(/https?:\/\/[^\s<>"“”]+/gu) ?? [];
16
+ if (links.length !== 1) fail("playlist_link_missing", "Paste one playlist link: Spotify, Apple Music, NetEase Music or QQ Music.");
17
+ let url;
18
+ try { url = new URL(links[0]); } catch { fail("playlist_link_missing", "That link can't be read."); }
19
+ if (url.username || url.password || url.port) fail("playlist_link_unsupported", "That link can't be read.");
20
+ if (url.hostname === "open.spotify.com") {
21
+ const match = /^\/(?:intl-[a-z-]+\/)?playlist\/([A-Za-z0-9]{10,40})\/?$/u.exec(url.pathname);
22
+ if (match) return { provider: "spotify", id: match[1], url: `https://open.spotify.com/playlist/${match[1]}` };
23
+ }
24
+ if (url.hostname === "music.apple.com" || url.hostname === "embed.music.apple.com") {
25
+ const match = /^\/([a-z]{2})\/playlist\/(?:[^/]+\/)?(pl\.[A-Za-z0-9.-]{6,80})\/?$/u.exec(url.pathname);
26
+ if (match) return { provider: "apple_music", id: match[2], storefront: match[1], url: `https://music.apple.com/${match[1]}/playlist/${match[2]}` };
27
+ }
28
+ if (PUBLIC_HOSTS.has(url.hostname)) return { provider: "public", url: url.href };
29
+ fail("playlist_link_unsupported", "Moondog reads playlist links from Spotify, Apple Music, NetEase Music and QQ Music.");
30
+ }
31
+
32
+ // The page's text, bounded, from Apple's own host only and never through a redirect elsewhere.
33
+ async function applePage(url, fetchImpl, signal) {
34
+ let response;
35
+ try {
36
+ response = await fetchImpl(url, { redirect: "follow", signal: AbortSignal.any([AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]),
37
+ headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Moondog playlist import)", "Accept-Language": "en" } });
38
+ } catch (error) {
39
+ if (signal?.aborted) throw error;
40
+ fail("playlist_unreachable", "Couldn't reach Apple Music. Check your connection and try the link again.");
41
+ }
42
+ if (response.url && new URL(response.url).hostname !== "music.apple.com") fail("playlist_unavailable", "Apple Music answered from another address.");
43
+ if (!response.ok) { await response.body?.cancel(); fail("playlist_unavailable", "Apple Music didn't show this playlist. Check that it opens in a browser without signing in."); }
44
+ const chunks = [];
45
+ let size = 0;
46
+ for await (const chunk of response.body) {
47
+ size += chunk.length;
48
+ if (size > APPLE_PAGE_MAX) fail("playlist_too_large", "This playlist page is too large to read.");
49
+ chunks.push(chunk);
50
+ }
51
+ return Buffer.concat(chunks).toString("utf8");
52
+ }
53
+
54
+ const text = (value, maximum = 200) => (typeof value === "string" ? Array.from(value.replace(/\s+/gu, " ").trim()).slice(0, maximum).join("") : "");
55
+
56
+ /**
57
+ * An Apple Music playlist from its public page: its name, how many songs it has, and the songs the page
58
+ * lists with their Apple Music ids and covers. A long playlist's page may list only its first songs;
59
+ * read and total say how many.
60
+ */
61
+ export async function readApplePlaylist({ id, url }, { fetchImpl = fetch, signal } = {}) {
62
+ const html = await applePage(url, fetchImpl, signal);
63
+ const block = (pattern) => { const match = pattern.exec(html); try { return match ? JSON.parse(match[1]) : null; } catch { return null; } };
64
+ const schema = block(/<script[^>]*id=schema:music-playlist[^>]*>([\s\S]*?)<\/script>/u) ?? block(/<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/u);
65
+ const data = block(/<script[^>]*id="serialized-server-data"[^>]*>([\s\S]*?)<\/script>/u);
66
+ const tracks = [];
67
+ const seen = new Set();
68
+ // The server data lists each song as a track lockup with its title, its artist and its catalog id.
69
+ const walk = (node, depth) => {
70
+ if (!node || typeof node !== "object" || depth > 12 || tracks.length >= 2000) return;
71
+ if (Array.isArray(node)) { for (const each of node) walk(each, depth + 1); return; }
72
+ const catalog = node.contentDescriptor?.identifiers?.storeAdamID;
73
+ if (typeof node.title === "string" && typeof node.artistName === "string" && node.contentDescriptor?.kind === "song" && catalog) {
74
+ const key = String(catalog);
75
+ if (!seen.has(key)) {
76
+ seen.add(key);
77
+ const artwork = typeof node.artwork?.dictionary?.url === "string" ? node.artwork.dictionary.url.replace("{w}x{h}", "300x300").replace("{f}", "jpg") : null;
78
+ tracks.push({ id: key, title: text(node.title), artist: text(node.artistName), ...(artwork?.startsWith("https://") ? { artwork } : {}) });
79
+ }
80
+ return;
81
+ }
82
+ for (const value of Object.values(node)) walk(value, depth + 1);
83
+ };
84
+ walk(data, 0);
85
+ const name = text(schema?.name) || text(/<meta property="og:title" content="([^"]+)"/u.exec(html)?.[1]) || "Apple Music playlist";
86
+ const total = Number.isSafeInteger(Number(schema?.numTracks)) ? Number(schema.numTracks) : null;
87
+ if (!tracks.length) fail("playlist_unavailable", "Apple Music didn't list the songs of this playlist. Check that it is shared publicly.");
88
+ return { provider: "apple_music", id, url, name, total: total !== null && total >= tracks.length ? total : null, tracks: tracks.filter((track) => track.title && track.artist) };
89
+ }
90
+
91
+ /** A NetEase Music or QQ Music playlist through its public share link, as library songs. */
92
+ export async function readPublicPlaylistLink({ url }, { fetchImpl = fetch } = {}) {
93
+ const playlist = await fetchPublicPlaylist({ input: url, fetchImpl });
94
+ return { provider: playlist.provider, id: playlist.id, url: playlist.url, name: playlist.name, total: playlist.total,
95
+ tracks: playlist.rows.map((row) => ({ id: row.id, title: row.title, artist: row.artist, ...(row.album ? { album: row.album } : {}) })).filter((track) => track.artist) };
96
+ }
@@ -54,7 +54,8 @@ async function boundedJson(response) {
54
54
  catch { throw new Error("The service changed its playlist response or requires sign-in. No music was imported."); }
55
55
  }
56
56
 
57
- export async function readPublicPlaylist({ input, subjectId, capturedAt, provider, fetchImpl = fetch }) {
57
+ /** A public QQ Music or NetEase playlist by its share link: { provider, id, url, name, total, rows, skipped }. */
58
+ export async function fetchPublicPlaylist({ input, provider, fetchImpl = fetch }) {
58
59
  let url = playlistLink(input);
59
60
  let identity = playlistIdentity(url);
60
61
  // Follow only official share redirects, never arbitrary network destinations or cookies.
@@ -93,9 +94,14 @@ export async function readPublicPlaylist({ input, subjectId, capturedAt, provide
93
94
  playlistId: identity.id, playlistName: name, position: index + 1, member: "public-playlist.json",
94
95
  };
95
96
  }).filter((row) => row.id && row.title);
96
- const bytes = Buffer.from(JSON.stringify({ provider: identity.provider, id: identity.id, name, rows }));
97
- const prepared = prepareMusicBundle({ provider: identity.provider, subjectId, capturedAt, rows,
98
- digest: musicHash(bytes), size: bytes.length, members: ["public-playlist.json"], fileName: name, skipped: songs.length - rows.length,
97
+ return { provider: identity.provider, id: identity.id, url: url.href, name, total, rows, skipped: songs.length - rows.length };
98
+ }
99
+
100
+ export async function readPublicPlaylist({ input, subjectId, capturedAt, provider, fetchImpl = fetch }) {
101
+ const { provider: source, id, name, total, rows, skipped } = await fetchPublicPlaylist({ input, provider, fetchImpl });
102
+ const bytes = Buffer.from(JSON.stringify({ provider: source, id, name, rows }));
103
+ const prepared = prepareMusicBundle({ provider: source, subjectId, capturedAt, rows,
104
+ digest: musicHash(bytes), size: bytes.length, members: ["public-playlist.json"], fileName: name, skipped,
99
105
  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
106
  });
101
107
  prepared.preview.source = "public-playlist";
@@ -587,7 +587,8 @@ function refusedMove(error, target, player) {
587
587
  const refused = new SpotifyServiceError("spotify_device_refused",
588
588
  `Spotify would not move playback to ${clipText(target.name, 80)}, either as a transfer or as a song started there. ` +
589
589
  `${stayed ? `Playback stays on ${stayed}${player.is_playing === false ? ", paused" : ""}. ` : ""}` +
590
- "The Spotify app on that device usually needs to be open and played once before it accepts remote control.");
590
+ "The Spotify app on that device usually needs to be open and played once before it accepts remote control. " +
591
+ "Both ways were already tried, so do not try another way in this turn; tell the listener.");
591
592
  refused.status = error?.status;
592
593
  refused.playbackStayedOn = stayed;
593
594
  return refused;
@@ -908,6 +909,26 @@ export function createSpotifyService(options = {}) {
908
909
  tracks: page ? page.items.map((entry) => entry.item).filter(Boolean) : null };
909
910
  },
910
911
 
912
+ // A whole playlist for the listener's library: its name, whether the signed-in listener owns it, and its
913
+ // songs page by page, up to 2,000. Spotify's own editorial playlists answer most apps with not found.
914
+ async readPlaylist({ playlistId } = {}, { signal } = {}) {
915
+ const id = requiredPlaylistId(String(playlistId ?? ""));
916
+ const playlist = await client.getPlaylist({ playlistId: id }, { signal });
917
+ if (!playlist) fail("spotify_playlist_unavailable", "Spotify did not describe this playlist.");
918
+ const tracks = [];
919
+ const pageSize = SPOTIFY_SERVICE_LIMITS.playlistItemsPageMax;
920
+ for (let offset = 0; offset < 2000; offset += pageSize) {
921
+ const page = await client.getPlaylistItems({ playlistId: id, limit: pageSize, offset }, { signal });
922
+ // A local file on the owner's computer has no catalog song to play or name; it stays out.
923
+ tracks.push(...page.items.filter((entry) => entry.is_local !== true).map((entry) => entry.item)
924
+ .filter((item) => item?.type === "track" && item.name && item.artists?.length && /^spotify:track:/u.test(item.uri ?? "")));
925
+ if (!page.has_more || !page.items.length) break;
926
+ }
927
+ const account = await client.getAccount({ signal }).catch((error) => (signal?.aborted ? Promise.reject(error) : null));
928
+ return { name: playlist.name, uri: playlist.uri, tracks_total: playlist.tracks_total ?? null,
929
+ owned: account?.account_id && playlist.owner_id ? account.account_id === playlist.owner_id : null, tracks };
930
+ },
931
+
911
932
  // One device decision for a whole queue request. An idle device cannot take a
912
933
  // queue entry, so the caller starts the first song there and queues the rest.
913
934
  async queueTarget(value, { signal } = {}) {