ym-mcp-wangyi 0.1.0

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.
@@ -0,0 +1,101 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ export const APP_NAME = "网易云音乐";
5
+ export const SEARCH_URL = "https://music.163.com/api/cloudsearch/pc";
6
+ export const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) NeteaseMusic/3.1.12";
7
+ const STORAGE_MARKERS = ["mmkv.default", "CEFCache", "file_storage"];
8
+ function homeDir(env) {
9
+ return env.HOME || homedir();
10
+ }
11
+ function looksLikeStorage(dir) {
12
+ return STORAGE_MARKERS.some((name) => existsSync(join(dir, name)));
13
+ }
14
+ /** Candidate user-data / storage roots for NetEase desktop client. */
15
+ export function storageCandidates(platform = process.platform, env = process.env) {
16
+ const home = homeDir(env);
17
+ if (platform === "darwin") {
18
+ return [
19
+ join(home, "Library", "Containers", "com.netease.163music", "Data", "Documents", "storage"),
20
+ join(home, "Library", "Application Support", "com.netease.163music", "Documents", "storage"),
21
+ join(home, "Library", "Application Support", "com.netease.163music", "storage"),
22
+ join(home, "Library", "Application Support", "NeteaseMusic", "storage"),
23
+ ];
24
+ }
25
+ if (platform === "win32") {
26
+ const roaming = env.APPDATA || join(home, "AppData", "Roaming");
27
+ const local = env.LOCALAPPDATA || join(home, "AppData", "Local");
28
+ const roots = [roaming, local];
29
+ const brands = ["NetEase", "Netease", "NeteaseCloudMusic", "NeteaseMusic", "CloudMusic"];
30
+ const tails = [
31
+ ["CloudMusic", "storage"],
32
+ ["CloudMusic", "Documents", "storage"],
33
+ ["CloudMusic", "data", "storage"],
34
+ ["storage"],
35
+ ["Documents", "storage"],
36
+ ];
37
+ return roots.flatMap((root) => brands.flatMap((brand) => tails.map((tail) => join(root, brand, ...tail))));
38
+ }
39
+ return [
40
+ join(home, ".config", "NetEase", "CloudMusic", "storage"),
41
+ join(home, ".config", "NeteaseMusic", "storage"),
42
+ ];
43
+ }
44
+ export function resolveStorageDir(platform = process.platform, env = process.env) {
45
+ const candidates = storageCandidates(platform, env);
46
+ return candidates.find(looksLikeStorage) || candidates[0];
47
+ }
48
+ let cachedStorageDir = "";
49
+ export function storageDir() {
50
+ if (!cachedStorageDir)
51
+ cachedStorageDir = resolveStorageDir();
52
+ return cachedStorageDir;
53
+ }
54
+ /** Test helper: clear cached path resolution. */
55
+ export function resetStorageDirCache() {
56
+ cachedStorageDir = "";
57
+ }
58
+ export function mmkvPath() {
59
+ return join(storageDir(), "mmkv.default");
60
+ }
61
+ export function cookieDbPath() {
62
+ return join(storageDir(), "CEFCache", "Cookies");
63
+ }
64
+ export function localStatePath() {
65
+ return join(storageDir(), "CEFCache", "Local State");
66
+ }
67
+ export function playingListPath() {
68
+ return join(storageDir(), "file_storage", "webdata", "file", "playingList");
69
+ }
70
+ export function hasUserData() {
71
+ return looksLikeStorage(storageDir());
72
+ }
73
+ export function installCandidates(platform = process.platform, env = process.env) {
74
+ const home = homeDir(env);
75
+ if (platform === "darwin") {
76
+ return [
77
+ join("/Applications", "NeteaseMusic.app"),
78
+ join(home, "Applications", "NeteaseMusic.app"),
79
+ join("/Applications", `${APP_NAME}.app`),
80
+ join(home, "Applications", `${APP_NAME}.app`),
81
+ ];
82
+ }
83
+ if (platform === "win32") {
84
+ const local = env.LOCALAPPDATA || join(home, "AppData", "Local");
85
+ const programFiles = env.ProgramW6432 || env.ProgramFiles || "C:\\Program Files";
86
+ const programFilesX86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
87
+ const exeNames = ["cloudmusic.exe", "CloudMusic.exe", "NeteaseCloudMusic.exe"];
88
+ const dirNames = ["CloudMusic", "Netease", "NetEase", "NeteaseCloudMusic"];
89
+ const roots = [
90
+ join(local, "Programs"),
91
+ programFiles,
92
+ programFilesX86,
93
+ join(programFiles, "Netease"),
94
+ join(programFilesX86, "Netease"),
95
+ join(local, "NetEase"),
96
+ join(local, "Netease"),
97
+ ];
98
+ return roots.flatMap((root) => dirNames.flatMap((dir) => exeNames.map((exe) => join(root, dir, exe))));
99
+ }
100
+ return [];
101
+ }
@@ -0,0 +1,45 @@
1
+ import { ToolError } from "./errors.js";
2
+ import { isInstalled, openOrpheusUrl } from "./process.js";
3
+ import { writeLastPlay } from "./last-play.js";
4
+ import { cdpPlayerState, isCdpReady } from "./cdp.js";
5
+ /** Desktop orpheus command used by the official Windows/macOS clients. */
6
+ export function playUrl(trackId, kind = "song") {
7
+ const command = { type: kind, id: String(trackId), cmd: "play" };
8
+ const encoded = Buffer.from(JSON.stringify(command), "utf8").toString("base64");
9
+ return `orpheus://${encoded}`;
10
+ }
11
+ async function waitForCdpSong(songId, timeoutMs = 8000) {
12
+ const start = Date.now();
13
+ while (Date.now() - start < timeoutMs) {
14
+ try {
15
+ const state = await cdpPlayerState();
16
+ if (state.songId === songId)
17
+ return true;
18
+ }
19
+ catch {
20
+ // CDP may briefly disconnect while the client switches tracks.
21
+ }
22
+ await new Promise((r) => setTimeout(r, 300));
23
+ }
24
+ return false;
25
+ }
26
+ export async function playTrackById(trackId, _title = "") {
27
+ const id = String(trackId || "").trim();
28
+ if (!/^\d+$/.test(id)) {
29
+ throw new ToolError("INVALID_ARGS", "track id 必须是数字", "先 wangyi_search 拿 id,再 wangyi_play");
30
+ }
31
+ if (!isInstalled()) {
32
+ throw new ToolError("NOT_INSTALLED", "未找到网易云音乐客户端", "先安装并登录网易云音乐,再重试");
33
+ }
34
+ const url = playUrl(id);
35
+ openOrpheusUrl(url);
36
+ writeLastPlay(id);
37
+ if (await isCdpReady()) {
38
+ const ok = await waitForCdpSong(id);
39
+ if (!ok) {
40
+ throw new ToolError("PLAY_NOT_CONFIRMED", `已发送播放指令,但客户端未切到 ${id}`, "确认网易云在前台且已登录;可再执行一次 launch 后重试");
41
+ }
42
+ return `${url}#via=orpheus+cdp-confirmed`;
43
+ }
44
+ return `${url}#via=orpheus`;
45
+ }
@@ -0,0 +1,51 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { hasUserData, installCandidates } from "./paths.js";
4
+ export function isInstalled() {
5
+ if (hasUserData())
6
+ return true;
7
+ return installCandidates().some((p) => existsSync(p));
8
+ }
9
+ export function isRunning() {
10
+ if (process.platform === "darwin") {
11
+ const result = spawnSync("pgrep", ["-x", "NeteaseMusic"], { encoding: "utf8" });
12
+ return result.status === 0 && Boolean(result.stdout.trim());
13
+ }
14
+ if (process.platform === "win32") {
15
+ const result = spawnSync("tasklist", ["/FI", "IMAGENAME eq cloudmusic.exe", "/FO", "CSV", "/NH"], { encoding: "utf8", windowsHide: true });
16
+ const out = (result.stdout || "").toLowerCase();
17
+ return out.includes("cloudmusic.exe");
18
+ }
19
+ return false;
20
+ }
21
+ export function orpheusProtocolOk() {
22
+ return isInstalled();
23
+ }
24
+ export function protocolOpener(url, platform = process.platform) {
25
+ if (platform === "win32") {
26
+ const safe = url.replace(/"/g, "");
27
+ return {
28
+ cmd: "cmd.exe",
29
+ args: ["/d", "/s", "/c", `start "" "${safe}"`],
30
+ windowsVerbatimArguments: true,
31
+ };
32
+ }
33
+ if (platform === "darwin") {
34
+ // Bind to the official app so Launch Services does not drop custom orpheus commands.
35
+ return { cmd: "open", args: ["-a", "NeteaseMusic", url] };
36
+ }
37
+ return { cmd: "xdg-open", args: [url] };
38
+ }
39
+ export function openOrpheusUrl(url) {
40
+ const { cmd, args, windowsVerbatimArguments } = protocolOpener(url);
41
+ const result = spawnSync(cmd, args, {
42
+ timeout: 10000,
43
+ stdio: "ignore",
44
+ windowsVerbatimArguments,
45
+ });
46
+ if (result.error)
47
+ throw result.error;
48
+ if (result.status !== 0 && result.status != null) {
49
+ throw new Error(`${cmd} exited ${result.status}`);
50
+ }
51
+ }
@@ -0,0 +1,131 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { playingListPath } from "./paths.js";
3
+ import { ToolError } from "./errors.js";
4
+ import { playTrackById } from "./play.js";
5
+ import { readLastPlay } from "./last-play.js";
6
+ import { cdpPlayerState, cdpSkip, isCdpReady } from "./cdp.js";
7
+ function formatDuration(ms) {
8
+ const total = Math.round(Number(ms || 0) / 1000);
9
+ if (!total)
10
+ return "";
11
+ const m = Math.floor(total / 60);
12
+ const s = String(total % 60).padStart(2, "0");
13
+ return `${m}:${s}`;
14
+ }
15
+ function artistNames(artists) {
16
+ if (!Array.isArray(artists))
17
+ return "";
18
+ return artists
19
+ .map((a) => (a && typeof a === "object" && "name" in a ? String(a.name || "") : ""))
20
+ .filter(Boolean)
21
+ .join(", ");
22
+ }
23
+ function mapTrack(item, index, currentId, source) {
24
+ const track = item.track || {};
25
+ const id = String(track.id || item.id || "");
26
+ return {
27
+ index: index + 1,
28
+ current: id === currentId,
29
+ id,
30
+ title: track.name || "",
31
+ artist: artistNames(track.artists),
32
+ album: (track.album && track.album.name) || "",
33
+ duration: formatDuration(track.duration),
34
+ source,
35
+ };
36
+ }
37
+ function readCachedQueue() {
38
+ const file = playingListPath();
39
+ if (!existsSync(file)) {
40
+ throw new ToolError("QUEUE_MISSING", "读不到网易云音乐的播放队列缓存", `客户端播过歌后才会写入 ${file}`);
41
+ }
42
+ let data;
43
+ try {
44
+ data = JSON.parse(readFileSync(file, "utf8"));
45
+ }
46
+ catch {
47
+ throw new ToolError("QUEUE_MISSING", "播放队列缓存无法解析", file);
48
+ }
49
+ const list = Array.isArray(data.list) ? [...data.list] : [];
50
+ if (!list.length) {
51
+ throw new ToolError("QUEUE_EMPTY", "播放队列缓存是空的", "先在客户端播一首歌");
52
+ }
53
+ list.sort((a, b) => Number(a.displayOrder ?? 0) - Number(b.displayOrder ?? 0));
54
+ const tracksPreview = list.map((item) => String(item.track?.id || item.id || ""));
55
+ const last = readLastPlay();
56
+ let index = 0;
57
+ if (last?.id) {
58
+ const found = tracksPreview.findIndex((id) => id === last.id);
59
+ if (found >= 0)
60
+ index = found;
61
+ }
62
+ const currentId = tracksPreview[index] || "";
63
+ const tracks = list.map((item, i) => mapTrack(item, i, currentId, "playingList"));
64
+ return {
65
+ source: "playingList",
66
+ hasMore: false,
67
+ index,
68
+ tracks,
69
+ current: tracks[index],
70
+ };
71
+ }
72
+ export async function readPlayerQueue() {
73
+ if (await isCdpReady()) {
74
+ try {
75
+ const state = await cdpPlayerState();
76
+ if (state.queue.length) {
77
+ const currentId = state.songId || state.queue[0]?.id || "";
78
+ const index = Math.max(0, state.queue.findIndex((t) => t.id === currentId));
79
+ const tracks = state.queue.map((t, i) => ({
80
+ index: i + 1,
81
+ current: t.id === currentId,
82
+ id: t.id,
83
+ title: t.title,
84
+ artist: "",
85
+ album: "",
86
+ duration: "",
87
+ source: "cdp",
88
+ }));
89
+ return {
90
+ source: "cdp",
91
+ hasMore: false,
92
+ index: index < 0 ? 0 : index,
93
+ tracks,
94
+ current: tracks[index < 0 ? 0 : index],
95
+ };
96
+ }
97
+ }
98
+ catch {
99
+ // fall through to file cache
100
+ }
101
+ }
102
+ return readCachedQueue();
103
+ }
104
+ export async function skipPlayerQueue(delta) {
105
+ if (await isCdpReady()) {
106
+ try {
107
+ await cdpSkip(delta > 0 ? 1 : -1);
108
+ await new Promise((r) => setTimeout(r, 400));
109
+ const queue = await readPlayerQueue();
110
+ return {
111
+ ...queue.current,
112
+ current: true,
113
+ status: delta > 0 ? "next" : "prev",
114
+ };
115
+ }
116
+ catch {
117
+ // fall through to id-based skip
118
+ }
119
+ }
120
+ const queue = await readPlayerQueue();
121
+ const nextIndex = queue.index + delta;
122
+ if (nextIndex < 0) {
123
+ throw new ToolError("EMPTY_RESULT", "已经是当前列表第一首");
124
+ }
125
+ if (nextIndex >= queue.tracks.length) {
126
+ throw new ToolError("EMPTY_RESULT", "已经是当前列表最后一首");
127
+ }
128
+ const track = queue.tracks[nextIndex];
129
+ await playTrackById(track.id, track.title);
130
+ return { ...track, current: true, index: nextIndex + 1, status: delta > 0 ? "next" : "prev" };
131
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cookieDbPath, mmkvPath, playingListPath, storageDir } from "./paths.js";
3
+ import { loadCookies } from "./cookies.js";
4
+ import { isInstalled, isRunning, orpheusProtocolOk } from "./process.js";
5
+ import { readPlayerQueue } from "./queue.js";
6
+ import { DEFAULT_CDP_PORT, isCdpReady } from "./cdp.js";
7
+ export async function getStatus() {
8
+ const hints = [];
9
+ if (process.platform === "linux") {
10
+ hints.push("网易云音乐官方桌面端主要支持 Windows / macOS;Linux 上协议播放可能不可用");
11
+ }
12
+ const installed = isInstalled();
13
+ if (!installed) {
14
+ hints.push("未检测到网易云音乐用户数据或安装目录,请先安装并打开一次客户端");
15
+ }
16
+ const running = isRunning();
17
+ const cookies = loadCookies();
18
+ const loggedIn = cookies.loggedIn;
19
+ if (!loggedIn) {
20
+ hints.push(cookies.cookieEncrypted
21
+ ? `Cookie 仍是密文,未能解密 MUSIC_U。CEF:${cookieDbPath()};MMKV:${mmkvPath()}`
22
+ : `未读到登录态。请先用网易云音乐登录。存储:${storageDir()}`);
23
+ }
24
+ let queueOk = false;
25
+ try {
26
+ if (existsSync(playingListPath()) || (await isCdpReady())) {
27
+ const queue = await readPlayerQueue();
28
+ queueOk = queue.tracks.length > 0;
29
+ }
30
+ }
31
+ catch {
32
+ queueOk = false;
33
+ }
34
+ if (!queueOk) {
35
+ hints.push(`播放队列为空或无法解析:${playingListPath()},先在客户端播一首歌`);
36
+ }
37
+ const protocolOk = orpheusProtocolOk();
38
+ const cdpOk = await isCdpReady(DEFAULT_CDP_PORT);
39
+ if (!cdpOk) {
40
+ hints.push(`要真正控制桌面端播放/切歌,请先执行: node dist/index.js launch(会以 CDP 端口 ${DEFAULT_CDP_PORT} 重启网易云)`);
41
+ }
42
+ const ready = installed && loggedIn;
43
+ return {
44
+ ready,
45
+ installed,
46
+ running,
47
+ logged_in: loggedIn,
48
+ cookie_encrypted: cookies.cookieEncrypted,
49
+ queue_ok: queueOk,
50
+ protocol_ok: protocolOk,
51
+ cdp_ok: cdpOk,
52
+ cdp_port: DEFAULT_CDP_PORT,
53
+ storage_dir: storageDir(),
54
+ cookie_source: cookies.source,
55
+ platform: process.platform,
56
+ hints,
57
+ };
58
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "ym-mcp-wangyi",
3
+ "version": "0.1.0",
4
+ "description": "网易云音乐 MCP:搜索、播放、队列切歌(macOS / Windows)",
5
+ "type": "module",
6
+ "bin": {
7
+ "ym-mcp-wangyi": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "packageManager": "pnpm@10.0.0",
18
+ "scripts": {
19
+ "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
20
+ "build": "pnpm clean && tsc",
21
+ "start": "node dist/index.js",
22
+ "cli": "node dist/index.js",
23
+ "test": "pnpm build && node --experimental-strip-types --test --test-concurrency=1 test/features.test.ts",
24
+ "prepublishOnly": "pnpm test"
25
+ },
26
+ "keywords": [
27
+ "mcp",
28
+ "netease",
29
+ "wangyi",
30
+ "网易云音乐"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.25.1"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.5.2",
38
+ "typescript": "^5.9.2"
39
+ }
40
+ }