334 lines
32 KiB
Markdown
334 lines
32 KiB
Markdown
# 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),发布镜像用
|
||
`scripts/publish.sh`。**命名规范**:公开镜像仓叫 `<工具>-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。
|