@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -4
- package/package.json +1 -1
- package/scripts/moondog.mjs +148 -14
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/capability-catalog.mjs +49 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +80 -6
- package/src/core/listening-profile-domain-services.mjs +4 -0
- package/src/core/moondog-application.mjs +578 -32
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +94 -0
- package/src/i18n/screen/ja.mjs +94 -0
- package/src/i18n/screen/pt.mjs +94 -0
- package/src/i18n/screen/zh.mjs +94 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +102 -0
- package/src/integrations/public-playlists.mjs +39 -8
- package/src/integrations/spotify/service.mjs +22 -1
- package/src/integrations/stable-audio/cover.mjs +324 -0
- package/src/integrations/stable-audio/local-generator.mjs +259 -87
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/integrations/web/deepseek-web.mjs +1 -1
- package/src/integrations/web/web-research.mjs +84 -17
- package/src/integrations/web/web-result.mjs +60 -12
- package/src/profile/listening-history-store.mjs +55 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +14 -5
- package/src/runtime/pi/agent-runtime.mjs +359 -37
- package/src/runtime/pi/configured-runtime.mjs +3 -0
- package/src/runtime/pi/image-input.mjs +148 -0
- package/src/runtime/pi/model-roles.mjs +121 -0
- package/src/runtime/pi/model-settings.mjs +63 -2
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/record-name.mjs +80 -0
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/runtime-settings.mjs +4 -2
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/model-roles-command.mjs +60 -0
- package/src/surfaces/cli/tui.mjs +419 -19
package/src/i18n/screen/zh.mjs
CHANGED
|
@@ -713,6 +713,8 @@ export default {
|
|
|
713
713
|
"Use /taste, or /taste report.": "使用 /taste 或 /taste report。",
|
|
714
714
|
"Full report above. /taste opens the profile.": "完整报告在上面。/taste 打开资料。",
|
|
715
715
|
"Web lookups aren't available when Moondog is started this way.": "这种启动方式不支持网页查询。",
|
|
716
|
+
"Model roles aren't available when Moondog is started this way.": "这种启动方式不支持设置模型用途。",
|
|
717
|
+
"Looking at the attached images... Ctrl+C cancels.": "正在看附上的图片…… Ctrl+C 取消。",
|
|
716
718
|
"/home doesn't take anything after it.": "/home 后面不需要加内容。",
|
|
717
719
|
"Say anything, or press Tab to look around.": "随便说点什么,或按 Tab 四处看看。",
|
|
718
720
|
"Use /theme, or /theme paper, charcoal, terminal, or auto.": "使用 /theme,或 /theme paper、charcoal、terminal 或 auto。",
|
|
@@ -874,6 +876,7 @@ export default {
|
|
|
874
876
|
"See what you're listening to": "查看你在听什么",
|
|
875
877
|
"Control the Music app": "控制“音乐”App",
|
|
876
878
|
"Choose a music service": "选择音乐服务",
|
|
879
|
+
"Read or change Moondog's settings": "读取或更改 Moondog 的设置",
|
|
877
880
|
"Search outside your library": "在你的曲库之外搜索",
|
|
878
881
|
"Find music nearby": "找附近的音乐",
|
|
879
882
|
"Remember what you've told me": "记住你告诉我的",
|
|
@@ -1053,4 +1056,95 @@ export default {
|
|
|
1053
1056
|
"Play a track Moondog made": "再放一首 Moondog 做的曲子",
|
|
1054
1057
|
"Start a stream": "开始音乐流",
|
|
1055
1058
|
"Stop the stream": "停止音乐流",
|
|
1059
|
+
"Go on air: Moondog keeps making music from your taste": "开播:Moondog 照着你的口味一直做音乐",
|
|
1060
|
+
"on air until {time}": "播出到 {time}",
|
|
1061
|
+
"Cutting the next record": "正在刻下一张",
|
|
1062
|
+
"Off air: the next record couldn't be made.": "停播了:下一张没刻成。",
|
|
1063
|
+
"The next record is still being cut.": "下一张还在刻。",
|
|
1064
|
+
"Off air.": "已停播。",
|
|
1065
|
+
"Turn on music making first: /generation on.": "先开启音乐生成:/generation on。",
|
|
1066
|
+
"Download a Stable Audio 3 model from Stability AI": "从 Stability AI 下载一个 Stable Audio 3 模型",
|
|
1067
|
+
"Make music with a downloaded model": "用已下载的模型做音乐",
|
|
1068
|
+
"Delete a downloaded model from this Mac": "从这台 Mac 删除一个已下载的模型",
|
|
1069
|
+
"Stop the download": "停止下载",
|
|
1070
|
+
"Medium: best sound, about 5.2 GB": "Medium:音质最好,约 5.2 GB",
|
|
1071
|
+
"Small Music: faster, lighter, about 1.7 GB": "Small Music:更快更轻,约 1.7 GB",
|
|
1072
|
+
"getting ready": "准备中",
|
|
1073
|
+
"There isn't enough free space on this Mac for the download. Free some up, then use /generation download again.": "这台 Mac 的可用空间不够下载。腾出一些空间,再用一次 /generation download。",
|
|
1074
|
+
"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 重试。",
|
|
1075
|
+
"The download couldn't reach its source. Check your connection, then use /generation download to try again.": "下载连不上来源。检查网络连接,再用 /generation download 重试。",
|
|
1076
|
+
"Another Moondog window or terminal is already downloading. /generation shows how far it is.": "另一个 Moondog 窗口或终端已经在下载。/generation 可以看进度。",
|
|
1077
|
+
"The download didn't finish: {error}. Use /generation download to try again.": "下载没有完成:{error}。用 /generation download 重试。",
|
|
1078
|
+
"unknown error": "未知错误",
|
|
1079
|
+
"Stable Audio 3 {model} is ready. Ask for a track.": "Stable Audio 3 {model} 已就绪。想听就让它做一首。",
|
|
1080
|
+
"Stable Audio 3 {model} is downloaded. Use it with /generation use {choice}.": "Stable Audio 3 {model} 已下载。用 /generation use {choice} 来使用它。",
|
|
1081
|
+
"Download stopped. /generation download {choice} picks it up where it left off.": "下载已停止。/generation download {choice} 会从停下的地方接着下。",
|
|
1082
|
+
"download stopped on an error": "下载因错误停止",
|
|
1083
|
+
"downloading": "下载中",
|
|
1084
|
+
"downloading, {percent}%": "下载中,{percent}%",
|
|
1085
|
+
"installed, in use": "已安装,使用中",
|
|
1086
|
+
"installed": "已安装",
|
|
1087
|
+
"partly downloaded": "已下载一部分",
|
|
1088
|
+
"not downloaded": "未下载",
|
|
1089
|
+
"{size} GB": "{size} GB",
|
|
1090
|
+
"In use: your own install at {folder}.": "使用中:你自己在 {folder} 的安装。",
|
|
1091
|
+
"Get a model with /generation download medium or small. /generation use, remove and cancel manage them.": "用 /generation download medium 或 small 获取模型。/generation use、remove 和 cancel 用来管理它们。",
|
|
1092
|
+
"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;下载完成后会开启音乐生成。",
|
|
1093
|
+
"Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.": "Stable Audio 3 {model} 还没下载。用 /generation download {choice} 获取。",
|
|
1094
|
+
"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 开启。",
|
|
1095
|
+
"Use /generation download, use or remove with medium or small, or /generation cancel.": "用 /generation download、use 或 remove 加上 medium 或 small,或用 /generation cancel。",
|
|
1096
|
+
"No download is running.": "没有正在进行的下载。",
|
|
1097
|
+
"Stable Audio 3 {model} is already downloading.": "Stable Audio 3 {model} 已经在下载了。",
|
|
1098
|
+
"Another download is running. /generation cancel stops it.": "另一个下载正在进行。/generation cancel 可以停止它。",
|
|
1099
|
+
"Stable Audio 3 {model} is already on this Mac. Use it with /generation use {choice}.": "Stable Audio 3 {model} 已经在这台 Mac 上。用 /generation use {choice} 来使用它。",
|
|
1100
|
+
"Already downloaded.": "已经下载过了。",
|
|
1101
|
+
"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",
|
|
1102
|
+
"Waiting for your agreement.": "等你同意。",
|
|
1103
|
+
"The download didn't finish.": "下载没有完成。",
|
|
1104
|
+
"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 可以停止,之后再下载会从停下的地方接着下。",
|
|
1105
|
+
"Downloading Stable Audio 3 {model}.": "正在下载 Stable Audio 3 {model}。",
|
|
1106
|
+
"Wait for the download to finish, or stop it with /generation cancel, before removing a model.": "删除模型前,先等下载完成,或用 /generation cancel 停止它。",
|
|
1107
|
+
"A download is running.": "有下载正在进行。",
|
|
1108
|
+
"Stable Audio 3 {model} isn't on this Mac.": "这台 Mac 上没有 Stable Audio 3 {model}。",
|
|
1109
|
+
"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",
|
|
1110
|
+
"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",
|
|
1111
|
+
"Nothing removed yet.": "还没有删除任何东西。",
|
|
1112
|
+
"Removed Stable Audio 3 {model} from this Mac.": "已从这台 Mac 删除 Stable Audio 3 {model}。",
|
|
1113
|
+
"Removed.": "已删除。",
|
|
1114
|
+
"Removed. Music making isn't ready.": "已删除。音乐生成还没准备好。",
|
|
1115
|
+
"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。",
|
|
1116
|
+
"best sound": "音质最好",
|
|
1117
|
+
"faster, lighter": "更快更轻",
|
|
1118
|
+
"getting uv": "正在获取 uv",
|
|
1119
|
+
"getting Stable Audio 3's code": "正在获取 Stable Audio 3 的代码",
|
|
1120
|
+
"setting up Python": "正在准备 Python",
|
|
1121
|
+
"downloading the model": "正在下载模型",
|
|
1122
|
+
"checking the files": "正在校验文件",
|
|
1123
|
+
"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 重试。",
|
|
1124
|
+
"The library isn't available here.": "这里用不了资料库。",
|
|
1125
|
+
"Saved": "收藏",
|
|
1126
|
+
"Play one with `/library play <name>`, or keep the song playing now with `/library save`.": "用 `/library play <名字>` 播放一个,或者用 `/library save` 收藏正在播放的歌。",
|
|
1127
|
+
"No playlist is called “{name}”.": "没有叫“{name}”的播放列表。",
|
|
1128
|
+
"{name} has nothing in it yet.": "{name} 里还是空的。",
|
|
1129
|
+
"None of its songs could be found.": "里面的歌都没有找到。",
|
|
1130
|
+
"Playing {name}.": "正在播放 {name}。",
|
|
1131
|
+
"Moondog's tracks were left out. They play with Apple Music chosen.": "Moondog 做的曲子没有放。选择 Apple Music 时才会播放它们。",
|
|
1132
|
+
"Some songs couldn't be found.": "有些歌没有找到。",
|
|
1133
|
+
"Already in {name}.": "已经在“{name}”里了。",
|
|
1134
|
+
"Saved.": "已收藏。",
|
|
1135
|
+
"Saved to {name}.": "已收藏到“{name}”。",
|
|
1136
|
+
"Your saved songs and playlists": "你收藏的歌和播放列表",
|
|
1137
|
+
"Look in the library": "翻翻资料库",
|
|
1138
|
+
"Save to the library": "收进资料库",
|
|
1139
|
+
"Play from the library": "播放资料库里的歌",
|
|
1140
|
+
"Paste a playlist link after /library import.": "在 /library import 后面粘贴歌单链接。",
|
|
1141
|
+
"Reading the playlist…": "正在读取歌单…",
|
|
1142
|
+
"{added} of {total} songs came in.": "{total} 首中导入了 {added} 首。",
|
|
1143
|
+
"{count} songs came in.": { one: "导入了 {count} 首。", other: "导入了 {count} 首。" },
|
|
1144
|
+
"It now shapes your taste.": "它现在会影响你的口味画像。",
|
|
1145
|
+
"It couldn't shape your taste this time.": "这次没能把它加入你的口味画像。",
|
|
1146
|
+
"It doesn't shape your taste; add taste after the link if it should.": "它不会影响你的口味;如果需要,在链接后加 taste。",
|
|
1147
|
+
"Imported {name}.": "已导入“{name}”。",
|
|
1148
|
+
"Use /library, /library play <name>, /library save [playlist], or /library import <link>.": "用 /library、/library play <名字>、/library save [播放列表] 或 /library import <链接>。",
|
|
1149
|
+
"Import a playlist": "导入歌单",
|
|
1056
1150
|
};
|
|
@@ -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
|
-
|
|
56
|
-
const
|
|
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
|
-
|
|
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)
|
|
308
|
-
|
|
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,102 @@
|
|
|
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
|
+
// The public reader explains itself in words; the library's surfaces also need its kind, to say it in their language.
|
|
94
|
+
const playlist = await fetchPublicPlaylist({ input: url, fetchImpl }).catch((error) => {
|
|
95
|
+
// Only the link checks' own refusals are about the link; any answer the service gave is about the playlist.
|
|
96
|
+
const aboutLink = /^(Paste a QQ Music|Use a playlist link|Open this share link|This link belongs to another service)/u;
|
|
97
|
+
if (!error.code) error.code = /^Could not reach/u.test(error.message) ? "playlist_unreachable" : aboutLink.test(error.message) ? "playlist_link_unsupported" : "playlist_unavailable";
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
return { provider: playlist.provider, id: playlist.id, url: playlist.url, name: playlist.name, total: playlist.total,
|
|
101
|
+
tracks: playlist.rows.map((row) => ({ id: row.id, title: row.title, artist: row.artist, ...(row.album ? { album: row.album } : {}), ...(row.artwork ? { artwork: row.artwork } : {}) })).filter((track) => track.artist) };
|
|
102
|
+
}
|
|
@@ -54,7 +54,32 @@ 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
|
-
|
|
57
|
+
// A NetEase playlist, as { code, result: { id, name, privacy, trackCount, tracks } }. NetEase's web player reads
|
|
58
|
+
// playlists with v6, which answers every public one, a listener's Liked songs among them, with all its song ids but
|
|
59
|
+
// details for only the first few without a sign-in; the rest come from song/detail, 200 at a time, in the
|
|
60
|
+
// playlist's order. The older endpoint, which some playlists answer with code 20001, is the fallback.
|
|
61
|
+
const NETEASE_SONGS_MAX = 3000;
|
|
62
|
+
async function neteasePlaylist(id, fetchImpl) {
|
|
63
|
+
const referer = "https://music.163.com/";
|
|
64
|
+
const current = await boundedJson(await responseFor(`https://music.163.com/api/v6/playlist/detail?id=${id}&n=100000`, fetchImpl, referer)).catch(() => null);
|
|
65
|
+
const playlist = current?.code === 200 ? current.playlist : null;
|
|
66
|
+
if (!playlist || !Array.isArray(playlist.trackIds)) {
|
|
67
|
+
return boundedJson(await responseFor(`https://music.163.com/api/playlist/detail?id=${id}`, fetchImpl, referer));
|
|
68
|
+
}
|
|
69
|
+
const ids = playlist.trackIds.map((entry) => String(entry?.id ?? "")).filter((value) => numericId.test(value)).slice(0, NETEASE_SONGS_MAX);
|
|
70
|
+
const songs = new Map((Array.isArray(playlist.tracks) ? playlist.tracks : []).map((song) => [String(song?.id ?? ""), song]));
|
|
71
|
+
const missing = ids.filter((songId) => !songs.has(songId));
|
|
72
|
+
for (let start = 0; start < missing.length; start += 200) {
|
|
73
|
+
const batch = missing.slice(start, start + 200);
|
|
74
|
+
const detail = await boundedJson(await responseFor(`https://music.163.com/api/song/detail/?ids=${encodeURIComponent(`[${batch.join(",")}]`)}`, fetchImpl, referer));
|
|
75
|
+
for (const song of Array.isArray(detail?.songs) ? detail.songs : []) songs.set(String(song?.id ?? ""), song);
|
|
76
|
+
}
|
|
77
|
+
return { code: 200, result: { id: playlist.id, name: playlist.name, privacy: playlist.privacy, trackCount: playlist.trackCount ?? ids.length,
|
|
78
|
+
tracks: ids.map((songId) => songs.get(songId)).filter(Boolean) } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A public QQ Music or NetEase playlist by its share link: { provider, id, url, name, total, rows, skipped }. */
|
|
82
|
+
export async function fetchPublicPlaylist({ input, provider, fetchImpl = fetch }) {
|
|
58
83
|
let url = playlistLink(input);
|
|
59
84
|
let identity = playlistIdentity(url);
|
|
60
85
|
// Follow only official share redirects, never arbitrary network destinations or cookies.
|
|
@@ -69,10 +94,9 @@ export async function readPublicPlaylist({ input, subjectId, capturedAt, provide
|
|
|
69
94
|
if (!identity) throw new Error("Open this share link in your browser, then copy the full playlist page URL. Song, album and private playlist links are not supported.");
|
|
70
95
|
if (provider && ["qq_music", "netease"].includes(provider) && provider !== identity.provider) throw new Error("This link belongs to another service. Go back and choose that service, or paste the matching playlist link.");
|
|
71
96
|
const qq = identity.provider === "qq_music";
|
|
72
|
-
const
|
|
73
|
-
? `https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&disstid=${identity.id}&format=json
|
|
74
|
-
:
|
|
75
|
-
const data = await boundedJson(await responseFor(endpoint, fetchImpl, qq ? "https://y.qq.com/" : "https://music.163.com/"));
|
|
97
|
+
const data = qq
|
|
98
|
+
? await boundedJson(await responseFor(`https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&disstid=${identity.id}&format=json`, fetchImpl, "https://y.qq.com/"))
|
|
99
|
+
: await neteasePlaylist(identity.id, fetchImpl);
|
|
76
100
|
const playlist = qq ? data?.cdlist?.[0] : data?.result;
|
|
77
101
|
if ((qq ? data?.code !== 0 || data?.subcode !== 0 : data?.code !== 200) || !playlist
|
|
78
102
|
|| (qq ? Number(playlist.dir_show) === 0 : Number(playlist.privacy) === 10)) throw new Error("This playlist is unavailable publicly. Choose a playlist you can open without signing in; no account password or cookies are needed.");
|
|
@@ -90,12 +114,19 @@ export async function readPublicPlaylist({ input, subjectId, capturedAt, provide
|
|
|
90
114
|
title: musicText(qq ? song.songname : song.name),
|
|
91
115
|
artist: musicText(Array.isArray(artists) ? artists.map((artist) => musicText(artist?.name)).filter(Boolean).join(", ") : ""),
|
|
92
116
|
album: musicText(qq ? song.albumname : (song.album ?? song.al)?.name),
|
|
117
|
+
// NetEase names each album cover on its own image host; it is shown over https.
|
|
118
|
+
...(!qq && /^https?:\/\/p[0-9]+\.music\.126\.net\//u.test((song.album ?? song.al)?.picUrl ?? "") ? { artwork: (song.album ?? song.al).picUrl.replace(/^http:/u, "https:") } : {}),
|
|
93
119
|
playlistId: identity.id, playlistName: name, position: index + 1, member: "public-playlist.json",
|
|
94
120
|
};
|
|
95
121
|
}).filter((row) => row.id && row.title);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
return { provider: identity.provider, id: identity.id, url: url.href, name, total, rows, skipped: songs.length - rows.length };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readPublicPlaylist({ input, subjectId, capturedAt, provider, fetchImpl = fetch }) {
|
|
126
|
+
const { provider: source, id, name, total, rows, skipped } = await fetchPublicPlaylist({ input, provider, fetchImpl });
|
|
127
|
+
const bytes = Buffer.from(JSON.stringify({ provider: source, id, name, rows }));
|
|
128
|
+
const prepared = prepareMusicBundle({ provider: source, subjectId, capturedAt, rows,
|
|
129
|
+
digest: musicHash(bytes), size: bytes.length, members: ["public-playlist.json"], fileName: name, skipped,
|
|
99
130
|
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
131
|
});
|
|
101
132
|
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 } = {}) {
|