32 KiB
netease-music 参考(架构 / 接口 / 播放器 / 坑)
1. 为什么是这套架构
以前的做法是模拟鼠标点官方客户端界面:慢(每次要点窗口、等动画)、脆(页面结构一变就废)、功能受界面限制, 连「是不是在播」都只能靠截屏看进度条,结果误判过(时间读数卡住但仍报 paused)。
现在三层,各自可单独验证:
| 层 | 文件 | 职责 |
|---|---|---|
| 取数 | scripts/netease-api.mjs |
直连 music.163.com 的 weapi(自写加密,零 npm 依赖,不跑后台服务) |
| 播放 | scripts/mpv-ctl.mjs |
常驻 mpv + unix socket JSON IPC:播放/暂停/切歌/音量/进度/真状态 |
| 队列 | scripts/lib/build-queue.py |
歌曲列表 + 播放地址 → queue.json(反查 id)+ queue.m3u8(mpv 队列,带 EXTINF 标题) |
| 入口 | scripts/netease-music.sh |
用户意图 → 上面三层 |
为什么不用 NeteaseCloudMusicApi(npm 那个项目):要装一大棵依赖树 + 常驻 HTTP 服务,而本机只用到十来个端点; 自写客户端启动 0 开销、不受上游版本变动影响。协议细节见下,改起来有据可依。
2. 接口协议(实测)
POST https://music.163.com/weapi/<路径>?csrf_token=<cookie 里 __csrf 的真值>,body 是
params=<密文>&encSecKey=<密文>,加密前的 JSON 里同样要带 csrf_token:
params = base64(AES-128-CBC(AES-128-CBC(JSON, '0CoJUm6Qyw8W8jud'), secret))
secret = 16 字符随机串
encSecKey= RSA(secret 字节反转 → 大整数) ** 0x10001 mod <固定 1024 位模数>,补足 256 个 hex
IV = '0102030405060708'
坑:aesEncrypt 输出是 base64,写成 hex 会得到「HTTP 200 + 空响应体」这种最难查的失败。
模数、常量都在 netease-api.mjs 顶部。
坑(写操作专用):csrf_token 必须是 cookie 里 __csrf 的真值。填空串时读操作(搜索/推荐/歌单/歌词)全都正常,
只有写操作被风控挡下:{"code":-460,"message":"检测到您的网络环境存在风险,请稍后再试"}。
所以「读都好好的」不能推出「cookie 拼对了」。
用到的端点:
| 路径 | 用途 | 备注 |
|---|---|---|
明文 POST /api/search/get/web |
搜歌 | 搜歌必须走明文:weapi 版 cloudsearch/get/web 对匿名请求回空体 |
weapi/cloudsearch/get/web |
搜歌手(100)/专辑(10) | 登录后可用(匿名回空体),索引比明文全 → 先走它、空再回退明文(searchFirst) |
weapi/v1/artist/<id> |
歌手热门 50 首 | 回 hotSongs;老接口 weapi/artist/top/song 给的是同一份榜单 |
weapi/artist/albums/<id> |
歌手的专辑列表 | 回 hotAlbums(含搜索索引里没有的老专辑);按时间倒序,limit 给 60 才够到经典专辑 |
weapi/v1/album/<id> |
专辑曲目 | 回 songs,专辑顺序 |
weapi/song/enhance/player/url/v1 |
播放地址 | level=standard/higher/exhigh/lossless/hires;VIP 无权限时 url=null |
weapi/v2/discovery/recommend/songs |
每日推荐 | 未登录报 code 301 |
weapi/v6/playlist/detail + weapi/v3/song/detail |
歌单 | trackCount 是总数;按 trackIds 截本页,补查内嵌 tracks 缺的详情;默认每页 500 首 |
weapi/v6/playlist/detail(specialType=5 歌单,n=0)+ weapi/v3/song/detail |
我喜欢的音乐 | trackIds[].at 是收藏时间戳,按它倒序 = 客户端顺序(最近收藏在前);song/like/get 的 ids 顺序与收藏时间无关,只用于单曲判定 |
weapi/radio/like |
喜欢/取消喜欢 | {alg:'itembased', trackId, like, time:3};被拒时回 -460(风控)/405(限流) |
weapi/playlist/manipulate/tracks |
喜欢/取消喜欢(风控时的回退) | {op:'add'|'del', pid:我喜欢的音乐id, trackIds:JSON字符串, imme:'true'};502 歌单内歌曲重复 = 已喜欢;删除不存在的歌也回 200 |
weapi/song/like/get |
liked-ids / 单曲判定 |
回的是 {ids:[…]} 全表;liked-ids 传 {trackIds:'[]'},一次调用就能判 |
weapi/radio/trash/add(API 的 fm/trash) |
私人 FM 不喜欢 | {songId:<id>,time:25,alg:'RT'};dislike [id] 提交,成功必须 code=200 |
weapi/song/lyric |
歌词 | lrc.lyric(LRC 格式) |
weapi/w/nuser/account/get |
登录态 | profile.userId 就是 uid |
weapi/user/playlist |
我的歌单 | 含specialType 标记的「我喜欢的音乐」 |
weapi/cloudsearch/get/web type=1000 |
搜歌单(别人的) | 同搜索:登录后可用,空则回退明文 |
weapi/cloudsearch/get/web type=1009 |
搜播客 | 回 djRadios(名字/id/期数) |
weapi/toplist |
排行榜列表 | 63 个榜,id 就是歌单 id → 直接喂 playlist/detail |
weapi/album/sublist |
我收藏的专辑 | data:{id,name,size,artists} |
weapi/artist/sublist |
我收藏的歌手 | data:{id,name,albumSize}(musicSize 常为 null,别用) |
weapi/radio/get |
私人 FM | 一次固定 3 首、忽略 limit → 循环凑数去重(cmdFm) |
weapi/v1/play/record |
最近播放 | {uid,type:1} → weekData 100 条 {playCount,score,song},已按最近排 |
weapi/djradio/get/subed |
我的播客订阅 | djRadios:{id,name,programCount,dj.nickname} |
weapi/djradio/recommend/v1 |
推荐播客 | --recommend 走它 |
weapi/dj/program/byradio |
播客的节目列表 | {radioId,limit,offset,asc:false} → programs[].mainSong.id 就是可播的 song id |
weapi/dj/program/detail |
单集详情 | 参数是 id(传 programId 回 400) |
weapi/login/qrcode/unikey / client/login |
扫码登录 | 轮询码:800 过期 / 801 待扫 / 802 待确认 / 803 成功(Set-Cookie 带 MUSIC_U) |
weapi/sms/captcha/sent / weapi/login/cellphone |
短信验证码登录(扫码不稳时的主力) | 两步:发码 {cellphone, ctcode:'86'} → 登录 {phone, countrycode:'86', captcha, rememberLogin:true} |
挑结果的规矩(pickBest):搜歌手/专辑时先看「完全同名(含别名)」,同名再挑曲目多的。
不挑就会拿到同名翻唱:搜「范特西」第一条是别人的 Type Beat。
网易搜索索引会缺正版(实测 2026-09):周杰伦《范特西》在明文和 weapi 两个搜索里都只有翻唱,搜不到正版 id。
走得通的路:albums 周杰伦 → 真 id 18915 → album 18915 拿到 10 首正版。但这 10 首全要 VIP,
url=null → 队列 0 首,命令按 error=notfound 退出(这是版权,不是故障)。
song/like/get 不认 limit:给 {uid, offset:0, limit:3} 也回全表,liked-ids 直接返回完整 ID 数组供喜欢状态判定。cmdLiked 按歌单的 trackIds[].at 倒序后,用 .slice(o.offset, o.offset + o.limit) 取本页,total 始终是完整 ID 数量。
内容侧命令的实测要点(2026-09):
- 收藏的歌单不用另找接口:
user/playlist一次给全(实测 36 张 = 自建 10 + 收藏别人的 26), 用subscribed === true区分。playlist/sublist那条老路径是 404,别再用。 - 榜单 = 特殊歌单:
toplist给{id,name,trackCount,updateFrequency},id直接当歌单 id 放,不用单独写取数。 - 私人 FM:
radio/get无视limit,固定 3 首;要 12 首就调 4 次再去重。 - 播客的节目:
byradio的mainSong.id与歌曲共用取地址接口(实测url有值、能放), 所以播客走的是和歌曲完全一样的队列构建路径,不用第二套播放逻辑。 - 失效路径(实测 404/400,别再试):
playlist/sublist、personal_fm、djradio/recommend(旧)、dj/program/recommend、dj/program/recommend/v2、djradio/hot。
登录态就是一个 MUSIC_U cookie,存 local/skills/netease-music/config.json(cookie 键,secret,不进共享仓库)。
cookie 失效时报 error=auth,重跑 login。
2.1 列表命令 / JSON 契约
下表列表命令的 scripts/netease-api.mjs <命令> [参数] --limit N --offset N 是只取数的原语,不建队、不播放。
--offset 是从 0 起的非负整数,默认 0;--limit 是本页最多取多少条。
先按原有顺序跳过 offset 条,再取最多 limit 条;查名字用的候选搜索始终从第一页找,不受内容页的 offset 影响。
翻过末尾时退出码 0,列表为 []、count 为 0,已有的 total 不清零;负数、非整数或缺 offset 值报 usage(退出码 2)。
所有下表命令的 JSON 都有数值型 count 和 total:count 只数本次实际返回的行(详情缺失或无可播主歌曲的条目不算)。
total 优先用表中的接口总数;接口没提供总数时,用它本次返回、还没本地截取/过滤的条数,不能把这个回退值当成完整云端库大小。
服务端分页接口若只在第一页给总数,后续页会按同一回退规则返回;total: 0 是有效值,不当成缺失。
| 命令(默认 limit) | 列表字段 / offset 怎么用 | total 的来源(从左到右回退) |
|---|---|---|
playlist(500)、chart(500) |
songs;按完整 trackIds 本地截页,复用 tracks 并补查缺的详情,保留歌单顺序;没有 trackIds 时截 tracks |
playlist.trackCount → 本次 detail 返回的 tracks.length |
liked(1000) |
songs;trackIds[].at 倒序后截页,再查详情 |
完整 trackIds.length(包括末尾空页) |
album(200) |
songs;整张专辑按原顺序本地截页 |
album.size → album.songCount → songs.length |
artist(50) |
songs;hotSongs 本地截页,老接口 artist/top/song 的 songs 作回退 |
热门列表截取前的长度;不是歌手全部作品数 musicSize |
search(20) |
songs;搜歌曲(type=1),向接口传 limit/offset |
result.songCount → 本次 songs.length |
playlists(20) |
playlists;搜歌单(type=1000),向 weapi 或明文回退接口传同一 limit/offset |
result.playlistCount → 本次 playlists.length |
charts(100) |
charts;toplist 全表本地截页 |
total → count → list.length |
myalbums(60)、myartists(60) |
albums / artists;向收藏接口传 limit/offset |
count → total → 本次 data.length |
recent(30) |
songs;最近一周记录本地截页,再过滤无 song 的记录 |
total → count → 原始记录条数 |
podcasts(50) |
podcasts;订阅和 --recommend 两个接口都忽略 offset,先取 offset + limit 条再本地截页 |
count → total → 本次 djRadios.length |
podcast(3) |
songs;dj/program/byradio 传 limit/offset,最新节目在前 |
count → total → 首个节目 radio.programCount → 本次 programs.length |
myplaylists(100) |
playlists;从头取再本地截页。实测 user/playlist 忽略 limit,非零 offset 还会多跳过置顶歌单,所以不用它的 offset |
total → count → 本次 playlist.length |
albums(60) |
albums;歌手专辑接口传 limit/offset |
artist.albumSize → total → 本次 hotAlbums.length |
daily(40) |
songs;每日推荐本地截页 |
本次推荐列表的完整长度 |
例如 500 首歌单翻过末尾,原语返回 {"id":123,"name":"示例","count":0,"total":500,"songs":[]};
不能再拿 count 当整张歌单的大小,也不能只凭 count < limit 判断没有后续 ID(本页可能有下架曲目)。
入口 pi-skill netease-music <命令> --limit N --offset N 把 offset 转给原语;列表的人读格式不变,
--json 摘要额外带 total(沿用入口的字符串字段格式,完整数组仍由原语输出)。
myplaylists collected/own 仍在本页内筛选,摘要 count 是筛后行数,total 是未筛选的我的歌单总数。
播放类入口仍会把本页入队;只读翻页用 myplaylists / search / charts 等列举命令,或直接调取数原语。
2.2 歌曲封面 / 下载契约
原语的单曲对象(song)和所有歌曲列表对象(songs[],含最近播放与播客的主歌曲)统一保留原有字段顺序和含义,追加 cover,再按可用性追加 liked:
| 字段 | 类型 / 含义 |
|---|---|
id |
歌曲 ID |
name |
歌名 |
artist |
歌手名字符串,多人以 / 连接 |
album |
专辑名字符串,没有则 "" |
ms |
时长(毫秒),没有则 0 |
vip |
布尔值,fee 为 1 或 4 时为 true |
cover |
封面 URL 字符串;依次取非空的 al.picUrl → album.picUrl → 歌曲顶层 picUrl,均缺失时为 "" |
liked(可选) |
接口歌曲对象的布尔 liked 原样透传(含 false);没有明确布尔值时省略。liked 命令返回的歌曲均为 true |
recent 的 plays、播客的 programId/program 等扩展字段仍保留;字段名统一为 cover。
原语列表不因缺封面失败,也不为补齐封面额外查询详情。
入口和原语都支持 cover <歌曲关键词|id> [--download PATH] [--force]:
- 关键词按
song的规则取第一首搜索结果;纯数字直接查歌曲详情。关键词搜索没给封面时,再按选中歌曲的 ID 补查详情。 - 不带
--download时,stdout 只输出一个 JSON 对象{ "id": 123, "name": "歌名", "artist": "歌手", "cover": "https://…" }。 - 带
--download PATH时,下载 HTTP(S) 图片,stdout 只输出{ "id": 123, "cover": "https://…", "file": "/绝对路径/cover.jpg", "bytes": 12345 }。file是 PATH 相对当前工作目录解析后的绝对路径;bytes是实际保存的字节数(数字)。不添加扩展名,不改图片编码,不自动创建父目录。 - 始终输出上述 JSON,
--json/--quiet不改变它。命令只查歌曲和封面,不播放、不改队列、不保存 cookie、不触发登录;下载请求也不携带登录 cookie。 - 最多 10 MB(10,000,000 字节,恰好上限允许)。先检查
Content-Length,再逐块累计实际响应字节数;没有或虚报长度也不能绕过上限。图片请求最多等待 30 秒;明确的非image/*Content-Type 会被拒绝。图片完整读入并通过检查后才写目标文件。 - PATH 已存在(包括符号链接)时默认报
usage,不会覆盖;仅显式--force允许覆盖。文件创建使用排他模式,因此并发创建也不会意外覆盖。--force必须与--download一起使用。 - 失败时 stdout 无结果,stderr 为标准
{ "error": "…", "hint": "…" }:缺歌曲/封面、图片 HTTP 404/410 或空图片为notfound(退出码 4);缺参数/路径或未授权覆盖为usage(2);超限、非图片响应、其他 HTTP/网络/文件写入失败为external(5);图片请求超时为timeout(5)。下载或大小检查失败时不会创建或截断目标文件。
2.3 喜欢 ID 与私人 FM 不喜欢
node scripts/netease-api.mjs liked-ids或入口liked-ids --json返回{count:3,total:3,ids:[1000,1002,1004]};ID 是数字,全表不分页,空表正常返回[]。入口人读输出仍为结论 +count/ids/source/as_of,ids是逗号分隔字符串。- 列表/搜索/详情没有
liked时表示未知;调用方用liked-ids结果按 ID 判定。普通取数不额外调用喜欢列表;查询失败不会伪造liked:false。喜欢接口返回301为auth(3),其他拒绝或缺少ids为external(5)。 - 入口
dislike [歌曲id]可省略 ID,通过 mpv 的实际曲目身份取当前歌;原语dislike <歌曲id>必须给一个正整数。它提交fm/trash对应的radio/trash/add,原语成功 JSON 为{id,disliked:true,code:200}。不自动跳歌、不删队列;需要跳歌可再用next。 - 无当前歌报
notfound(4);参数非法报usage(2);服务端未登录301报auth(3),其他非200报external(5),不会打印成功。接口没有对应的 ban 状态读回,本操作以返回码确认接受,不声称另行验证云端黑名单。
3. 播放器(mpv)
常驻方式(mpv-ctl.mjs 自动拉起):
mpv --idle=yes --no-video --force-window=no --audio-display=no --no-terminal \
--input-ipc-server=<local>/mpv.sock --volume=70
- 控制走 socket 上的 JSON IPC:一行一个
{"command":[...],"request_id":n},回包按request_id配对。 - 状态用
get_property:idle-active / pause / time-pos / duration / volume / playlist-pos / media-title。 - 队列交给 mpv 自己(
loadfile+loadlist),自动连播、playlist-next/prev、loop-playlist、shuffle都是原生能力, 不需要守护进程;status里的曲名来自 m3u 的#EXTINF。 - 音质与延迟:无损 flac 首播要下载几 MB,比 320k 慢一点点;网络卡就
setup --quality exhigh。 - 日志在
local/skills/netease-music/mpv.log(起不来先看它)。
三种循环模式:入口及 mpv-ctl 都接受 repeat off|list|single,on 和无参数兼容为 list。每次设置两个属性,清除上一模式:
| 模式 | loop-playlist |
loop-file |
|---|---|---|
off |
no |
no |
list / on |
inf |
no |
single |
no |
inf |
status 和控制后的状态回复追加 repeat_mode: off|list|single;底层 JSON 为字符串,入口人读输出为 repeat_mode=...。
既有 repeat 布尔字段保持「是否整列循环」的含义(入口 JSON 仍沿用字符串 "true"/"false");single 时这个旧字段为 false。状态读取两个属性,若外部设置两者都为 inf,模式优先报 single。非法模式在 IPC 前报 usage(2)。
批量播放:pi-skill netease-music play-ids <id,id,...> [--start N],例如 play-ids 1000,1001,1002 --start 2。
- ID 列表必须是一个参数,逗号分隔、不含空格或空项,均为正的安全整数;保留输入顺序和重复项。
--start默认 1,必须是输入列表范围内的 1 基序号。参数错误在请求/队列修改前报usage(2)。 - 按 ID 批量查详情(每批最多 200),再取播放地址。任何 ID 的详情缺失,或
--start指定歌曲无地址,报notfound(4)并保留旧队列;其他无地址歌曲跳过,原输入序号换算为过滤后的位置,skipped沿用原字段。 - 临时构建成功后自动保存上一条快照并替换队列,关闭随机播放;不支持
--shuffle。加载时先暂停,定位好再继续,避免先播第一首。 - 只取数原语
node scripts/netease-api.mjs play-ids <id,id,...> [--start N]返回{count,total,start,songs:[...]},start仍为输入列表的 1 基序号;它不连接 mpv,也不写队列。
编辑队列:remove <index> 按 queue 当前显示顺序使用 1 基序号,随机队列也如此。非当前项移除后继续原曲和进度;当前项移除时选下一首,原本在末尾则选上一首,只有一首时停止清空;保留暂停状态和循环模式。
入口根据 mpv 的实际条目重建 queue.json / queue.m3u8 / songs.json / urls.txt,在 IPC 成功后安装,避免重启已在播放的其他歌曲。非法序号报 usage(2)、越界报 notfound(4);条目无法映射时不修改队列。
clear 先停止,再清 mpv 列表,删除本地队列、地址及时间戳并收掉保活;配置与快照保留,空队列重复调用成功。移除/清空后回复仍回读 mpv 状态。
队列真值:get_property playlist 给整条队列(每项 title 来自 m3u 的 #EXTINF,duration 只探测正在放的那首,
其余是 0);playlist-pos 是当前第几首(0-based)。
但 shuffle 会重排 mpv 自己的播放顺序,playlist-pos 不再对应 queue.json 的序号(实测:status 说在放
《野心家》、实际在放《All Of Me/Say Something》)。所以「哪首 / 第几首」一律按身份认,实现在
scripts/lib/mpv-map.py:m3u 标题写成 [#<id>] 歌手 - 歌名(build-queue.py/queue-append.py 都按这个写),
旧队列回退按条目 filename 对 urls.txt(去 query 后比路径),再回退按「歌手 - 歌名」串。据此:
queue(入口命令)用 mpv 的 playlist 出真值,每条的时长/当前标记逐条按上面的身份规则配queue.json(配不上宁缺勿错);jump N(N 从 1 数,指queue列表里的第 N 首)先按歌找到它在 mpv 队列里的实际序号,再set_property playlist-pos; 队列没打乱时序号就是序号;打乱了又定位不到会明确报错,不静默跳错歌;status/like/lyric的「当前歌曲」同样按身份认(cur_song_json),不按playlist-pos索引。
队列现场(快照 / back / queue-load / add):一个 mpv 只有一条队列,任何「放 X」都是整列替换; 但内容在网易云那边,替换队列不等于丢内容。所以本技能的做法是:
build_queue是唯一的建队入口,它在新文件准备好、替换前自动把「当前这条」存成local/skills/netease-music/queues/prev.json(SKIP_SNAPSHOT=1可跳过——回放快照时必须跳,否则会把要载入的那条覆盖掉);- 快照只存歌曲列表(id/歌名/歌手/时长)+ 位置 + shuffle + 当时在放那首的
song_id,不存播放地址 (那种 URL 二十分钟就过期),回放时按列表重新取地址;开随机后位置没有意义,回放优先按song_id回到那首歌, 旧快照(没存 id)才退回位置; back的语义是交换:当前这条 → prev,prev → 当前,所以连着 back 两次就是「切回去再切回来」;queue-load <名字>载入命名快照,当前这条同样留成 prev;命名快照最多留 20 条(snapshot.py _prune);add走 mpv 的loadlist <临时 m3u> append(mpv-ctl append),同时把这一首追加进queue.json与queue.m3u8, 保证queue命令的两边首数一致;开着 shuffle 时新歌仍是排在队尾、按随机顺序播到。restore_snapshot里的play_queue输出要静音(>/dev/null),否则一条命令会打出两个结果块。
4. 踩过的坑
$var后面紧跟中文一律写${var}:bash 会把中文字节的第一个字节当成变量名的一部分,set -u下直接unbound variable。实测 15 处(如"开始播放:$now_name - $now_artist(队列 …")让play成功出声却返回退出码 1, 消费方(应用、脚本)都会以为播放失败。新增文案后自查:grep -nP '\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]' scripts/*.sh应当无输出。cycle pause在 mpv 里报 invalid parameter,得用set_property pause;toggle是先get_property再取反。- bash heredoc 与 herestring 抢 stdin:
python3 - <<'PY' <<<"$json"里 Python 读到的是 JSON 而不是脚本 (报name 'false' is not defined)。传 JSON 一律走临时文件或python3 -c。 qrencode -t UTF8能在终端画二维码;-o x.png出图片(open打开给用户扫)。- 官方客户端与本技能互不干扰(各放各的),别指望它们共享播放状态。
- 搜索接口返回的
fee:1/4表示要 VIP,url接口会给null,构建队列时会跳过并计入skipped。 - 队列文件在
local/skills/netease-music/:queue.json(曲目+id+服务端档位,status/like/lyric靠它反查)、queue.m3u8、urls.txt。 - 别无视返回码:网易云用 HTTP 200 +
{code:-460}/{code:405}表达「拒绝」。第一版实现没看 code, 喜欢失败还报「已加入」——写命令一律校验 code,有查询接口时在变更后回读真值再回报(cmd_like会回读like-state;FM ban 无对应查询,以接受返回码确认)。 - 连打十几次会触发 405「操作频繁」:
radio/like与song/like/get会整族限流几分钟(读接口不受影响)。 调参实验要隔开做;撞上就等冷却,别继续重试。 quality=报的是服务端实际给的档位(url接口返回的level),不是请求的档位:不登录(无 VIP)时请求lossless会拿到exhigh。登录后同一首才是lossless。play <纯数字>要回退:歌曲 id 与歌单 id 都是纯数字,形状上分不出来——先当歌曲查,查不到再走歌单流程。- 控制类命令(pause/next/volume/seek…)必须回读状态:只回「已执行」等于没验证(早期版本就是这样,
让人怀疑命令没生效)。现在统一走
emit_status打印 mpv 真值。 - 新登录的会话可能写不了「喜欢」:
radio/like会回-460 检测到您的网络环境存在风险(换 cookie、补_ntes_nuid、 加浏览器头、走明文/api/radio/like都不行——是会话/网络层面的风控,不是请求形状)。 回退到playlist/manipulate/tracks直接改「我喜欢的音乐」歌单是能通的(另一套风控),代码里已自动回退。 pid 取user/playlist里specialType === 5那张;502当幂等成功;读操作(搜索/推荐/歌单/歌词)全程不受风控影响。
坑:die 在 $( ) 里杀不掉父脚本。 症状很吓人:接口报 error=notfound 之后脚本继续跑,
拿错误文本当 JSON 喂给 python(一串 traceback),最后把旧队列重播一遍还报成功。
规矩:凡是捕获接口输出的地方都写 x="$(api_out …)" || exit $?
(api_out = 「能让整个脚本停下来的 api_or_die」)。例外只有 cmd_play 里那句带 2>/dev/null 的容错分支。
坑:空队列必须报错,不能报成功。 歌全要 VIP 时 build-queue.py 照样把 #EXTM3U 头写进 m3u(文件非空!),
所以判据是 queue.json 里 songs 的条数,不是 m3u 的大小。
坑:链接要先剥掉 id=。 不剥的话,play <专辑链接> 会把整条 URL 当关键词搜,搜到一首毫不相关的歌还播起来。
artist / album / albums / playlist 统一走 .replace(/.*[?&#/]id=/, '')。
代理:这个技能不依赖本机代理客户端。 入口第一件事就是 unset HTTP_PROXY/HTTPS_PROXY/ALL_PROXY 并设 NO_PROXY=*
(国内服务直连最快;sing-box 关着也照放)。验证用死代理,不用真代理:
HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 pi-skill netease-music play "晴天" 应当照常成功。
坑:播放直链是限时签名 URL,约 5 分钟就过期(长队列会「在放但没声音」)。 实测:同一个 id 现取的地址
200 能下 59 MB 完整文件,21:21 取的那条到 21:26 就 403(路径里的 14 位时间戳就是取用时刻,vuutv/authSecret
是绑「这首歌 + 这次生成 + 你的会话」的签名)。症状很误导人:status 说 state=playing、pause=false,
但 time-pos 取不到、audio-codec-name 是 None、core-idle=true,而且 mpv 在两个位置之间疯狂跳曲
(实测 4 秒里从第 162 首跳到 173 首)——看起来像"播放器坏了",其实是地址全 403。
所以不要在建队时预取整条长队列的地址就完事。现在的做法:
- 建队照旧预取(这样起播零等待),同时把取用时刻写进
local/skills/netease-music/urls_at; - 入口在
play_queue成功后排一个后台保活scripts/refresh-urls.sh(keepalive.pid单实例,stop时收掉); - 保活每 90 秒一轮,地址超过 4 分钟就把「当前这首之后还没播的」批量重取、原地替换 m3u 里对应的行 (mpv 是按需读下一首的,所以换 URL 不影响正在放的那首);
- 诊断一条命令:
mpv-ctl层看time-pos/core-idle,比看state靠谱得多。
本地调试请用 NETEASE_COOKIE。 入口读 PI_SKILL_NETEASE_MUSIC_COOKIE,传给 API 层的是 NETEASE_COOKIE;
直接调 node scripts/netease-api.mjs 时用错名字会得到 code=301 的假故障(看着像掉登录)。
坑:别拿 PY 当外层 heredoc 定界符。 补丁脚本里嵌 python heredoc 时,内容里那句 PY 会把外层提前掐断,
补丁只落一半。定界符取个不会撞的名字(如 PATCH_END)。
5. 想加功能往哪加
- 新的取数:在
netease-api.mjs的HANDLERS里加一个函数(榜单/收藏/FM/最近/播客都已接好), 入口脚本里加一条命令转发;播放侧不用动。 - 新的播放动作:mpv 原生就有的(倍速
speed、静音mute、跳歌单第 n 首playlist-pos)加在mpv-ctl.mjs的 switch 里。 - 加完跑
pi-skill skillcheck netease-music和skill-contract update netease-music --apply。
scripts/lib/ 五个小工具:build-queue.py(歌曲列表+地址 → queue.json/m3u8)、
snapshot.py(队列快照的存/列/取)、queue-append.py(追加一首并写临时 m3u)、
mpv-map.py(mpv 队列/当前曲 ↔ queue.json 的身份映射,shuffle 下也准)、queue-edit.py(按 mpv 实际顺序准备删除后的本地文件)。
离线门禁(工作目录为本技能目录):node --check scripts/netease-api.mjs、node --test tests/pagination.test.mjs。
测试沿用 tests/fixtures/api-fetch.mjs,并加载 mpv-ipc.mjs 替代 socket;禁止真实播放器进程,后台保活也被替代,所有临时文件均在 tests/ 下并在结束时清理。
加接口前先探:node scripts/netease-api.mjs raw <路径> '{…}'(内部探针,不在入口命令面里)——
路径以 api/ 开头走明文 POST,否则走 weapi;返回什么原样打出来,看清结构再写 handler。
加 mpv 动作:mpv-ctl.mjs 的 switch 里加一个 case,往 out 里塞要回读的值(结尾统一 JSON.stringify)。
两个都加完跑 skillcheck netease-music,再 skill-contract update netease-music --apply 刷新契约。
6. 对外契约(CLI 消费者)
本技能目录同时是一个可独立使用的 CLI:入口 scripts/netease-music.sh,standalone 安装用
scripts/install-cli.sh(链接成 netease-music 并查 node/mpv/python3),发布用共享的
skill-publish(声明在 interface.json 的 publish,见 skill-interface 的 REFERENCE §12)。命名规范:公开镜像仓叫 <工具>-cli(这里是
netease-music-cli),装出来的命令仍叫 <工具>(netease-music)——仓名标交付物类型,
命令名只管好敲。消费者(Emacs 包 etaf-ncm、其他脚本)只依赖本节与 --json 输出,
不读实现、不读状态文件。
- 版本:
netease-music version/--version(读VERSION)。破坏性改动抬高 minor(0.x 期间),并在本节记录。 - 调用:
netease-music <命令> [参数] [--json] [--quiet];完整命令面见netease-music help。 - JSON:
--json时 stdout 是单个对象。列表命令的字段与分页语义见 §2.1;状态与队列如下表。
| 命令 | 字段 |
|---|---|
status --json |
state(idle|playing|paused) title artist song_id elapsed duration volume queue_pos("i/n") shuffle repeat vip quality as_of |
queue --json |
count pos(1 基) songs[](歌曲对象同 §2.1) |
- 环境变量:
NETEASE_MUSIC_STATE_DIR(默认<PI_AGENT_DIR 或 ~/.pi/agent>/local/skills/netease-music)、NETEASE_MUSIC_COOKIE、NETEASE_MUSIC_QUALITY;旧名PI_SKILL_NETEASE_MUSIC_*仍兼容(新名优先)。 - 状态目录(引擎独占写入):
config.json(cookie/音质)、queue.json/queue.m3u8/songs.json(队列)、urls.txt/urls_at(限时直链缓存)、mpv.sock(mpv IPC)。消费者不要直接读这些文件, 队列与状态一律走queue --json/status --json。 - 退出码:0 成功、2 用法、3 依赖缺失或未登录、4 找不到目标、5 播放或队列操作失败;失败时 stderr 给
error=<码>/hint=<下一步>两行,消费者应原样透出 hint。