netease-music-cli/REFERENCE.md
2026-10-02 07:43:52 +08:00

32 KiB
Raw Blame History

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-release cli|package(声明在 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。