release v0.1.0

This commit is contained in:
Kinneyzhang 2026-10-02 07:26:46 +08:00
commit 56f3e4ff2b
19 changed files with 4453 additions and 0 deletions

331
REFERENCE.md Normal file
View File

@ -0,0 +1,331 @@
# 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`。消费者(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。

86
SKILL.md Normal file
View File

@ -0,0 +1,86 @@
---
name: netease-music
description: 放网易云音乐并控制播放:直接调接口(搜歌点播、每日推荐、我喜欢的音乐、暂停切歌、音量、歌词、喜欢),不像以前那样模拟鼠标点界面。用户说「放首歌/放每日推荐/换一首/暂停/现在在放什么」时使用。
platforms: [darwin]
---
# 网易云音乐(netease-music)
**一句话**:`pi-skill netease-music daily` 直接开播每日推荐;`play <关键词>` 想听什么说什么;控制都是毫秒级命令,状态是真值。
## 何时用
| 用户说的 | 跑这个 |
|---|---|
| 放每日推荐 / 放点歌 | `pi-skill netease-music daily`(`--limit 20` 少放点,`--shuffle` 随机) |
| 放某首歌 / 某个歌手的歌 | `pi-skill netease-music play "<关键词>"` |
| 放某张歌单 | `pi-skill netease-music play "<歌单链接>"`(或 `play playlist:<id>`;id 用 `myplaylists` 查) |
| 放我喜欢的音乐 | `pi-skill netease-music liked` |
| 放某位歌手的热门 | `pi-skill netease-music artist "陈粒"`(`--limit 20` 少放点;也可给歌手 id) |
| 放某张专辑 | `pi-skill netease-music album "自传"`(名字、id、专辑链接都行) |
| 查某歌手有哪些专辑 | `pi-skill netease-music albums "周杰伦"`(只列不播;搜不到正版专辑时用它拿 id) |
| 放别人做的歌单 | `pi-skill netease-music playlist "夜跑"`(名字直接写;`playlists "夜跑"` 只搜不播) |
| 放排行榜 | `pi-skill netease-music chart 飙升榜`(`charts` 列全部 63 个) |
| 放我收藏的歌单 / 专辑 / 歌手 | `pi-skill netease-music myplaylists collected` / `myalbums` / `myartists` |
| 放点没听过的 | `pi-skill netease-music fm`(私人 FM) |
| 刚才那首再放一遍 | `pi-skill netease-music replay`(`recent` 看最近播放) |
| 放播客(订阅的) | `pi-skill netease-music podcasts` → `podcast 黑水公园 --limit 3` |
| 换队列但别丢现场 | 每次换队列会自动留「上一条」:`back` 切回(含位置);`queue-save 名字` / `queue-load 名字` 自己存取的;`queues` 看有哪些 |
| 往队列里加一首 | `pi-skill netease-music add "幻听"`(不动正在放的那首) |
| 把搜索结果整列播放 / 从某首开始 | `pi-skill netease-music play-ids 1000,1001,1002 --start 2`(单个逗号分隔的 ID 参数;按输入顺序,序号从 1 开始) |
| 看队列 / 跳到第几首 | `pi-skill netease-music queue` / `jump 5` |
| 删除队列第几首 / 清空 | `pi-skill netease-music remove 3` / `clear`(当前项删除后选下一首,末尾选上一首,删空停止;保留暂停状态) |
| 单曲循环 / 整列循环 / 关闭循环 | `pi-skill netease-music repeat single` / `repeat list` / `repeat off`(旧 `on` = `list`;状态字段 `repeat_mode`) |
| 换一首 / 回上一首 | `pi-skill netease-music next` / `prev` |
| 暂停 / 继续 | `pi-skill netease-music pause` / `resume`(或 `toggle`) |
| 声音大点 / 小点 | `pi-skill netease-music volume 45`(不带数字=读当前值) |
| 现在在放什么 | `pi-skill netease-music status` |
| 这首歌歌词 | `pi-skill netease-music lyric`(`--all` 全文) |
| 查歌曲封面 / 下载封面 | `pi-skill netease-music cover "<歌曲关键词或id>"`(始终输出 JSON;加 `--download PATH` 下载,覆盖需 `--force`,最多 10 MB = 10,000,000 字节;不改变队列或登录态) |
| 这首好听,收藏 | `pi-skill netease-music like`(`unlike` 取消) |
| 私人 FM 这首不喜欢 | `pi-skill netease-music dislike`(也可 `dislike 1000` 指定歌曲 ID;提交 `fm/trash`,不自动切歌) |
| 查询哪些歌曲已喜欢 | `pi-skill netease-music liked-ids --json`(`{count,total,ids:[数字,...]}` 全表;只读) |
| 只是查有哪些歌 | `pi-skill netease-music search "<关键词>"`(只列不播) |
| 取歌曲 JSON 给播放器 | `node scripts/netease-api.mjs song "<关键词或id>"`(歌曲字段依次为 `id/name/artist/album/ms/vip/cover`;没有封面时为 `""`;接口提供布尔 `liked` 时追加它) |
| 列下一页 | `pi-skill netease-music myplaylists --limit 3 --offset 3`(跳过前 3 条;`--json` 带 `total`,人读样式不变) |
**分页**:`playlist` / `liked` / `album` / `artist` / `chart` / `search` / `playlists` / `charts` / `myalbums` / `myartists` / `recent` / `podcasts` / `podcast`,以及 `myplaylists` / `albums` / `daily`,都支持 `--limit N --offset N`。`offset` 默认 0,从 0 起跳过 N 条;后端 JSON 的 `count` 是本页条数,`total` 是接口总数(没给总数时用接口返回、尚未本地截取的条数)。翻过末尾返回空列表,具体口径见 `REFERENCE.md`。
**封面**:`cover` 查询返回 `{id,name,artist,cover}`,下载返回 `{id,cover,file,bytes}`(`file` 为绝对路径,`bytes` 为实际字节数)。歌曲或封面缺失时报 `notfound` 错误 JSON;下载路径的父目录须已存在。字段来源与大小检查见 `REFERENCE.md` §2.2。
**批量与队列**:`play-ids <id,id,...> [--start N]` 接收一个无空格、逗号分隔的正整数 ID 参数,保留顺序和重复项;默认从输入列表第 1 首开始。`--start` 指输入列表的 1 基序号;指定歌曲无播放地址或任一歌曲详情缺失时报错并保留旧队列,其他无地址项跳过并计入 `skipped`。它关闭随机,不支持 `--shuffle`。`remove N` 按 `queue` 显示的顺序删除(含随机队列);`clear` 停止、清掉本地队列和地址保活,保留配置及快照。
**喜欢状态**:列表、搜索和详情的歌曲对象仅在接口提供布尔值时追加 `liked: true|false`;`liked` 歌曲列表全部为 `true`。缺字段表示未知,可用只读 `liked-ids --json`(或 `node scripts/netease-api.mjs liked-ids`)取得完整 ID 数组后按 ID 补齐;不自动为每首歌增加请求。`dislike [id]` 是私人 FM 的不喜欢,与 `unlike` 分开;只提交 API,播放位置和队列保持原样。
**循环状态**:`repeat off|list|single` 同时设置 mpv 的两个循环属性;`on` 和无参数继续表示 `list`。`status` 的 `repeat_mode` 报 `off/list/single`,原 `repeat` 布尔字段仍表示是否整列循环。
**首次使用**:`pi-skill netease-music check`(自检依赖与登录态)。没登录就登一次,两条路都行:
`login --sms <手机号>` 收验证码,再 `login --sms <手机号> --sms-code <验证码>`(主力,实测最稳);
或 `login` 出二维码用手机网易云 App 扫(30 秒)。cookie 只存本机。
**与音箱配合**:电脑的声音去哪台设备由系统输出决定,要让家里的 EDIFIER 音箱出声就先 `pi-skill edifier-speaker bt`(它会切音箱档位 + 把系统输出接过去)。
## 失败 → 动作
| 现象 | 动作 |
|---|---|
| `error=auth`(未登录) | `login --sms <手机号>` 两步短信登录;或 `login` 扫码;或 `setup --cookie 'MUSIC_U=…'` 粘浏览器里的 cookie |
| `error=notfound`(搜不到/歌单不存在) | 换个关键词;歌单 id 用 `myplaylists` 查 |
| `error=notfound ... 没有一首能播` | 这首/这张全要 VIP 或没版权:换一个(周杰伦正版目录基本都这样) |
| 歌手/专辑搜到的是翻唱 | 用 `albums <歌手>` 拿正版专辑 id,再 `album <id>`(网易搜索索引会缺正版) |
| `error=deps missing=mpv` | `brew install mpv`(播放器);`qrencode` 只在扫码登录时要 |
| `error=external ... url=null` | 那首要 VIP 或没版权,队列会自己跳过并在 `skipped=` 里报数量 |
| `error=timeout`(扫码超时) | 重新 `login`(二维码 2 分钟过期) |
| 队列有歌但没声音 | 系统输出没指向音箱:`pi-skill edifier-speaker bt`;或 `volume` 太小 |
| 播放卡在旧队列 | `stop` 清空队列再来 |
| `error=notfound 没有上一条可切回` | 还没换过队列;`queues` 看有哪些快照 |
| 换队列后想找回刚才那条 | `back`(回到上一条,含放到哪儿);更早的看 `queues`,或 `queue-load <名字>` |
| `add` 之后没马上听到 | 队列开着随机,新歌在队尾按随机顺序插进来;想顺序放先 `shuffle off` |
| 播客某一期放不出来 | 那期要付费或已下架;换几期:`podcast <播客> --limit 5` |
## 边界
- 只做「播放与控制」,不碰客户端界面;官方客户端开着不受影响,两边互不干扰(各放各的)。
- 音质默认无损(`quality`,改成 `exhigh` 可省流量)。
- 歌单每页默认最多取 500 首,每日推荐 40 首(`--limit` 可调);用 `--offset` 接着取下一页,500 不是歌单总量上限。歌手热门默认 50 首、专辑默认 200 首;后端用 `total` 报分页前数量。
- 能不能播由版权/VIP 决定:拉不到地址的会被跳过(`skipped=` 报数量),整张都拿不到就 `error=notfound`。

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

174
contract.lock.json Normal file
View File

@ -0,0 +1,174 @@
{
"version": 1,
"skill": "netease-music",
"tier": 1,
"platforms": [
"darwin"
],
"commands": {
"add": {
"destructive": false
},
"album": {
"destructive": false
},
"albums": {
"destructive": false
},
"artist": {
"destructive": false
},
"back": {
"destructive": false
},
"chart": {
"destructive": false
},
"charts": {
"destructive": false
},
"check": {
"destructive": false
},
"clear": {
"destructive": false
},
"cover": {
"destructive": false
},
"daily": {
"destructive": false
},
"dislike": {
"destructive": false
},
"episode": {
"destructive": false
},
"fm": {
"destructive": false
},
"jump": {
"destructive": false
},
"like": {
"destructive": false
},
"liked": {
"destructive": false
},
"liked-ids": {
"destructive": false
},
"login": {
"destructive": false
},
"lyric": {
"destructive": false
},
"myalbums": {
"destructive": false
},
"myartists": {
"destructive": false
},
"myplaylists": {
"destructive": false
},
"next": {
"destructive": false
},
"pause": {
"destructive": false
},
"play": {
"destructive": false
},
"play-ids": {
"destructive": false
},
"playlist": {
"destructive": false
},
"playlists": {
"destructive": false
},
"podcast": {
"destructive": false
},
"podcasts": {
"destructive": false
},
"prev": {
"destructive": false
},
"queue": {
"destructive": false
},
"queue-load": {
"destructive": false
},
"queue-save": {
"destructive": false
},
"queues": {
"destructive": false
},
"recent": {
"destructive": false
},
"remove": {
"destructive": false
},
"repeat": {
"destructive": false
},
"replay": {
"destructive": false
},
"resume": {
"destructive": false
},
"search": {
"destructive": false
},
"seek": {
"destructive": false
},
"setup": {
"destructive": false
},
"shuffle": {
"destructive": false
},
"status": {
"destructive": false
},
"stop": {
"destructive": false
},
"toggle": {
"destructive": false
},
"unlike": {
"destructive": false
},
"volume": {
"destructive": false
}
},
"errors": [
"auth",
"deps",
"external",
"internal",
"notfound",
"timeout",
"usage"
],
"aliases": [
"netease",
"netease-music"
],
"adminAliases": []
}

288
interface.json Normal file
View File

@ -0,0 +1,288 @@
{
"summary": "网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)",
"useWhen": "用户要放网易云的音乐(每日推荐、指定歌曲/歌手、我喜欢的音乐)、问现在在放什么,或要暂停/切歌/调音量/看歌词/取歌曲封面时",
"tier": 1,
"entry": {
"path": "scripts/netease-music.sh"
},
"aliases": [
"netease",
"netease-music"
],
"commands": [
{
"name": "daily",
"summary": "每日推荐分页入队播放(--limit N / --offset N / --shuffle;JSON 带 total)"
},
{
"name": "play",
"summary": "播一首或一整张:关键词→最匹配一首;歌单 id/链接→整张(--shuffle)"
},
{
"name": "play-ids",
"summary": "play-ids <id,id,...> [--start N]:按输入顺序整列替换,1 基起点默认 1;保留重复项,选中歌不可播则保留旧队列;关闭随机"
},
{
"name": "search",
"summary": "只搜不播:列出 id/歌名/歌手/时长/是否要 VIP(--limit N / --offset N;JSON 带 total)"
},
{
"name": "cover",
"summary": "cover <歌曲关键词|id> [--download PATH] [--force]:只读查询封面,JSON 为 id/name/artist/cover;下载为 id/cover/file/bytes,最多 10 MB(10000000 字节),覆盖需 --force;所有原语歌曲对象含字符串 cover(缺失为空)"
},
{
"name": "artist",
"summary": "歌手热门分页入队播放(名字或 id;--limit N / --offset N;JSON 带 total)"
},
{
"name": "album",
"summary": "专辑按顺序分页入队播放(名字、id 或专辑链接;--limit N / --offset N;JSON 带 total)"
},
{
"name": "albums",
"summary": "只列不播:歌手的专辑(含老专辑;--limit N / --offset N;JSON 带 total)"
},
{
"name": "liked",
"summary": "分页播放「我喜欢的音乐」(--limit N / --offset N;JSON 带 total)"
},
{
"name": "liked-ids",
"summary": "只读喜欢 ID 全表;--json 为 {count,total,ids:[数字,...]};歌曲 JSON 透传可用的布尔 liked,缺字段时用全表按 id 判定"
},
{
"name": "myplaylists",
"summary": "我的歌单(自建 + 收藏);--limit N / --offset N 分页,collected/own 筛本页;JSON 带 total"
},
{
"name": "pause",
"summary": "暂停"
},
{
"name": "resume",
"summary": "继续播放"
},
{
"name": "toggle",
"summary": "暂停/继续切换"
},
{
"name": "next",
"summary": "下一首"
},
{
"name": "prev",
"summary": "上一首"
},
{
"name": "stop",
"summary": "停止并清空队列"
},
{
"name": "clear",
"summary": "停止并清空 mpv 和本地队列,收掉保活;配置和快照保留,可重复调用"
},
{
"name": "volume",
"summary": "读或设音量(0-130)"
},
{
"name": "seek",
"summary": "跳进度(相对秒数,如 +30)"
},
{
"name": "queue",
"summary": "看队列(mpv 真值):每首歌名、时长,标出正在放的那首"
},
{
"name": "jump",
"summary": "跳到队列第 N 首(从 1 开始),并回读状态确认"
},
{
"name": "remove",
"summary": "remove <index>:按 queue 显示的 1 基序号删除;当前项选下一首,末尾选上一首,删空停止;同步本地文件"
},
{
"name": "status",
"summary": "当前曲目/进度/音量/队列位置(mpv 真值);repeat_mode 为 off/list/single"
},
{
"name": "lyric",
"summary": "当前歌曲歌词(默认只显示当前句与下一句,--all 全文)"
},
{
"name": "like",
"summary": "把当前这首歌加入「我喜欢的音乐」"
},
{
"name": "unlike",
"summary": "把当前这首歌移出「我喜欢的音乐」"
},
{
"name": "shuffle",
"summary": "队列随机开/关"
},
{
"name": "repeat",
"summary": "repeat off|list|single:关闭/整列/单曲循环;on 和无参数 = list;状态追加 repeat_mode,保留旧 repeat 布尔含义"
},
{
"name": "login",
"summary": "登录(--sms 手机号+验证码两步为主力;不带参数出二维码扫码),cookie 存本机 config"
},
{
"name": "setup",
"summary": "写配置(--cookie/--quality);不带参数打印登录办法"
},
{
"name": "check",
"summary": "只读自检:依赖、登录态、mpv 可用性、默认音质"
},
{
"name": "playlist",
"summary": "放一张歌单:名字/id/链接都行(--limit N / --offset N;JSON 带 total)"
},
{
"name": "playlists",
"summary": "搜歌单(只列不播):id / 名字 / 曲目数 / 作者 / 是否已收藏;--limit N / --offset N,JSON 带 total"
},
{
"name": "charts",
"summary": "列排行榜(只列不播):飙升榜 / 新歌榜 / …;--limit N / --offset N,JSON 带 total"
},
{
"name": "chart",
"summary": "放某个榜单:名字或 id(--limit N / --offset N;JSON 带 total)"
},
{
"name": "myalbums",
"summary": "我收藏的专辑(只列不播;--limit N / --offset N;JSON 带 total;id 可喂 album)"
},
{
"name": "myartists",
"summary": "我收藏的歌手(只列不播;--limit N / --offset N;JSON 带 total;id 可喂 artist)"
},
{
"name": "fm",
"summary": "私人 FM:没听过的歌,整列入队开播(--limit N)"
},
{
"name": "dislike",
"summary": "dislike [歌曲id]:私人 FM 不喜欢(fm/trash);省略 id 用当前歌曲,只上报,不自动切歌或改队列"
},
{
"name": "recent",
"summary": "最近播放(只列不播,一周内,含播放次数;--limit N / --offset N;JSON 带 total)"
},
{
"name": "replay",
"summary": "再放一次最近第 N 首(默认 1 = 最近那首)"
},
{
"name": "podcasts",
"summary": "我的播客订阅(只列不播;--recommend 看推荐的;--limit N / --offset N;JSON 带 total)"
},
{
"name": "podcast",
"summary": "放某播客的节目(--limit N 默认 3;--offset N;名字或 id;JSON 带 total)"
},
{
"name": "episode",
"summary": "放某一期节目(节目 id 从 podcast 输出里抄)"
},
{
"name": "queues",
"summary": "看本地队列快照(每次换队列会自动留一条「上一条」)"
},
{
"name": "queue-save",
"summary": "把当前队列存成命名快照(只存歌曲列表,地址回放时重取)"
},
{
"name": "queue-load",
"summary": "载入某个命名快照(含当时位置与随机状态)"
},
{
"name": "back",
"summary": "切回上一条队列(含位置);再 back 一次切回来"
},
{
"name": "add",
"summary": "把一首加到当前队列尾部,不动正在放的那首"
}
],
"errors": [
"usage",
"deps",
"auth",
"notfound",
"timeout",
"external",
"internal"
],
"requires": [
{
"kind": "runtime",
"name": "node",
"check": "which node",
"note": "接口客户端与 mpv 控制都是 node 脚本,零 npm 依赖"
},
{
"kind": "runtime",
"name": "python3",
"check": "which python3"
},
{
"kind": "binary",
"name": "mpv",
"install": "brew install mpv",
"check": "which mpv",
"note": "真正的播放器(常驻 + unix socket 控制)"
},
{
"kind": "binary",
"name": "qrencode",
"install": "brew install qrencode",
"check": "which qrencode",
"note": "只在扫码登录时用"
}
],
"config": [
{
"key": "cookie",
"desc": "登录 cookie(MUSIC_U=…),由 login 命令扫码写入",
"type": "string",
"secret": true,
"required": true,
"ask": "扫码登录一次:pi-skill netease-music login",
"note": "只存本机 local/skills/netease-music/config.json,不进共享仓库"
},
{
"key": "quality",
"desc": "音质级别:standard/higher/exhigh/lossless/hires",
"type": "enum",
"values": [
"standard",
"higher",
"exhigh",
"lossless",
"hires"
],
"default": "lossless"
}
],
"helpListsCommands": true,
"probeUnknownCommand": true,
"admin": [
{
"path": "scripts/install-cli.sh",
"runner": "sh",
"summary": "standalone 安装:不需要 Pi,把入口链接成 netease-music 并检查 node/mpv/python3"
},
{
"path": "scripts/publish.sh",
"runner": "sh",
"summary": "发布公开镜像:测试门禁通过后把技能目录整体覆盖到 gitea 镜像仓(生成物,不在镜像仓手改)"
}
]
}

28
scripts/install-cli.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/sh
# standalone 安装:把本技能目录当纯 CLI 用(不需要 Pi)。
# 用法:scripts/install-cli.sh [--prefix DIR];默认 ~/.local。
set -eu
here="$(cd "$(dirname "$0")/.." && pwd)"
prefix="$HOME/.local"
while [ $# -gt 0 ]; do
case "$1" in
--prefix) prefix="$2"; shift 2 ;;
-h|--help) printf '用法:%s [--prefix DIR](默认 ~/.local;链接 <prefix>/bin/netease-music)\n' "$0"; exit 0 ;;
*) printf '未知参数:%s\n' "$1" >&2; exit 2 ;;
esac
done
bindir="$prefix/bin"
mkdir -p "$bindir"
ln -sfn "$here/scripts/netease-music.sh" "$bindir/netease-music"
printf 'linked: %s/netease-music -> %s/scripts/netease-music.sh\n' "$bindir" "$here"
missing=""
for tool in node mpv python3; do
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
done
[ -z "$missing" ] || printf '缺少依赖:%s(macOS: brew install node mpv python3)\n' "$missing"
case ":$PATH:" in
*":$bindir:"*) ;;
*) printf '提示:把 %s 加进 PATH,例如 export PATH="%s:$PATH"\n' "$bindir" "$bindir" ;;
esac
printf '下一步:netease-music check(自检);未登录再跑 netease-music login(手机扫码)\n'
printf '状态目录默认 ~/.pi/agent/local/skills/netease-music;不用 Pi 时建议 export NETEASE_MUSIC_STATE_DIR="$HOME/.local/state/netease-music"\n'

View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""build-queue — 把歌曲列表 + 播放地址合成 mpv 队列(内部原语,被 netease-music.sh 调用)
用法: build-queue.py <songs.json> <urls.txt> <queue.json> <queue.m3u8> [start,从 1 开始]
songs.json: [{"id","name","artist","ms"}, ...]
urls.txt: 每行 "<id>\t<档位>\t<播放地址>"(取不到地址的歌不写进来)
输出: "picked skipped" 一行;queue.json 按 m3u 顺序存曲目,供 status/like/lyric 反查。
**为什么 m3u 里写的是 URL 而不是 id**:网易云的直链是限时签名 URL,实测取出来约 **5 分钟**就 403。
所以长队列不能靠预取地址(放到后半段全部失效,听感是「歌还在放但没声音」)。
这里的做法是:m3u 里写的仍是取地址那一刻的 URL(保证刚建队就能立刻出声、零等待),
同时 queue.json 记下这批地址的取用时刻;播到过期地址时由入口的 keepalive 重取(见 netease-music.sh)。
"""
import json
import sys
import time
def main():
songs_path, urls_path, queue_path, m3u_path = sys.argv[1:5]
songs = json.load(open(songs_path, encoding="utf-8"))
urls = {}
levels = {}
for line in open(urls_path, encoding="utf-8"):
line = line.strip()
if not line:
continue
parts = line.split("\t")
sid, level, url = (parts[0], parts[1], parts[2]) if len(parts) == 3 else (parts[0], "", parts[-1])
if url:
urls[sid] = url
levels[sid] = level
picked = [s for s in songs if str(s["id"]) in urls]
start = None
if len(sys.argv) > 5:
requested = int(sys.argv[5]) - 1
if not 0 <= requested < len(songs) or str(songs[requested]["id"]) not in urls:
print('error=notfound\nhint=--start 指定的歌曲不可播,队列未替换', file=sys.stderr)
return 4
start = 1 + sum(str(s["id"]) in urls for s in songs[:requested])
for s in picked:
s["level"] = levels.get(str(s["id"]), "") # 服务端实际给的档位(VIP 歌可能被降档)
with open(queue_path, "w", encoding="utf-8") as f:
json.dump(
{
"songs": picked,
"skipped": len(songs) - len(picked),
"as_of": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
**({"start": start} if start is not None else {}),
},
f,
ensure_ascii=False,
indent=1,
)
with open(m3u_path, "w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for s in picked:
# 标题前缀 [#id]:mpv 开了 shuffle 后顺序和 queue.json 不一致,
# 靠它把「mpv 在放/队列第几项」映射回歌(见 scripts/lib/mpv-map.py)。
f.write("#EXTINF:%d,[#%s] %s - %s\n%s\n" % ((s.get("ms") or 0) // 1000, s["id"], s["artist"], s["name"], urls[str(s["id"])]))
print(len(picked), len(songs) - len(picked))
return 0
if __name__ == "__main__":
if len(sys.argv) < 5 or sys.argv[1] in ("-h", "--help"):
print(__doc__)
sys.exit(0 if len(sys.argv) > 1 else 2)
sys.exit(main())

145
scripts/lib/mpv-map.py Normal file
View File

@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""mpv-map — 把 mpv 的「当前在放哪首 / 队列第几项」映射回 queue.json 里的歌。
为什么需要它:mpv 开着 shuffle 时播放顺序是它自己排的,`playlist-pos`
指的是那条打乱过的顺序,直接拿去索引 queue.json 会报错歌(实测:
status 说在放《野心家》,实际在放《All Of Me/Say Something》)。
每首歌在队列里有两种稳定身份,建队时都会写进去:
1. m3u8 的 #EXTINF 标题写成 "[#<id>] <歌手> - <歌名>",mpv 的 media-title / 条目 title 原样带回;
2. 条目 filename 是取地址那一刻的 URL,可对照 urls.txt(<id>\\t<档位>\\t<url>,按去 query 的路径比)。
旧格式标题(没有 [#id])依次用 URL、标题串「歌手 - 歌名」回退;都对不上退出 3。
用法:
mpv-map.py now <queue.json> <urls.txt> <status.json> # → 当前歌曲 JSON(mpv status 的输出)
mpv-map.py index <queue.json> <urls.txt> <title> <path> # → 当前歌在 queue.json 里的 0 基序号
mpv-map.py find <queue.json> <urls.txt> <song_id> <playlist.json> # → 该歌在 mpv 队列里的 1 基序号
"""
import json
import re
import sys
ID_RE = re.compile(r'^\[#(\d+)\]\s*')
def load_json(path):
with open(path, encoding='utf-8') as f:
return json.load(f)
def load_urls(path):
m = {}
try:
f = open(path, encoding='utf-8')
except OSError:
return m
with f:
for line in f:
parts = line.rstrip('\n').split('\t')
if len(parts) >= 2 and parts[-1]:
m[parts[0]] = parts[-1]
return m
def strip_query(url):
return (url or '').split('?')[0]
def id_of_title(title):
m = ID_RE.match(title or '')
return m.group(1) if m else ''
def id_of_url(url, urls):
if not url:
return ''
u = strip_query(url)
for sid, u2 in urls.items():
if strip_query(u2) == u:
return sid
return ''
def display_title(title):
return ID_RE.sub('', title or '')
def resolve(songs, urls, title, path):
"""按标题里的 [#id] → URL → 「歌手 - 歌名」的顺序找回 queue.json 里的歌。"""
sid = id_of_title(title)
if not sid:
sid = id_of_url(path, urls)
if sid:
for s in songs:
if str(s.get('id')) == str(sid):
return s
t = (title or '').strip()
if t:
for s in songs:
if t == '%s - %s' % (s.get('artist') or '', s.get('name') or ''):
return s
return None
def cmd_now(queue_path, urls_path, status_path):
st = load_json(status_path)
songs = (load_json(queue_path).get('songs') or [])
s = resolve(songs, load_urls(urls_path), st.get('title'), st.get('path'))
if not s:
return 3
print(json.dumps(s, ensure_ascii=False))
return 0
def cmd_index(queue_path, urls_path, title, path):
songs = (load_json(queue_path).get('songs') or [])
s = resolve(songs, load_urls(urls_path), title, path)
if not s:
return 3
print(songs.index(s))
return 0
def cmd_find(queue_path, urls_path, song_id, playlist_path):
songs = (load_json(queue_path).get('songs') or [])
urls = load_urls(urls_path)
entries = load_json(playlist_path).get('entries') or []
target = str(song_id)
for e in entries:
sid = id_of_title(e.get('title')) or id_of_url(e.get('filename'), urls)
if sid and str(sid) == target:
print(e.get('index'))
return 0
# 标题是旧格式时,再用「歌手 - 歌名」对一次
for s in songs:
if str(s.get('id')) != target:
continue
want = '%s - %s' % (s.get('artist') or '', s.get('name') or '')
for e in entries:
if (e.get('title') or '').strip() == want:
print(e.get('index'))
return 0
return 3
def main():
if len(sys.argv) < 3:
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 2
cmd = sys.argv[1]
args = sys.argv[2:]
try:
if cmd == 'now' and len(args) == 3:
return cmd_now(*args)
if cmd == 'index' and len(args) == 4:
return cmd_index(*args)
if cmd == 'find' and len(args) == 4:
return cmd_find(*args)
except (OSError, ValueError, KeyError):
return 3
print('用法见脚本 docstring', file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""把一首歌追加进队列文件,并写出给 mpv 用的「只有这一首」的临时 m3u。
参数:queue.json queue.m3u8 tmp.m3u id 歌名 歌手 毫秒 url
输出(JSON):{"ok": true, "count": N, "name", "artist", "id"} 或 {"ok": false, "reason": "duplicate", "count": N}
"""
import json
import sys
def main():
if len(sys.argv) < 9:
print('用法:queue-append.py <queue.json> <queue.m3u8> <tmp.m3u> <id> <歌名> <歌手> <毫秒> <url>', file=sys.stderr)
return 2
qpath, mpath, tpath, sid, name, artist, ms, url = sys.argv[1:9]
q = json.load(open(qpath, encoding='utf-8'))
songs = q.get('songs') or []
new = {'id': int(sid), 'name': name, 'artist': artist, 'ms': int(ms or 0)}
if any(s.get('id') == new['id'] for s in songs):
print(json.dumps({'ok': False, 'reason': 'duplicate', 'count': len(songs)}, ensure_ascii=False))
return 0
songs.append(new)
q['songs'] = songs
q['count'] = len(songs)
with open(qpath, 'w', encoding='utf-8') as f:
json.dump(q, f, ensure_ascii=False)
# 标题前缀 [#id] 与 build-queue.py 一致,供 mpv-map.py 映射回歌曲
entry = "#EXTINF:%d,[#%s] %s - %s\n%s\n" % ((new['ms'] or 0) // 1000, new['id'], artist, name, url)
with open(mpath, 'a', encoding='utf-8') as f:
f.write(entry)
with open(tpath, 'w', encoding='utf-8') as f:
f.write('#EXTM3U\n' + entry)
print(json.dumps({'ok': True, 'count': len(songs), 'name': name, 'artist': artist, 'id': new['id']},
ensure_ascii=False))
return 0
if __name__ == '__main__':
sys.exit(main())

55
scripts/lib/queue-edit.py Normal file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""准备 remove 的本地队列文件;按 mpv 的实际顺序映射,IPC 成功后才由入口安装。
用法:queue-edit.py <local-dir> <playlist.json> <1-based index> <stage-dir>
"""
import importlib.util
import json
from pathlib import Path
import sys
import time
spec = importlib.util.spec_from_file_location('mpv_map', Path(__file__).with_name('mpv-map.py'))
mpv_map = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mpv_map)
def main():
local, playlist, index, stage = sys.argv[1:]
local, stage = Path(local), Path(stage)
entries = json.loads(Path(playlist).read_text())['entries']
index = int(index) - 1
if not 0 <= index < len(entries):
print('error=notfound\nhint=队列里没有这个序号', file=sys.stderr)
return 4
queue = json.loads((local / 'queue.json').read_text())
urls = mpv_map.load_urls(local / 'urls.txt')
songs, lines, url_lines = [], ['#EXTM3U'], []
for i, entry in enumerate(entries):
if i == index:
continue
song = mpv_map.resolve(queue['songs'], urls, entry['title'], entry['filename'])
if not song or not entry['filename']:
print('error=notfound\nhint=mpv 条目无法映射到本地歌曲,队列未修改', file=sys.stderr)
return 4
songs.append(song)
lines.extend([
'#EXTINF:%d,[#%s] %s - %s' % ((song.get('ms') or 0) // 1000, song['id'], song['artist'], song['name']),
entry['filename'],
])
url_lines.append('%s\t%s\t%s' % (song['id'], song.get('level', ''), entry['filename']))
queue.update(songs=songs, count=len(songs), as_of=time.strftime('%Y-%m-%dT%H:%M:%S%z'))
queue.pop('start', None)
(stage / 'queue.json').write_text(json.dumps(queue, ensure_ascii=False), encoding='utf-8')
(stage / 'songs.json').write_text(json.dumps(songs, ensure_ascii=False), encoding='utf-8')
(stage / 'queue.m3u8').write_text('\n'.join(lines) + '\n', encoding='utf-8')
(stage / 'urls.txt').write_text('\n'.join(url_lines) + ('\n' if url_lines else ''), encoding='utf-8')
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, ValueError, KeyError) as error:
print('error=external\nhint=无法编辑本地队列:%s' % error, file=sys.stderr)
sys.exit(5)

129
scripts/lib/snapshot.py Normal file
View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""队列快照:把「当前队列」存成可回放的文件。
只存歌曲列表(id/歌名/歌手/时长),不存播放地址 —— 那种 URL 二十分钟就过期。
回放时按列表重新取地址,所以快照可以长期留着。
用法:
snapshot.py save <queue.json> <快照文件> <位置> <shuffle:true|false> [名字]
snapshot.py list <快照目录> 列出全部快照(JSON 数组)
snapshot.py songs <快照文件> 只打印歌曲列表(喂给 build_queue)
snapshot.py meta <快照文件> 打印元信息(名字/首数/位置/shuffle/来源/时间)
"""
import datetime
import json
import os
import sys
KEEP_NAMED = 20 # 命名快照最多留 20 条,多的按时间删最旧(prev.json 不受影响)
def _now():
return datetime.datetime.now().astimezone().isoformat(timespec='seconds')
def cmd_save(queue_path, out_path, pos, shuffle, name='', song_id=''):
try:
q = json.load(open(queue_path, encoding='utf-8'))
except Exception:
return 2
songs = q.get('songs') or []
if not songs:
return 2
out = {
'name': name or q.get('source') or 'unnamed',
'saved_at': _now(),
'source': q.get('source') or '',
'pos': int(pos or -1),
'shuffle': str(shuffle).lower() == 'true',
# 开随机后 pos 是打乱顺序里的位置,回放靠 song_id 认「当时在放的那首」
'song_id': int(song_id) if str(song_id).strip().isdigit() else None,
'count': len(songs),
'songs': songs,
}
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(out, f, ensure_ascii=False)
_prune(os.path.dirname(out_path))
print('%s\t%d\t%s' % (out['name'], out['count'], out['saved_at']))
return 0
def _prune(d):
named = []
for fn in os.listdir(d):
if not fn.endswith('.json') or fn.startswith('.') or fn == 'prev.json':
continue
try:
meta = json.load(open(os.path.join(d, fn), encoding='utf-8'))
except Exception:
continue
named.append((meta.get('saved_at') or '', fn))
named.sort()
for _, fn in named[:-KEEP_NAMED] if len(named) > KEEP_NAMED else []:
try:
os.remove(os.path.join(d, fn))
except OSError:
pass
def cmd_list(d):
rows = []
for fn in sorted(os.listdir(d)) if os.path.isdir(d) else []:
if not fn.endswith('.json') or fn.startswith('.'):
continue
try:
meta = json.load(open(os.path.join(d, fn), encoding='utf-8'))
except Exception:
continue
rows.append({
'file': fn,
'name': meta.get('name') or fn[:-5],
'count': meta.get('count') or len(meta.get('songs') or []),
'saved_at': meta.get('saved_at') or '',
'source': meta.get('source') or '',
'pos': meta.get('pos', -1),
})
rows.sort(key=lambda r: (r['file'] != 'prev.json', r['saved_at']), reverse=False)
json.dump(rows, sys.stdout, ensure_ascii=False)
return 0
def cmd_songs(p):
try:
d = json.load(open(p, encoding='utf-8'))
except Exception:
return 4
json.dump(d.get('songs') or [], sys.stdout, ensure_ascii=False)
return 0
def cmd_meta(p):
try:
d = json.load(open(p, encoding='utf-8'))
except Exception:
return 4
json.dump({k: d.get(k) for k in ('name', 'count', 'pos', 'shuffle', 'song_id', 'source', 'saved_at')},
sys.stdout, ensure_ascii=False)
return 0
def main():
if len(sys.argv) < 3:
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 2
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'save':
return cmd_save(*(args + ['', '', '', '', ''])[:6])
if cmd == 'list':
return cmd_list(args[0])
if cmd == 'songs':
return cmd_songs(args[0])
if cmd == 'meta':
return cmd_meta(args[0])
print('未知子命令:%s' % cmd, file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())

309
scripts/mpv-ctl.mjs Normal file
View File

@ -0,0 +1,309 @@
#!/usr/bin/env node
// mpv-ctl — 后台 mpv 的生命周期与 IPC 控制(网易云播放的「播放器层」)
//
// 为什么这样:mpv 常驻(--idle=yes)+ unix socket 上跑 JSON IPC,
// 所以播放/暂停/切歌/音量都是毫秒级命令,状态(时间、进度、曲名)也是真值,
// 不再靠截屏看进度条猜。mpv 自己会接着放歌单,不需要守护进程。
//
// 输出:JSON(内部原语);退出码 0 成功 / 2 用法 / 3 缺依赖 / 5 执行失败
// 用法:
// mpv-ctl play <m3u|url> [--title T] [--index N] 建队列并开播
// mpv-ctl status 当前状态(真值)
// mpv-ctl pause|resume|toggle|next|prev|stop
// mpv-ctl volume [0-130] | seek <秒|+n|-n> | shuffle on|off | repeat off|list|single (on=list)
// mpv-ctl append <m3u|url> | remove <1-based index> | clear
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
const DIR = process.env.NETEASE_MPV_DIR || process.env.NETEASE_MUSIC_STATE_DIR
|| path.join(process.env.PI_AGENT_DIR || path.join(os.homedir(), '.pi/agent'), 'local', 'skills', 'netease-music');
const SOCK = path.join(DIR, 'mpv.sock');
const LOG = path.join(DIR, 'mpv.log');
fs.mkdirSync(DIR, { recursive: true });
const die = (code, hint, rc) => {
console.error(JSON.stringify({ error: code, hint }));
process.exit(rc);
};
function connect(timeoutMs = 1500) {
return new Promise((resolve) => {
const s = net.connect(SOCK);
const t = setTimeout(() => {
s.destroy();
resolve(null);
}, timeoutMs);
s.once('connect', () => {
clearTimeout(t);
resolve(s);
});
s.once('error', () => {
clearTimeout(t);
s.destroy();
resolve(null);
});
});
}
function makeClient(sock) {
let buf = '';
let id = 0;
const pending = new Map();
sock.on('data', (d) => {
buf += d.toString();
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.request_id && pending.has(msg.request_id)) {
const { resolve } = pending.get(msg.request_id);
pending.delete(msg.request_id);
resolve(msg.error === 'success' || msg.error === undefined ? msg.data : { __error: msg.error });
}
}
});
const send = (command, timeoutMs = 4000) =>
new Promise((resolve) => {
const rid = ++id;
const t = setTimeout(() => {
pending.delete(rid);
resolve({ __error: 'timeout' });
}, timeoutMs);
pending.set(rid, {
resolve: (v) => {
clearTimeout(t);
resolve(v);
},
});
sock.write(JSON.stringify({ command, request_id: rid }) + '\n');
});
return { send, close: () => sock.end() };
}
async function ensureMpv() {
let sock = await connect(300);
if (sock) return makeClient(sock);
const out = fs.openSync(LOG, 'a');
const child = spawn(
'mpv',
['--idle=yes', '--no-video', '--force-window=no', '--audio-display=no', '--no-terminal', `--input-ipc-server=${SOCK}`, '--volume=70'],
{ detached: true, stdio: ['ignore', out, out] },
);
child.unref();
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
sock = await connect(300);
if (sock) return makeClient(sock);
}
die('deps', `mpv 没起来(日志 ${LOG}):确认 brew install mpv 成功`, 3);
}
const argv = process.argv.slice(2);
const CMD = argv[0];
const opts = {};
const pos = [];
for (let i = 1; i < argv.length; i++) {
if (argv[i] === '--title') opts.title = argv[++i];
else if (argv[i] === '--index') opts.index = +argv[++i];
else pos.push(argv[i]);
}
const GOOD = {
pause: ['set_property', 'pause', true],
resume: ['set_property', 'pause', false],
};
async function main() {
const repeatMode = (pos[0] || 'on') === 'on' ? 'list' : pos[0];
if (CMD === 'repeat' && (pos.length > 1 || !['off', 'list', 'single'].includes(repeatMode))) {
die('usage', '用法:repeat off|list|single(on = list)', 2);
}
if (CMD === 'remove' && (pos.length !== 1 || !/^\d+$/.test(pos[0]) || !Number.isSafeInteger(+pos[0]) || +pos[0] < 1)) {
die('usage', '用法:remove <队列序号,从 1 开始>', 2);
}
if (CMD === 'clear' && pos.length) die('usage', '用法:clear', 2);
if (CMD === 'play' && opts.index !== undefined && (!Number.isSafeInteger(opts.index) || opts.index < 0)) {
die('usage', '--index 需要从 0 开始的序号', 2);
}
const c = await ensureMpv();
const get = (p) => c.send(['get_property', p]);
const checked = async (command) => {
const result = await c.send(command);
if (result && result.__error) die('external', `mpv 报错:${result.__error}`, 5);
return result;
};
let out = { command: CMD };
switch (CMD) {
case 'play': {
const target = pos[0];
if (!target) die('usage', '用法:mpv-ctl play <m3u|url> [--title T] [--index N]', 2);
const isUrl = /^https?:/.test(target);
const isList = /\.(m3u8?|pls)$/i.test(target);
const cmd = isUrl || !isList ? ['loadfile', target, 'replace'] : ['loadlist', target, 'replace'];
if (isUrl && opts.title) cmd.splice(2, 0, `--force-media-title=${opts.title}`);
// Pause first: a load starts the first entry immediately, so an indexed
// start (or any later adjustment) would leak a fraction of the wrong
// song as a click. Silence the swap, position, then resume.
await checked(['set_property', 'pause', true]);
const r = await c.send(cmd);
if (r && r.__error) die('external', `mpv 拒绝播放:${r.__error}`, 5);
if (opts.index !== undefined) await checked(['set_property', 'playlist-pos', opts.index]);
await checked(['set_property', 'pause', false]);
out.playing = true;
break;
}
case 'append': {
const target = pos[0];
if (!target) die('usage', '用法:mpv-ctl append <m3u|url>', 2);
const isUrl = /^https?:/.test(target);
const r = await c.send(isUrl ? ['loadfile', target, 'append'] : ['loadlist', target, 'append']);
if (r && r.__error) die('external', `mpv 拒绝追加:${r.__error}`, 5);
out.count = await get('playlist-count');
break;
}
case 'pause':
case 'resume':
case 'toggle': {
let cmd = GOOD[CMD];
if (CMD === 'toggle') cmd = ['set_property', 'pause', !(await c.send(['get_property', 'pause']))];
const r = await c.send(cmd);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
out.paused = await get('pause');
break;
}
case 'next':
case 'prev': {
const r = await c.send([CMD === 'next' ? 'playlist-next' : 'playlist-prev', 'force']);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
break;
}
case 'stop':
case 'clear': {
await checked(['stop']);
await checked(['playlist-clear']);
out.stopped = true;
break;
}
case 'remove': {
const index = +pos[0] - 1;
const [count, current, paused] = await Promise.all([get('playlist-count'), get('playlist-pos'), get('pause')]);
if (!Number.isInteger(count) || index >= count) die('notfound', `队列里没有第 ${pos[0]} 首`, 4);
if (index === current) {
if (count === 1) {
await checked(['stop']);
await checked(['playlist-clear']);
} else {
// 先选下一首,末尾则选上一首;不受循环模式影响,并保留暂停状态。
await checked(['set_property', 'playlist-pos', index + 1 < count ? index + 1 : index - 1]);
await checked(['playlist-remove', index]);
await checked(['set_property', 'pause', paused === true]);
}
} else await checked(['playlist-remove', index]);
out.removed = index + 1;
out.count = await get('playlist-count');
break;
}
case 'seek': {
const v = pos[0] ?? '0';
const r = await c.send(['seek', v, 'relative']);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
break;
}
case 'volume': {
if (pos[0]) {
await c.send(['set_property', 'volume', Number(pos[0])]);
}
out.volume = await get('volume');
break;
}
case 'shuffle': {
const on = (pos[0] || 'on') !== 'off';
await checked(['set_property', 'shuffle', on]);
out.shuffle = on;
break;
}
case 'repeat': {
// 每次同时设置两个属性,避免从 single 切换后仍单曲循环。
await checked(['set_property', 'loop-file', repeatMode === 'single' ? 'inf' : 'no']);
await checked(['set_property', 'loop-playlist', repeatMode === 'list' ? 'inf' : 'no']);
out.repeat = repeatMode === 'list'; // 保留原字段含义:是否整列循环。
out.repeat_mode = repeatMode;
break;
}
case 'playlist': {
const [ppos, list] = await Promise.all([get('playlist-pos'), get('playlist')]);
const entries = Array.isArray(list) ? list : [];
out = {
command: 'playlist',
pos: Number.isInteger(ppos) ? ppos : -1,
count: entries.length,
entries: entries.map((e, i) => ({
index: i + 1,
title: typeof e.title === 'string' ? e.title : '',
filename: typeof e.filename === 'string' ? e.filename : '',
duration: Math.round(e.duration || 0),
})),
};
break;
}
case 'jump': {
const n = Number(pos[0]);
if (!Number.isInteger(n) || n < 1) die('usage', '用法:mpv-ctl jump <队列第几首,从 1 开始>', 2);
const count = await get('playlist-count');
if (!Number.isFinite(count) || count < 1) die('notfound', '队列是空的(先 play/daily/liked/artist/album 建队列)', 4);
if (n > count) die('notfound', `队列只有 ${count} 首,没有第 ${n} 首`, 4);
const r = await c.send(['set_property', 'playlist-pos', n - 1]);
if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5);
await c.send(['set_property', 'pause', false]);
out = { command: 'jump', jumped: n, count };
break;
}
case 'status': {
const [idle, paused, tpos, dur, vol, ppos, pcount, title, path_, loop, fileLoop, shuffle] = await Promise.all([
get('idle-active'),
get('pause'),
get('time-pos'),
get('duration'),
get('volume'),
get('playlist-pos'),
get('playlist-count'),
get('media-title'),
get('path'),
get('loop-playlist'),
get('loop-file'),
get('shuffle'),
]);
out = {
command: 'status',
idle: idle === true,
playing: idle === false && paused === false,
paused: paused === true,
pos: Number.isFinite(ppos) ? ppos : -1,
count: Number.isFinite(pcount) ? pcount : 0,
elapsed: Number.isFinite(tpos) ? Math.round(tpos) : null,
duration: Number.isFinite(dur) ? Math.round(dur) : null,
volume: Number.isFinite(vol) ? vol : null,
title: typeof title === 'string' ? title : '',
path: typeof path_ === 'string' ? path_ : '',
repeat: loop === 'inf',
repeat_mode: fileLoop === 'inf' ? 'single' : loop === 'inf' ? 'list' : 'off',
shuffle: shuffle === true,
};
break;
}
default:
die('usage', `未知命令 ${CMD || '(空)'}:play/append/status/playlist/jump/remove/clear/pause/resume/toggle/next/prev/stop/volume/seek/shuffle/repeat`, 2);
}
console.log(JSON.stringify(out));
c.close();
}
main().catch((e) => die('internal', String(e && e.message ? e.message : e), 5));

747
scripts/netease-api.mjs Normal file
View File

@ -0,0 +1,747 @@
#!/usr/bin/env node
// netease-api — 网易云接口客户端(自包含,零 npm 依赖)
//
// 原理:网页端所有请求都走 /weapi/<路径>,参数用「AES-128-CBC 两次 + RSA 加密密钥」包装,
// 这套加密是固定常量(见下方 NONCE/IV/MODULUS),所以不需要跑任何 API 服务,直接调。
// 登录态就是 MUSIC_U cookie,由调用方从环境变量 NETEASE_COOKIE 传进来。
//
// 输出:统一 JSON 到 stdout(内部原语,不给人读);出错时 status 非 0 + {"error":..,"hint":..}
// 用法:node netease-api.mjs <命令> [参数]
// 列表命令支持 --offset N(默认 0):跳过 N 条;count 是本页条数,total 是分页前总数
// search <关键词> [--limit 20] 搜歌
// song <关键词|id> 解析成一首歌(关键词取最匹配一条)
// play-ids <id,id,...> [--start N] 批量详情(只取数);start 从 1 开始,默认 1
// cover <关键词|id> [--download PATH] [--force] 封面 JSON;可下载图片(最多 10 MB)
// url <id...> [--level lossless] 取播放地址(有 VIP 就取无损)
// daily [--limit 40] 每日推荐
// playlist <id|链接> [--limit 500] 歌单全部歌曲
// artist <关键词|id> [--limit 50] 歌手热门 50 首
// album <关键词|id> [--limit 200] 专辑整张
// albums <歌手|id> [--limit 60] 歌手的专辑列表(含搜索索引里没有的老专辑)
// playlists <关键词> [--limit 20] 搜歌单(别人做的)
// charts [--limit 100] 排行榜列表
// chart <名字|id> [--limit 500] 放某个榜单
// myalbums / myartists [--limit 60] 我收藏的专辑 / 歌手
// fm [--limit 12] 私人 FM
// recent [--limit 30] 最近播放(一周)
// podcasts [--recommend] [--limit 50] 我的播客订阅(或推荐)
// podcast <名字|id> [--limit 3] 某播客最新 N 期
// episode <节目id> 某一期节目
// liked [--limit 1000] 我喜欢的音乐(按收藏时间由近到远)
// liked-ids 我喜欢的歌曲 ID 全表(只读)
// dislike <id> 私人 FM 垃圾桶(fm/trash)
// like <id> [--unlike] 喜欢/取消喜欢
// lyric <id> [--trans] 歌词(--trans 带翻译)
// myplaylists [--limit 100] 我的歌单(含「我喜欢的音乐」)
// session 登录态与账号信息
// uid 当前账号 uid(未登录报错)
// qr-start 生成扫码登录的二维码内容与 key
// qr-poll <key> [--timeout 120] 轮询扫码结果,成功时输出 cookie
import crypto from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const NONCE = '0CoJUm6Qyw8W8jud';
const IV = '0102030405060708';
const PUBKEY = '010001';
const MODULUS =
'00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7';
const UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
const COOKIE = process.env.NETEASE_COOKIE || '';
function aesEncrypt(text, key) {
const c = crypto.createCipheriv('aes-128-cbc', Buffer.from(key), Buffer.from(IV));
return Buffer.concat([c.update(text, 'utf8'), c.final()]).toString('base64');
}
function modPow(b, e, m) {
let r = 1n;
b %= m;
while (e > 0n) {
if (e & 1n) r = (r * b) % m;
b = (b * b) % m;
e >>= 1n;
}
return r;
}
function rsaEncrypt(text) {
const hex = Buffer.from(text).reverse().toString('hex');
return modPow(BigInt('0x' + hex), BigInt('0x' + PUBKEY), BigInt('0x' + MODULUS))
.toString(16)
.padStart(256, '0');
}
function cookieValue(name) {
const m = (COOKIE || '').match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
return m ? m[1] : '';
}
function weapi(data) {
const secret = crypto.randomBytes(8).toString('hex').slice(0, 16);
const plain = JSON.stringify({ ...data, csrf_token: cookieValue('__csrf') });
return {
params: aesEncrypt(aesEncrypt(plain, NONCE), secret),
encSecKey: rsaEncrypt(secret),
};
}
class ApiError extends Error {
constructor(code, hint) {
super(hint);
this.code = code;
this.hint = hint;
}
}
async function plainPost(path, data) {
let res;
try {
res = await fetch('https://music.163.com/' + path, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: 'https://music.163.com/',
'User-Agent': UA,
...(COOKIE ? { Cookie: COOKIE } : {}),
},
body: new URLSearchParams(data).toString(),
});
} catch (e) {
throw new ApiError('external', '连不上 music.163.com:' + e.message);
}
lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
const text = await res.text();
if (!text) throw new ApiError('external', `接口返回空(HTTP ${res.status})`);
return JSON.parse(text);
}
let lastSetCookie = [];
// 登录会分多步下发 Set-Cookie,同名会有多条;按名字去重(取最后一条),避免服务端看到重复 cookie
function jarToCookie(jar) {
const map = new Map();
for (const c of jar) {
const kv = c.split(';')[0];
const i = kv.indexOf('=');
if (i > 0) map.set(kv.slice(0, i).trim(), kv.slice(i + 1));
}
return [...map].map(([k, v]) => `${k}=${v}`).join('; ');
}
async function weapiPost(path, data = {}) {
const body = new URLSearchParams(weapi(data)).toString();
let res;
try {
res = await fetch('https://music.163.com/weapi/' + path + '?csrf_token=' + encodeURIComponent(cookieValue('__csrf')), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: 'https://music.163.com/',
'User-Agent': UA,
...(COOKIE ? { Cookie: COOKIE } : {}),
},
body,
});
} catch (e) {
throw new ApiError('external', '连不上 music.163.com:' + e.message);
}
lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : [];
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new ApiError('external', `接口返回的不是 JSON(HTTP ${res.status}):${text.slice(0, 120)}`);
}
return json;
}
const song = (s) => ({
id: s.id,
name: s.name,
artist: (s.ar || s.artists || []).map((a) => a.name).join(' / '),
album: (s.al || s.album || {}).name || '',
ms: s.dt || s.duration || 0,
vip: s.fee === 1 || s.fee === 4,
cover: String(s.al?.picUrl || s.album?.picUrl || s.picUrl || ''),
// 没有明确布尔值就不猜;调用方可用 liked-ids 一次取全表后按 id 补齐。
...(typeof s.liked === 'boolean' ? { liked: s.liked } : {}),
});
const num = (v) => (Number.isFinite(+v) ? +v : 0);
function songId(value) {
if (!/^\d+$/.test(value || '') || !Number.isSafeInteger(+value) || +value < 1) {
throw new ApiError('usage', '歌曲 id 需要正整数');
}
return +value;
}
async function cmdPlayIds(args, o) {
if (args.length !== 1) throw new ApiError('usage', '用法:play-ids <id,id,...> [--start N]');
const ids = args[0].split(',').map(songId);
if (!Number.isSafeInteger(o.start) || o.start < 1 || o.start > ids.length) {
throw new ApiError('usage', '--start 需要输入列表内的序号(从 1 开始)');
}
const songs = [];
for (let i = 0; i < ids.length; i += 200) songs.push(...await songsByIds(ids.slice(i, i + 200)));
if (songs.length !== ids.length) throw new ApiError('notfound', '输入列表中有歌曲详情缺失,队列未替换');
return { count: songs.length, total: ids.length, start: o.start, songs };
}
// 搜索必须走明文接口:weapi 版的 cloudsearch 现在对匿名请求回空体(不报错,就空)
async function cmdSearch(kw, o) {
const j = await plainPost('api/search/get/web', { s: kw, type: 1, limit: o.limit, offset: o.offset, total: true });
const list = (j.result && j.result.songs) || [];
return { kw, count: list.length, total: j.result?.songCount ?? list.length, songs: list.map(song) };
}
async function cmdSong(kw) {
if (/^\d+$/.test(kw)) {
const j = await weapiPost('v3/song/detail', { c: JSON.stringify([{ id: +kw }]) });
const s = (j.songs || []).find((s) => +s.id === +kw);
if (!s) throw new ApiError('notfound', `没有这首歌:id=${kw}`);
return song(s);
}
const r = await cmdSearch(kw, { limit: 5, offset: 0 });
if (!r.songs.length) throw new ApiError('notfound', `搜不到「${kw}」`);
return r.songs[0];
}
const MAX_COVER_BYTES = 10_000_000;
async function downloadCover(cover, file, force) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
let body;
try {
const url = new URL(cover);
if (!['http:', 'https:'].includes(url.protocol)) throw new ApiError('external', '封面地址必须是 HTTP(S) URL');
// 图片请求不携带登录 cookie;先完整读取有上限的响应,再打开目标文件。
const res = await fetch(url, {
headers: { Referer: 'https://music.163.com/', 'User-Agent': UA },
signal: controller.signal,
});
if (!res.ok) throw new ApiError([404, 410].includes(res.status) ? 'notfound' : 'external', `封面下载失败(HTTP ${res.status})`);
const type = res.headers.get('content-type');
if (type && !/^image\//i.test(type)) throw new ApiError('external', `封面响应不是图片:${type}`);
if (Number(res.headers.get('content-length')) > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)');
if (!res.body) throw new ApiError('notfound', '封面图片为空');
const chunks = [];
let bytes = 0;
for await (const chunk of res.body) {
bytes += chunk.byteLength;
if (bytes > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)');
chunks.push(chunk);
}
if (!bytes) throw new ApiError('notfound', '封面图片为空');
body = Buffer.concat(chunks, bytes);
} catch (e) {
if (e instanceof ApiError) throw e;
throw new ApiError(controller.signal.aborted ? 'timeout' : 'external', '封面下载失败:' + e.message);
} finally {
clearTimeout(timer);
controller.abort();
}
try {
await writeFile(file, body, { flag: force ? 'w' : 'wx' });
} catch (e) {
if (e.code === 'EEXIST') throw new ApiError('usage', `文件已存在:${file}(覆盖需 --force)`);
throw new ApiError('external', `无法写入封面:${e.message}`);
}
return body.length;
}
async function cmdCover(kw, o) {
if (!kw.trim() || pos.some((a) => a.startsWith('--'))) throw new ApiError('usage', 'cover 需要歌曲关键词或 id');
if (o.download !== undefined && (!o.download.trim() || o.download.startsWith('--'))) throw new ApiError('usage', '--download 需要文件路径');
if (o.force && o.download === undefined) throw new ApiError('usage', '--force 只能与 --download 一起使用');
let s = await cmdSong(kw);
// 明文搜索可能省略图片 URL;只在缺封面时补查选中歌曲的详情。
if (!s.cover && !/^\d+$/.test(kw)) s = await cmdSong(String(s.id));
const { id, name, artist, cover } = s;
if (!cover) throw new ApiError('notfound', `这首歌没有封面:id=${id}`);
if (o.download === undefined) return { id, name, artist, cover };
const file = resolve(o.download);
const bytes = await downloadCover(cover, file, o.force);
return { id, cover, file, bytes };
}
async function cmdUrl(ids, o) {
const level = o.level;
const j = await weapiPost('song/enhance/player/url/v1', {
ids: JSON.stringify(ids.map(Number)),
level,
encodeType: ['standard', 'higher'].includes(level) ? 'aac' : 'flac',
});
return {
level: j.data && j.data[0] && j.data[0].level,
items: (j.data || []).map((d) => ({ id: d.id, url: d.url, br: d.br, level: d.level, size: d.size, fee: d.fee })),
};
}
async function cmdDaily(o) {
const j = await weapiPost('v2/discovery/recommend/songs', {});
const list = (j.data && j.data.dailySongs) || j.recommend || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '每日推荐返回空:' + JSON.stringify(j).slice(0, 160));
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
return { count: songs.length, total: list.length, songs };
}
async function cmdPlaylist(idOrKw, o) {
let id = String(idOrKw);
if (!/^\d+$/.test(id)) {
const hit = await searchFirst(String(idOrKw), 1000, 'playlists', 'playlist');
id = hit.id;
}
const j = await weapiPost('v6/playlist/detail', { id: +id, n: o.limit, s: 8 });
if (!j.playlist) throw new ApiError('notfound', `没有这个歌单:id=${id}`);
const tracks = j.playlist.tracks || [];
let songs = tracks.slice(o.offset, o.offset + o.limit).map(song);
// detail 不认 offset,内嵌 tracks 也可能截断;按完整 trackIds 取本页,缺的详情才补查。
if (Array.isArray(j.playlist.trackIds)) {
const ids = j.playlist.trackIds.slice(o.offset, o.offset + o.limit).map((t) => t.id);
const byId = new Map(tracks.map((s) => [s.id, song(s)]));
const missing = ids.filter((id) => !byId.has(id));
for (const s of await songsByIds(missing)) byId.set(s.id, s);
songs = ids.map((id) => byId.get(id)).filter(Boolean);
}
return {
id: j.playlist.id,
name: j.playlist.name,
count: songs.length,
total: j.playlist.trackCount ?? tracks.length,
songs,
};
}
const norm = (v) => String(v || '').toLowerCase().replace(/\s+/g, '').replace(/[((].*$/, '');
// 同名/别名优先;专辑再按曲目数排(正版专辑 > 同名单曲/翻唱)
function pickBest(list, kw, kind) {
const want = norm(kw);
const score = (it) => {
const names = [it.name, ...(it.alias || [])].map(norm);
if (want && names.includes(want)) return 2;
if (want && names.some((n) => n && (n.includes(want) || want.includes(n)))) return 1;
return 0;
};
const ranked = list.map((it) => ({ s: score(it), it })).sort((a, b) => b.s - a.s);
let cands = ranked.filter((r) => r.s === ranked[0].s).map((r) => r.it);
if (ranked[0].s === 2 && kind === 'album') cands = cands.slice().sort((a, b) => (b.size || 0) - (a.size || 0));
return cands[0];
}
// 搜歌手/专辑/歌单/播客:先走 weapi cloudsearch(已登录时索引全,明文库里连周杰伦《范特西》都没有),
// 拿不到再回退明文 api/search/get/web
async function searchList(kw, type, key, limit = 10, offset = 0) {
let result;
try {
const j = await weapiPost('cloudsearch/get/web', { s: kw, type, limit, offset, total: true });
result = j.result;
} catch { /* 回退明文 */ }
// 有总数的空页就是到末尾了,不换搜索索引重新找一页。
if (!(result?.[key]?.length) && !(offset > 0 && result?.playlistCount != null)) {
const j = await plainPost('api/search/get/web', { s: kw, type, limit, offset, total: true });
result = j.result;
}
const list = result?.[key] || [];
return { list, total: result?.playlistCount ?? list.length };
}
async function searchFirst(kw, type, key, kind) {
const { list } = await searchList(kw, type, key);
if (!list.length) throw new ApiError('notfound', `搜不到「${kw}」`);
return pickBest(list, kw, kind);
}
// 歌手热门:v1/artist/<id> 的 hotSongs;老接口 artist/top/song 作回退
async function cmdArtist(kw, o) {
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id;
let j = await weapiPost(`v1/artist/${id}`, {});
let list = j.hotSongs || [];
let name = (j.artist && j.artist.name) || '';
if (!list.length) {
j = await weapiPost('artist/top/song', { id });
list = j.songs || [];
}
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有可播的歌:${kw}`);
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
return { id, name: name || kw, count: songs.length, total: list.length, songs };
}
// 歌手的专辑列表(含经典老专辑,搜索索引里没有的也能从这里拿到 id)
async function cmdAlbums(kw, o) {
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id;
const j = await weapiPost(`artist/albums/${id}`, { limit: o.limit, offset: o.offset, total: true });
const list = j.hotAlbums || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有专辑:${kw}`);
return {
id,
name: (list[0] && list[0].artist && list[0].artist.name) || kw,
count: list.length,
total: j.artist?.albumSize ?? j.total ?? list.length,
albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, year: (a.publishTime && new Date(a.publishTime).getFullYear()) || 0 })),
};
}
// 专辑:v1/album/<id> 的 songs(整张,按专辑顺序)
async function cmdAlbum(kw, o) {
const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 10, 'albums', 'album')).id;
const j = await weapiPost(`v1/album/${id}`, {});
const list = j.songs || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这张专辑没有可播的歌:${kw}`);
const songs = list.slice(o.offset, o.offset + o.limit).map(song);
return { id, name: (j.album && j.album.name) || kw, count: songs.length, total: j.album?.size ?? j.album?.songCount ?? list.length, songs };
}
// ---------- 歌单搜索 / 榜单 / 收藏 / FM / 最近 / 播客 ----------
async function cmdPlaylists(kw, o) {
const { list, total } = await searchList(kw, 1000, 'playlists', o.limit, o.offset);
if (!list.length && !o.offset) throw new ApiError('notfound', `搜不到歌单「${kw}」`);
return {
kw,
count: list.length,
total,
playlists: list.map((p) => ({
id: p.id,
name: p.name,
count: p.trackCount || 0,
creator: (p.creator || {}).nickname || '',
sub: p.subscribed === true,
})),
};
}
async function cmdCharts(o) {
const j = await weapiPost('toplist', {});
const list = j.list || [];
if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '榜单接口没返回列表');
const page = list.slice(o.offset, o.offset + o.limit);
return {
count: page.length,
total: j.total ?? j.count ?? list.length,
charts: page.map((c) => ({ id: c.id, name: c.name, count: c.trackCount, freq: c.updateFrequency })),
};
}
async function cmdChart(kw, o) {
let id = /^\d+$/.test(kw) ? +kw : 0;
let name = kw;
if (!id) {
const j = await weapiPost('toplist', {});
const hit = pickBest(j.list || [], kw, 'chart');
if (!hit) throw new ApiError('notfound', `没有这个榜单:${kw}(用 charts 看全部)`);
id = hit.id;
name = hit.name;
}
const r = await cmdPlaylist(String(id), o);
return { ...r, chart_id: id, chart: name };
}
async function cmdMyAlbums(o) {
const j = await weapiPost('album/sublist', { limit: o.limit, offset: o.offset, total: true });
const list = j.data || [];
return {
count: list.length,
total: j.count ?? j.total ?? list.length,
albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, artist: (a.artists || []).map((x) => x.name).join(' / ') })),
};
}
async function cmdMyArtists(o) {
const j = await weapiPost('artist/sublist', { limit: o.limit, offset: o.offset, total: true });
const list = j.data || [];
return { count: list.length, total: j.count ?? j.total ?? list.length, artists: list.map((a) => ({ id: a.id, name: a.name, albums: a.albumSize, songs: a.musicSize })) };
}
// 私人 FM:接口一次固定给 3 首且不认 limit,多拉几次凑够(去重)
async function cmdFm(o) {
const want = Math.min(o.limit, 30);
const seen = new Set();
const out = [];
for (let i = 0; i < 12 && out.length < want; i++) {
const j = await weapiPost('radio/get', {});
const batch = j.data || [];
if (!batch.length) break;
for (const s of batch) if (!seen.has(s.id)) { seen.add(s.id); out.push(song(s)); }
}
if (!out.length) throw new ApiError('external', '私人 FM 没返回歌曲');
const picked = out.slice(0, want);
return { count: picked.length, songs: picked };
}
async function cmdRecent(o) {
const uid = await currentUid();
const j = await weapiPost('v1/play/record', { uid, type: 1 }); // type=1:最近一周
const list = j.weekData || j.allData || [];
const items = list.slice(o.offset, o.offset + o.limit).filter((x) => x.song).map((x) => ({ ...song(x.song), plays: x.playCount }));
if (!items.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', '最近播放是空的');
return { count: items.length, total: j.total ?? j.count ?? list.length, songs: items };
}
async function cmdPodcasts(o) {
// 两个端点都认 limit、不认 offset;拉到页尾再本地截,避免每页重复第一页。
const j = await weapiPost(o.recommend ? 'djradio/recommend/v1' : 'djradio/get/subed', {
limit: o.offset + o.limit, offset: o.offset,
});
const list = j.djRadios || [];
const page = list.slice(o.offset, o.offset + o.limit);
if (!list.length && (!o.offset || j.code !== 200)) {
throw new ApiError('notfound', o.recommend ? '没有推荐播客' : '没订阅播客(加 --recommend 看推荐的)');
}
return {
kind: o.recommend ? 'recommend' : 'subscribed',
count: page.length,
total: j.count ?? j.total ?? list.length,
podcasts: page.map((r) => ({ id: r.id, name: r.name, episodes: r.programCount, dj: (r.dj || {}).nickname || '' })),
};
}
// 放某播客的最新 N 期:名字先在订阅+推荐里找,找不到再搜播客
async function cmdPodcast(kw, o) {
let id = /^\d+$/.test(kw) ? +kw : 0;
let name = kw;
if (!id) {
const subs = (await weapiPost('djradio/get/subed', { limit: 100, offset: 0 })).djRadios || [];
const rec = (await weapiPost('djradio/recommend/v1', { limit: 100, offset: 0 })).djRadios || [];
const hit = pickBest([...subs, ...rec], kw, 'djradio');
if (hit) {
id = hit.id;
name = hit.name;
} else {
const s = await searchFirst(kw, 1009, 'djRadios', 'djradio');
id = s.id;
name = s.name;
}
}
const j = await weapiPost('dj/program/byradio', { radioId: id, limit: o.limit, offset: o.offset, asc: false });
const progs = j.programs || [];
const songs = progs
.filter((p) => p.mainSong && p.mainSong.id)
.map((p) => ({ ...song(p.mainSong), programId: p.id, program: p.name || '' }));
if (!songs.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这个播客没有可播的节目:${kw}`);
return { id, name, count: songs.length, total: j.count ?? j.total ?? progs[0]?.radio?.programCount ?? progs.length, songs };
}
async function cmdEpisode(pid, o) {
const j = await weapiPost('dj/program/detail', { id: +pid });
const p = j.program || {};
const ms = p.mainSong || {};
if (!ms.id) throw new ApiError('notfound', `没有这期节目:${pid}`);
return {
id: +pid,
name: p.name || ms.name || '',
dj: (p.dj || {}).nickname || '',
count: 1,
songs: [{ ...song(ms), programId: +pid, program: p.name || '' }],
};
}
// 「我喜欢的音乐」顺序 = 收藏时间由近到远(与客户端一致):
// song/like/get 返回的 ids 顺序与收藏时间无关(实测最老收藏的排第 1),
// 真值是 specialType=5 歌单的 trackIds[].at(收藏时间毫秒戳),按它倒序再取详情。
async function cmdLiked(o) {
const pid = await likedPlaylistId();
if (!pid) throw new ApiError('notfound', '找不到「我喜欢的音乐」歌单');
// n=0 省掉内嵌 tracks,trackIds 仍全量返回
const j = await weapiPost('v6/playlist/detail', { id: pid, n: 0, s: 8 });
const tids = ((j.playlist || {}).trackIds || []).slice().sort((a, b) => (b.at || 0) - (a.at || 0));
const ids = tids.slice(o.offset, o.offset + o.limit).map((x) => x.id);
const songs = (await songsByIds(ids)).map((s) => ({ ...s, liked: true }));
return { count: songs.length, total: tids.length, songs };
}
async function songsByIds(ids) {
if (!ids.length) return [];
const det = await weapiPost('v3/song/detail', { c: JSON.stringify(ids.map((id) => ({ id }))) });
const byId = new Map((det.songs || []).map((s) => [s.id, song(s)]));
return ids.map((id) => byId.get(id)).filter(Boolean);
}
async function likedPlaylistId() {
const uid = await currentUid();
const j = await weapiPost('user/playlist', { uid, limit: 1000, offset: 0, includeVideo: true });
const p = (j.playlist || []).find((x) => x.specialType === 5) || (j.playlist || [])[0];
return p && p.id;
}
async function cmdLike(id, o) {
const like = !o.unlike;
const j = await weapiPost('radio/like', { alg: 'itembased', trackId: +id, like, time: 3 });
if (j.code === 200) return { id: +id, liked: like, via: 'radio/like', code: j.code };
const first = `radio/like code=${j.code}:${j.message || j.msg || ''}`;
// 风控(-460)时退一步:直接对「我喜欢的音乐」歌单做增删,走另一套风控
const pid = await likedPlaylistId();
if (pid) {
const j2 = await weapiPost('playlist/manipulate/tracks', {
op: like ? 'add' : 'del',
pid,
trackIds: JSON.stringify([+id]),
imme: 'true',
});
if (j2.code === 200 || (like && j2.code === 502)) {
// 502 = 歌单内歌曲重复 → 目标状态已经达成,按幂等成功处理
return { id: +id, liked: like, via: 'playlist/manipulate/tracks', already: j2.code === 502, fallback_from: first };
}
throw new ApiError('external', `网易云拒绝了这次操作(${first};playlist/manipulate code=${j2.code}:${j2.message || j2.msg || ''})`);
}
throw new ApiError('external', `网易云拒绝了这次操作(${first})`);
}
async function cmdLyric(id, o) {
const j = await weapiPost('song/lyric', { id: +id, lv: -1, kv: -1, tv: -1 });
return { id: +id, lyric: (j.lrc && j.lrc.lyric) || '', trans: o.trans ? (j.tlyric && j.tlyric.lyric) || '' : undefined };
}
async function cmdSession() {
const j = await weapiPost('w/nuser/account/get', {});
return {
loggedIn: !!(j.profile && j.profile.userId),
uid: num(j.profile && j.profile.userId),
nick: (j.profile && j.profile.nickname) || '',
vip: (j.profile && j.profile.vipType) || 0,
};
}
async function currentUid() {
const s = await cmdSession();
if (!s.loggedIn) throw new ApiError('auth', '没登录:先跑 pi-skill netease-music login(手机扫码)');
return s.uid;
}
async function cmdMyPlaylists(o) {
const uid = await currentUid();
// user/playlist 的 limit 不稳定,非零 offset 还会多跳过置顶歌单;从头取再本地分页。
const j = await weapiPost('user/playlist', { uid, limit: o.offset + o.limit, includeVideo: true });
const list = j.playlist || [];
const page = list.slice(o.offset, o.offset + o.limit);
return {
count: page.length,
total: j.total ?? j.count ?? list.length,
playlists: page.map((p) => ({ id: p.id, name: p.name, count: p.trackCount, special: p.specialType || 0, sub: p.subscribed === true })),
};
}
async function cmdLikeState(id) {
// 这个端点回的是「我喜欢的歌曲 id 全表」(key 是 ids),不是单曲对象
const j = await weapiPost('song/like/get', { trackIds: JSON.stringify([Number(id)]) });
if (j.code !== 200) throw new ApiError('external', `查喜欢状态失败(code=${j.code}):${j.message || j.msg || ''}`);
const ids = (Array.isArray(j.ids) ? j.ids : []).map(Number);
return { id: Number(id), liked: ids.includes(Number(id)), liked_total: ids.length, code: j.code };
}
async function cmdLikedIds(args) {
if (args.length) throw new ApiError('usage', '用法:liked-ids');
const j = await weapiPost('song/like/get', { trackIds: '[]' });
if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `查喜欢列表失败(code=${j.code}):${j.message || j.msg || ''}`);
if (!Array.isArray(j.ids)) throw new ApiError('external', '喜欢列表缺少 ids,不能当成空列表');
const ids = j.ids.map(Number);
if (ids.some((id) => !Number.isSafeInteger(id) || id < 1)) throw new ApiError('external', '喜欢列表含无效歌曲 id');
return { count: ids.length, total: ids.length, ids };
}
async function cmdDislike(args) {
if (args.length !== 1) throw new ApiError('usage', '用法:dislike <歌曲id>');
const id = songId(args[0]);
// NeteaseCloudMusicApi 的 fm/trash 对应此 weapi 写端点。
const j = await weapiPost('radio/trash/add', { songId: id, time: 25, alg: 'RT' });
if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `私人 FM 不喜欢失败(code=${j.code}):${j.message || j.msg || ''}`);
return { id, disliked: true, code: j.code };
}
async function cmdSmsSend(phone) {
const j = await weapiPost('sms/captcha/sent', { cellphone: String(phone), ctcode: '86' });
if (j.code !== 200) throw new ApiError('external', `发验证码没成功(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`);
return { sent: true };
}
async function cmdSmsLogin(phone, code) {
const j = await weapiPost('login/cellphone', { phone: String(phone), countrycode: '86', captcha: String(code), rememberLogin: true });
const cookie = jarToCookie(lastSetCookie);
if (!/MUSIC_U=/.test(cookie)) throw new ApiError('auth', `登录没成(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`);
return { state: 'ok', cookie };
}
async function cmdQrStart() {
const j = await weapiPost('login/qrcode/unikey', { type: 1, header: {} });
if (!j.unikey) throw new ApiError('external', '拿不到二维码 key:' + JSON.stringify(j).slice(0, 160));
return { key: j.unikey, url: 'https://music.163.com/login?codekey=' + j.unikey };
}
async function cmdQrPoll(key, o) {
const deadline = Date.now() + o.timeout * 1000;
let state = 'waiting';
while (Date.now() < deadline) {
const j = await weapiPost('login/qrcode/client/login', { key, type: 1 });
if (j.code === 800) return { state: 'expired', code: j.code };
if (j.code === 802 && state !== 'scanned') {
state = 'scanned';
console.error('已扫到,等你在手机上点「确认登录」…');
}
if (j.code === 803) {
const cookie = jarToCookie(lastSetCookie);
if (!/MUSIC_U=/.test(cookie)) throw new ApiError('external', '登录成功了但没拿到 MUSIC_U:' + cookie.slice(0, 120));
return { state: 'ok', cookie };
}
await new Promise((r) => setTimeout(r, 2000));
}
return { state, code: 801 };
}
// ---------- 参数解析 ----------
const argv = process.argv.slice(2);
const CMD = argv[0];
const opts = { limit: 0, offset: 0, start: 1, level: 'lossless', trans: false, unlike: false, timeout: 120 };
const pos = [];
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
if (a === '--limit') opts.limit = +argv[++i];
else if (a === '--offset') opts.offset = +argv[++i];
else if (a === '--start') {
const value = argv[++i];
opts.start = /^\d+$/.test(value || '') ? +value : NaN;
}
else if (a === '--level') opts.level = argv[++i];
else if (a === '--trans') opts.trans = true;
else if (a === '--unlike') opts.unlike = true;
else if (a === '--timeout') opts.timeout = +argv[++i];
else if (a === '--recommend') opts.recommend = true;
else if (a === '--download') opts.download = argv[++i] ?? '';
else if (a === '--force') opts.force = true;
else pos.push(a);
}
const DEFAULTS = { search: 20, daily: 40, playlist: 500, liked: 1000, myplaylists: 100, artist: 50, album: 200, albums: 60,
playlists: 20, charts: 100, myalbums: 60, myartists: 60, fm: 12, recent: 30, podcasts: 50, podcast: 3 };
if (!opts.limit) opts.limit = DEFAULTS[CMD] || 20;
const rawPost = (path, data) => (String(path).startsWith('api/') ? plainPost(path, data) : weapiPost(path, data));
const HANDLERS = {
search: () => cmdSearch(pos.join(' '), opts),
song: () => cmdSong(pos.join(' ')),
'play-ids': () => cmdPlayIds(pos, opts),
cover: () => cmdCover(pos.join(' '), opts),
url: () => cmdUrl(pos, opts),
daily: () => cmdDaily(opts),
playlist: () => cmdPlaylist(String(pos[0]).replace(/.*[?&#/]id=/, ''), opts),
playlists: () => cmdPlaylists(pos.join(' '), opts),
charts: () => cmdCharts(opts),
chart: () => cmdChart(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
myalbums: () => cmdMyAlbums(opts),
myartists: () => cmdMyArtists(opts),
fm: () => cmdFm(opts),
recent: () => cmdRecent(opts),
podcasts: () => cmdPodcasts(opts),
podcast: () => cmdPodcast(String(pos.join(' ')), opts),
episode: () => cmdEpisode(pos[0], opts),
artist: () => cmdArtist(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
album: () => cmdAlbum(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
albums: () => cmdAlbums(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts),
liked: () => cmdLiked(opts),
'liked-ids': () => cmdLikedIds(pos),
dislike: () => cmdDislike(pos),
like: () => cmdLike(pos[0], opts),
lyric: () => cmdLyric(pos[0], opts),
myplaylists: () => cmdMyPlaylists(opts),
session: () => cmdSession(),
uid: () => currentUid().then((uid) => ({ uid })),
raw: () => rawPost(pos[0], JSON.parse(pos[1] || '{}')),
'like-state': () => cmdLikeState(pos[0]),
'sms-send': () => cmdSmsSend(pos[0]),
'sms-login': () => cmdSmsLogin(pos[0], pos[1]),
'qr-start': () => cmdQrStart(),
'qr-poll': () => cmdQrPoll(pos[0], opts),
};
if (!HANDLERS[CMD]) {
console.error(JSON.stringify({ error: 'usage', hint: `未知命令 ${CMD || '(空)'};可用:${Object.keys(HANDLERS).join(' ')}` }));
process.exit(2);
}
try {
if (!Number.isSafeInteger(opts.offset) || opts.offset < 0) throw new ApiError('usage', '--offset 需要非负整数');
const out = await HANDLERS[CMD]();
console.log(JSON.stringify(out));
} catch (e) {
const code = e instanceof ApiError ? e.code : 'internal';
console.error(JSON.stringify({ error: code, hint: e.hint || String(e) }));
process.exit(code === 'usage' ? 2 : code === 'auth' ? 3 : code === 'notfound' ? 4 : 5);
}

1157
scripts/netease-music.sh Executable file

File diff suppressed because it is too large Load Diff

40
scripts/publish.sh Executable file
View File

@ -0,0 +1,40 @@
#!/bin/sh
# 发布镜像:把本技能目录整体发布成公开仓库(生成物,整体覆盖;不要在镜像仓手工改)。
# 用法:scripts/publish.sh [--dry-run]
# 远端:NETEASE_MUSIC_PUBLISH_REMOTES(空格分隔);默认两个 gitea 远端。
set -eu
here="$(cd "$(dirname "$0")/.." && pwd)"
version="$(cat "$here/VERSION")"
dry=0
ok=0; failed=0
[ "${1:-}" = "--dry-run" ] && dry=1
remotes="${NETEASE_MUSIC_PUBLISH_REMOTES:-git@gitea.gklazycat.heiyu.space:geekinney/netease-music.git https://gitea.vhkd.top/geekinney/netease-music.git}"
# 门禁:先过测试再发布。
(cd "$here" && node --test tests/*.test.mjs >/dev/null)
for remote in $remotes; do
work="$(mktemp -d)"
if git clone --quiet "$remote" "$work/repo" 2>/dev/null; then
cd "$work/repo"
else
mkdir -p "$work/repo"; cd "$work/repo"; git init -q; git remote add origin "$remote"
fi
rsync -a --delete --exclude '.git' --exclude '__pycache__' --exclude 'tests/.runtime-*' "$here/" "$work/repo/"
git add -A
if git diff --cached --quiet; then
printf 'no changes for %s\n' "$remote"
else
git commit -q -m "release v$version"
fi
if [ "$dry" = 1 ]; then
printf 'dry-run: would push %s\n' "$remote"
elif git push -q origin HEAD:main 2>/dev/null || git push -q --set-upstream origin HEAD:main 2>/dev/null; then
printf 'published v%s -> %s\n' "$version" "$remote"
ok=$((ok + 1))
else
printf 'push failed: %s(仓库没建?凭据?)\n' "$remote"
failed=$((failed + 1))
fi
cd "$here"; rm -rf "$work"
done
[ "$ok" -gt 0 ] || exit 1
printf 'remotes ok=%s failed=%s\n' "$ok" "$failed"

100
scripts/refresh-urls.sh Executable file
View File

@ -0,0 +1,100 @@
#!/bin/bash
# refresh-urls.sh — 队列地址保活
#
# 网易云的播放直链是限时签名 URL:实测取出来约 5 分钟就 403
# (路径里的时间戳 + vuutv/authSecret 签名,服务端一核,过期就拒绝)。
# 长队列预取一批地址,放到后半段会集体失效,听感是「歌还在放、就是没声音」。
#
# 本脚本由入口的 keepalive_start 拉起(一次只跑一个),每 90 秒看一次:
# 这批地址取出来多久了(urls_at)× mpv 播到第几首(playlist-pos)
# 一到 4 分钟,就把「当前这首之后还没播的」批量重取,原地换掉 m3u 里对应的行。
# mpv 是按需读取下一首的,所以换 URL 不影响正在放的那首。
# 队列被换掉 / 停止 / mpv 退出,脚本自己收工。
set -u
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_DIR="${NETEASE_MUSIC_STATE_DIR:-${PI_AGENT_DIR:-$HOME/.pi/agent}/local/skills/netease-music}"
QUEUE="$LOCAL_DIR/queue.json"
M3U="$LOCAL_DIR/queue.m3u8"
URLS_AT="$LOCAL_DIR/urls_at"
SOCK="$LOCAL_DIR/mpv.sock"
API="$SKILL_DIR/scripts/netease-api.mjs"
QUALITY="$(python3 -c 'import json,sys
try: print(json.load(open(sys.argv[1])).get("quality") or "lossless")
except Exception: print("lossless")' "$LOCAL_DIR/config.json" 2>/dev/null)"
QUALITY="${NETEASE_MUSIC_QUALITY:-${PI_SKILL_NETEASE_MUSIC_QUALITY:-$QUALITY}}"
COOKIE="${NETEASE_MUSIC_COOKIE:-${PI_SKILL_NETEASE_MUSIC_COOKIE:-$(python3 -c 'import json,sys
try: print(json.load(open(sys.argv[1])).get("cookie") or "")
except Exception: print("")' "$LOCAL_DIR/config.json" 2>/dev/null)}}"
log() { printf '%s %s\n' "$(date '+%H:%M:%S')" "$*"; }
mpv_query() { # mpv_query <属性>:拿不到就打印空
python3 - "$SOCK" "$1" <<'PY' 2>/dev/null
import json, socket, sys
try:
s = socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect(sys.argv[1])
s.sendall(json.dumps({"command": ["get_property", sys.argv[2]], "request_id": 1}).encode() + b"\n")
r = json.loads(s.recv(65536).decode())
print(r.get("data") if r.get("error") == "success" else "")
except Exception:
print("")
PY
}
refetch_from() { # refetch_from <起始序号 0-based>:重取这一首之后的地址,原地换 m3u
local from="$1" tmp_ids tmp_urls n
tmp_ids="$(mktemp)"; tmp_urls="$(mktemp)"
python3 - "$QUEUE" "$from" > "$tmp_ids" <<'PY'
import json, sys
q = json.load(open(sys.argv[1]))
for i, s in enumerate(q["songs"]):
if i >= int(sys.argv[2]):
print(s["id"])
PY
[ -s "$tmp_ids" ] || { rm -f "$tmp_ids" "$tmp_urls"; return 1; }
n="$(wc -l < "$tmp_ids" | tr -d ' ')"
log "重取地址:从第 $((from + 1)) 首起共 $n 首"
while read -r chunk; do
NETEASE_COOKIE="$COOKIE" node "$API" url $chunk --level "$QUALITY" 2>/dev/null \
| python3 -c 'import json,sys
for it in (json.load(sys.stdin).get("items") or []):
print("%s\t%s" % (it.get("id"), it.get("url") or ""))' >> "$tmp_urls"
done < <(python3 -c 'import sys
ids = [l.strip() for l in sys.stdin if l.strip()]
for i in range(0, len(ids), 50): print(" ".join(ids[i:i+50]))' < "$tmp_ids")
python3 - "$M3U" "$QUEUE" "$tmp_urls" "$from" <<'PY'
import json, sys
mpath, qpath, upath, start = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
urls = {}
for line in open(upath):
sid, _, url = line.rstrip("\n").partition("\t")
if url:
urls[sid] = url
order = [str(s["id"]) for s in json.load(open(qpath))["songs"]]
lines = open(mpath).read().split("\n")
head = [i for i, l in enumerate(lines) if l.startswith("#EXTINF")]
changed = 0
for pos, i in enumerate(head):
if pos < start or pos >= len(order) or i + 1 >= len(lines):
continue
u = urls.get(order[pos])
if u:
lines[i + 1] = u
changed += 1
open(mpath, "w").write("\n".join(lines))
print("换成新地址的条数:%d/%d" % (changed, len(head) - start))
PY
log "重取完成(从第 $((from + 1)) 首起)"
rm -f "$tmp_ids" "$tmp_urls"
}
log "地址保活启动"
while :; do
sleep 90
[ -n "$(mpv_query idle-active)" ] || { log "mpv 没了,收工"; exit 0; }
[ -s "$QUEUE" ] || { log "队列没了,收工"; exit 0; }
age=$(( $(date +%s) - $(cat "$URLS_AT" 2>/dev/null || echo 0) ))
[ "$age" -lt 240 ] && continue
# 「当前放到第几首」按歌认:mpv 开了 shuffle 时 playlist-pos 不是 queue.json 的序号
title="$(mpv_query media-title)"
curl="$(mpv_query path)"
pos="$(python3 "$SKILL_DIR/scripts/lib/mpv-map.py" index "$QUEUE" "$LOCAL_DIR/urls.txt" "$title" "$curl" 2>/dev/null)"
case "$pos" in ''|*[!0-9]*) pos=0 ;; esac
if refetch_from "$pos"; then date +%s > "$URLS_AT"; else log "重取失败,90 秒后再试"; fi
done

113
tests/fixtures/api-fetch.mjs vendored Normal file
View File

@ -0,0 +1,113 @@
// 离线 weapi 替身:解开真实请求参数,按接口的分页方式回包;绝不连接网络。
import crypto from 'node:crypto';
import { appendFileSync } from 'node:fs';
import './mpv-ipc.mjs';
const secret = Buffer.alloc(8, 1);
crypto.randomBytes = () => secret;
const scenario = process.env.NETEASE_TEST_SCENARIO || '';
const coverUrl = 'https://artwork.invalid/cover.png';
const logCall = (call) => process.env.NETEASE_TEST_API_CALLS
? appendFileSync(process.env.NETEASE_TEST_API_CALLS, JSON.stringify(call) + '\n')
: console.error(JSON.stringify(call));
const song = (id) => ({
id, name: `歌曲 ${id}`, ar: [{ name: '歌手' }], al: { name: '专辑' }, dt: 180000,
...(scenario === 'liked-flags' ? { liked: id % 2 === 0 } : {}),
...(scenario === 'liked-unknown' ? { liked: null } : {}),
...(scenario.startsWith('cover') && scenario !== 'cover-missing' ? {
al: { name: '专辑', picUrl: ['cover-album', 'cover-song'].includes(scenario) ? '' : coverUrl },
album: { picUrl: scenario === 'cover-song' ? '' : `${coverUrl}?album` },
picUrl: `${coverUrl}?song`,
} : {}),
});
const songs = Array.from({ length: scenario === 'empty' ? 0 : 507 }, (_, i) => song(1000 + i));
const rows = songs.slice(0, 7).map((s, i) => ({ ...s, trackCount: 20, specialType: i ? 0 : 5, subscribed: i > 2, programCount: 7, size: 7 }));
const withTotal = (key, value) => scenario === 'no-total' ? {} : { [key]: scenario === 'empty' ? 0 : value };
const page = (data) => rows.slice(+data.offset || 0, (+data.offset || 0) + +data.limit);
const decrypt = (value, key) => {
const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(key), Buffer.from('0102030405060708'));
return Buffer.concat([decipher.update(value, 'base64'), decipher.final()]).toString();
};
globalThis.fetch = async (url, options) => {
const u = new URL(url);
if (u.hostname === 'artwork.invalid') {
if (options.headers.Cookie) throw new Error('图片请求不应包含登录 cookie');
logCall({ path: 'artwork', data: { url: u.href } });
const headers = { 'Content-Type': scenario === 'cover-non-image' ? 'text/html' : 'image/png' };
let body = Buffer.from('89504e4700ff1080', 'hex');
if (scenario === 'cover-large-header') headers['Content-Length'] = '10000001';
if (scenario === 'cover-large-stream' || scenario === 'cover-false-length') {
// 多块响应;分别验证缺少/虚报 Content-Length 都不能绕过上限。
body = new ReadableStream({ start(controller) {
controller.enqueue(Buffer.alloc(6_000_000));
controller.enqueue(Buffer.alloc(4_000_001));
controller.close();
} });
if (scenario === 'cover-false-length') headers['Content-Length'] = '8';
}
if (scenario === 'cover-exact-limit') body = Buffer.alloc(10_000_000);
if (scenario === 'cover-empty-image') body = Buffer.alloc(0);
return new Response(body, { headers, status: scenario === 'cover-http-missing' ? 404 : 200 });
}
const form = new URLSearchParams(options.body);
const data = u.pathname.startsWith('/weapi/')
? JSON.parse(decrypt(decrypt(form.get('params'), secret.toString('hex')), '0CoJUm6Qyw8W8jud'))
: Object.fromEntries(form);
const path = u.pathname.replace(/^\/(weapi|api)\//, '');
logCall({ path, data });
let body;
switch (path) {
case 'w/nuser/account/get': body = { profile: { userId: 42 } }; break;
case 'song/like/get':
body = scenario === 'liked-error' ? { code: 405, message: '操作频繁' }
: scenario === 'liked-auth' ? { code: 301 }
: scenario === 'liked-no-ids' ? {}
: scenario === 'liked-bad-ids' ? { ids: ['invalid'] }
: { ids: scenario === 'empty' ? [] : [1000, '1002', 1004] };
break;
case 'radio/trash/add': body = scenario === 'dislike-error' ? { code: -460, message: '拒绝' }
: scenario === 'dislike-auth' ? { code: 301 } : {}; break;
case 'radio/get': body = { data: songs.slice(0, 3) }; break;
case 'song/enhance/player/url/v1':
body = { data: JSON.parse(data.ids).map((id) => ({ id, level: 'lossless',
url: scenario === 'no-urls' || (scenario === 'missing-url' && id === 1004) ? null : `https://audio.invalid/${id}.flac`,
})) };
break;
case 'user/playlist': body = { playlist: rows, more: false }; break;
case 'v6/playlist/detail':
body = { playlist: {
id: +data.id, name: '长歌单', ...withTotal('trackCount', 507),
tracks: songs.slice(0, Math.min(+data.n, 3)),
...(scenario === 'no-track-ids' ? {} : { trackIds: songs.map((s, i) => ({ id: s.id, at: i })) }),
} };
break;
case 'v3/song/detail':
// 详情接口故意乱序/混入额外歌曲,验证调用方确实按所选 ID 还原。
body = { songs: JSON.parse(data.c).map(({ id }) => song(id)).filter((s) => scenario !== 'cover-no-song' && (scenario !== 'missing-song' || s.id !== 1004)).reverse().concat(song(9999)) };
break;
case 'v1/album/1': body = { album: { name: '专辑', ...withTotal('size', 7) }, songs: songs.slice(0, 7) }; break;
case 'v1/artist/1': body = { artist: { name: '歌手', musicSize: 507 }, hotSongs: scenario === 'artist-fallback' ? [] : songs.slice(0, 7) }; break;
case 'artist/top/song': body = { songs: songs.slice(0, 7) }; break;
case 'artist/albums/1': body = { artist: withTotal('albumSize', 7), hotAlbums: page(data) }; break;
case 'search/get/web':
case 'cloudsearch/get/web':
if (path === 'cloudsearch/get/web' && scenario === 'search-fallback') { body = {}; break; }
body = { result: +data.type === 1000
? { playlists: page(data), ...withTotal('playlistCount', 7) }
: { songs: scenario === 'cover-no-song' ? [] : page(data).map((s) => scenario === 'cover-search-no-art'
? { ...s, al: { name: '专辑' }, album: undefined, picUrl: undefined } : s), ...withTotal('songCount', 7) } };
break;
case 'toplist': body = { list: rows, ...withTotal('total', 7) }; break;
case 'album/sublist':
case 'artist/sublist': body = { data: page(data), ...withTotal('count', 7) }; break;
case 'v1/play/record': body = { weekData: rows.map((s) => ({ song: scenario === 'missing-song' && s.id === 1004 ? undefined : s, playCount: 2 })) }; break;
case 'djradio/get/subed':
case 'djradio/recommend/v1': body = { djRadios: rows.slice(0, +data.limit), ...(path.endsWith('subed') ? withTotal('count', 7) : {}) }; break;
case 'dj/program/byradio': body = { programs: page(data).map((s) => ({ id: s.id, mainSong: scenario === 'missing-song' && s.id === 1004 ? undefined : s })), ...withTotal('count', 7) }; break;
case 'dj/program/detail': body = { program: { id: +data.id, mainSong: song(1000) } }; break;
case 'v2/discovery/recommend/songs': body = { data: { dailySongs: songs.slice(0, 7) } }; break;
default: throw new Error(`测试不允许的接口:${path}`);
}
return { status: 200, headers: { getSetCookie: () => [] }, text: async () => JSON.stringify({ code: 200, ...body }) };
};

73
tests/fixtures/mpv-ipc.mjs vendored Normal file
View File

@ -0,0 +1,73 @@
// 离线 mpv IPC:只读写测试目录内的 JSON;禁止真实 socket 与启动播放器。
import net from 'node:net';
import childProcess from 'node:child_process';
import { EventEmitter } from 'node:events';
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { join } from 'node:path';
childProcess.spawn = () => { throw new Error('离线测试禁止启动真实进程/播放器'); };
net.connect = (socket) => {
const file = process.env.NETEASE_TEST_MPV_STATE;
if (!file || socket !== join(process.env.NETEASE_MPV_DIR, 'mpv.sock')) {
throw new Error('离线测试禁止连接真实 mpv socket');
}
const client = new EventEmitter();
client.destroy = () => {};
client.end = () => {};
client.write = (line) => {
const { command, request_id } = JSON.parse(line);
appendFileSync(`${file}.calls`, JSON.stringify(command) + '\n');
const state = JSON.parse(readFileSync(file, 'utf8'));
const [op, property, value] = command;
let data, error = 'success';
const current = () => state.entries[state.pos];
if (state.fail && command.slice(0, state.fail.length).every((v, i) => v === state.fail[i])) {
error = 'fixture rejected command';
} else if (op === 'get_property') {
const props = {
'playlist-pos': state.pos, 'playlist-count': state.entries.length, playlist: state.entries,
'idle-active': !current(), pause: state.pause, 'time-pos': current() ? state.elapsed : null,
duration: current() ? 180 : null, volume: 70,
'media-title': current()?.title || '', path: current()?.filename || '',
'loop-file': state.loopFile, 'loop-playlist': state.loopList, shuffle: state.shuffle,
};
if (property in props) data = props[property];
else error = `unknown fixture property ${property}`;
} else if (op === 'set_property') {
if (property === 'playlist-pos') {
if (!Number.isInteger(value) || value < 0 || value >= state.entries.length) error = 'invalid index';
else { state.pos = value; state.elapsed = 0; }
} else {
const key = { pause: 'pause', shuffle: 'shuffle', 'loop-file': 'loopFile', 'loop-playlist': 'loopList' }[property];
if (key) state[key] = value;
else error = `unknown fixture property ${property}`;
}
} else if (op === 'loadlist') {
const lines = readFileSync(property, 'utf8').trim().split('\n');
const entries = [];
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('#EXTINF:')) entries.push({ title: lines[i].slice(lines[i].indexOf(',') + 1), filename: lines[++i] });
}
state.entries = value === 'append' ? [...state.entries, ...entries] : entries;
if (value !== 'append') { state.pos = entries.length ? 0 : -1; state.elapsed = 0; }
} else if (op === 'playlist-remove') {
if (!Number.isInteger(property) || property < 0 || property >= state.entries.length) error = 'invalid index';
else {
state.entries.splice(property, 1);
if (property < state.pos) state.pos--;
else if (property === state.pos && state.pos >= state.entries.length) state.pos = -1;
}
} else if (op === 'stop') { state.entries = []; state.pos = -1; }
else if (op === 'playlist-clear') {
// mpv 的 playlist-clear 自身会保留正在播放的条目;调用方必须先 stop。
state.entries = current() ? [current()] : [];
state.pos = state.entries.length ? 0 : -1;
} else error = `unknown fixture command ${op}`;
writeFileSync(file, JSON.stringify(state));
queueMicrotask(() => client.emit('data', Buffer.from(JSON.stringify({ request_id, error, data }) + '\n')));
};
queueMicrotask(() => client.emit('connect'));
return client;
};
syncBuiltinESMExports();

569
tests/pagination.test.mjs Normal file
View File

@ -0,0 +1,569 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test, { after } from 'node:test';
const api = fileURLToPath(new URL('../scripts/netease-api.mjs', import.meta.url));
const shell = fileURLToPath(new URL('../scripts/netease-music.sh', import.meta.url));
const fake = fileURLToPath(new URL('./fixtures/api-fetch.mjs', import.meta.url));
const mpv = fileURLToPath(new URL('../scripts/mpv-ctl.mjs', import.meta.url));
// 所有临时文件、配置、队列都限制在技能目录;播放器与后台保活均用离线替身。
const runtime = mkdtempSync(fileURLToPath(new URL('./.runtime-', import.meta.url)));
after(() => rmSync(runtime, { recursive: true, force: true }));
mkdirSync(join(runtime, 'bin'));
writeFileSync(join(runtime, 'bin/nohup'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
Object.assign(process.env, {
TMPDIR: runtime, PI_AGENT_DIR: runtime, NETEASE_MPV_DIR: join(runtime, 'mpv'),
NETEASE_TEST_API_CALLS: '', NETEASE_TEST_MPV_STATE: '', NETEASE_TEST_SCENARIO: '',
PYTHONDONTWRITEBYTECODE: '1', PATH: `${join(runtime, 'bin')}:${process.env.PATH}`,
});
function run(args, scenario = '') {
const p = spawnSync(process.execPath, ['--import', fake, api, ...args], {
encoding: 'utf8', env: { ...process.env, NETEASE_COOKIE: '', NETEASE_TEST_SCENARIO: scenario },
});
assert.equal(p.status, 0, p.stderr);
return { out: JSON.parse(p.stdout), calls: p.stderr.trim().split('\n').filter(Boolean).map((s) => JSON.parse(s)) };
}
const cases = [
{ cmd: 'playlist', arg: '1', key: 'songs', total: 507 },
{ cmd: 'liked', key: 'songs', total: 507, ids: [1503, 1502, 1501] },
{ cmd: 'album', arg: '1', key: 'songs', total: 7 },
{ cmd: 'artist', arg: '1', key: 'songs', total: 7 },
{ cmd: 'chart', arg: '1', key: 'songs', total: 507 },
{ cmd: 'search', arg: '关键词', key: 'songs', total: 7, path: 'search/get/web' },
{ cmd: 'playlists', arg: '关键词', key: 'playlists', total: 7, path: 'cloudsearch/get/web' },
{ cmd: 'charts', key: 'charts', total: 7 },
{ cmd: 'myalbums', key: 'albums', total: 7, path: 'album/sublist' },
{ cmd: 'myartists', key: 'artists', total: 7, path: 'artist/sublist' },
{ cmd: 'recent', key: 'songs', total: 7 },
{ cmd: 'podcasts', key: 'podcasts', total: 7 },
{ cmd: 'podcast', arg: '1', key: 'songs', total: 7, path: 'dj/program/byradio' },
{ cmd: 'myplaylists', key: 'playlists', total: 7 },
{ cmd: 'albums', arg: '1', key: 'albums', total: 7, path: 'artist/albums/1' },
{ cmd: 'daily', key: 'songs', total: 7 },
];
for (const c of cases) {
test(`${c.cmd}: 默认第一页、下一页、越界空页及 total`, () => {
const args = [c.cmd, ...(c.arg ? [c.arg] : []), '--limit', '3'];
const first = run(args).out;
assert.equal(first.count, 3);
assert.equal(first.total, c.total);
if (c.key === 'songs') for (const s of first.songs) assert.equal(s.cover, '');
const { out, calls } = run([...args, '--offset', '3']);
assert.equal(out.count, out[c.key].length);
assert.equal(out.total, c.total);
assert.deepEqual(out[c.key].map((s) => s.id), c.ids || [1003, 1004, 1005]);
assert.ok(!first[c.key].some((a) => out[c.key].some((b) => a.id === b.id)));
if (c.path) {
const request = calls.find((r) => r.path === c.path).data;
assert.equal(+request.offset, 3);
assert.equal(+request.limit, 3);
}
const last = run([...args, '--offset', '10000']);
assert.equal(last.out.count, 0);
assert.deepEqual(last.out[c.key], []);
assert.equal(last.out.total, c.total);
if (['playlist', 'liked', 'chart'].includes(c.cmd)) assert.ok(!last.calls.some((r) => r.path === 'v3/song/detail'));
});
}
test('歌单超过 500 首仍可分页;详情只查询本页,原有顺序不丢', () => {
const { out, calls } = run(['playlist', '1', '--limit', '3', '--offset', '500']);
assert.equal(out.total, 507);
assert.deepEqual(out.songs.map((s) => s.id), [1500, 1501, 1502]);
assert.deepEqual(JSON.parse(calls.find((r) => r.path === 'v3/song/detail').data.c), [{ id: 1500 }, { id: 1501 }, { id: 1502 }]);
});
test('空库也能翻过末尾,total 为 0', () => {
for (const c of cases.filter((c) => c.cmd !== 'liked')) {
const { out } = run([c.cmd, ...(c.arg ? [c.arg] : []), '--limit', '3', '--offset', '3'], 'empty');
assert.equal(out.count, 0, c.cmd);
assert.equal(out.total, 0, c.cmd);
assert.deepEqual(out[c.key], [], c.cmd);
}
});
test('缺总数字段时用接口返回的数量,缺详情仍保留真正 total', () => {
for (const c of cases.filter((c) => c.path || c.cmd === 'album' || c.cmd === 'playlist')) {
const args = [c.cmd, ...(c.arg ? [c.arg] : []), '--limit', '3', '--offset', '3'];
assert.equal(run(args, 'no-total').out.total, c.cmd === 'album' ? 7 : 3, c.cmd);
}
const { out } = run(['playlist', '1', '--limit', '3', '--offset', '3'], 'missing-song');
assert.equal(out.total, 507);
assert.equal(out.count, 2);
assert.deepEqual(out.songs.map((s) => s.id), [1003, 1005]);
assert.deepEqual(run(['playlist', '1', '--limit', '3', '--offset', '3'], 'no-track-ids').out.songs, []);
});
test('最近播放和播客过滤缺失歌曲时 count 只数实际返回行', () => {
for (const cmd of ['recent', 'podcast']) {
const { out } = run([cmd, ...(cmd === 'podcast' ? ['1'] : []), '--limit', '3', '--offset', '3'], 'missing-song');
assert.equal(out.count, 2);
assert.equal(out.total, 7);
}
});
test('推荐播客忽略上游 offset:拉前缀后本地截页,total 回退到前缀长度', () => {
const { out, calls } = run(['podcasts', '--recommend', '--limit', '3', '--offset', '3']);
assert.equal(out.count, 3);
assert.equal(out.total, 6);
assert.deepEqual(out.podcasts.map((p) => p.id), [1003, 1004, 1005]);
assert.equal(calls[0].data.limit, 6);
assert.equal(calls[0].data.offset, 3);
assert.deepEqual(run(['podcasts', '--recommend', '--limit', '3', '--offset', '10000']).out.podcasts, []);
});
test('歌单搜索回退时保留 limit/offset/total;末尾不更换搜索索引', () => {
const { out, calls } = run(['playlists', '关键词', '--limit', '3', '--offset', '3'], 'search-fallback');
assert.equal(out.total, 7);
assert.deepEqual(out.playlists.map((p) => p.id), [1003, 1004, 1005]);
assert.equal(calls.length, 2);
for (const { data } of calls) assert.equal(+data.offset, 3);
assert.equal(run(['playlists', '关键词', '--limit', '3', '--offset', '10000']).calls.length, 1);
});
test('歌手热门旧接口回退也分页,不把全部作品数当热门数', () => {
const { out } = run(['artist', '1', '--limit', '3', '--offset', '3'], 'artist-fallback');
assert.equal(out.total, 7);
assert.deepEqual(out.songs.map((s) => s.id), [1003, 1004, 1005]);
});
test('非法/缺失 offset 在请求前报 usage', () => {
for (const value of ['-1', '1.5', 'no', 'Infinity', '9007199254740992', undefined]) {
const p = spawnSync(process.execPath, ['--import', fake, api, 'charts', '--offset', ...(value ? [value] : [])], { encoding: 'utf8' });
assert.equal(p.status, 2, p.stderr);
assert.equal(JSON.parse(p.stderr).error, 'usage');
}
});
test('只读 shell 入口转发 limit/offset,JSON 带 total,人读样式不增加字段', () => {
const env = { ...process.env, NODE_OPTIONS: `--import=${fake}`, PI_SKILL_NETEASE_MUSIC_COOKIE: 'test', PI_SKILL_NETEASE_MUSIC_QUALITY: 'lossless' };
const args = [shell, 'myplaylists', '--limit', '3', '--offset', '3'];
const human = spawnSync('bash', args, { encoding: 'utf8', env });
assert.equal(human.status, 0, human.stderr);
assert.match(human.stdout, /^我的歌单(3 张)\ncount=3\nsource=api\nas_of=/);
assert.doesNotMatch(human.stdout, /total=/);
const ids = human.stdout.split('\n').filter((s) => /^ \d/.test(s)).map((s) => +s.trim().split(' ')[0]);
assert.deepEqual(ids, [1003, 1004, 1005]);
const json = spawnSync('bash', [...args, '--json'], { encoding: 'utf8', env });
assert.equal(json.status, 0, json.stderr);
assert.equal(JSON.parse(json.stdout).total, '7');
assert.equal(JSON.parse(json.stdout).count, '3');
});
const coverUrl = 'https://artwork.invalid/cover.png';
const coverBytes = Buffer.from('89504e4700ff1080', 'hex');
function coverDir(t) {
const dir = mkdtempSync(fileURLToPath(new URL('./.cover-test-', import.meta.url)));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
function coverShell(args, scenario = 'cover', extraEnv = {}) {
return spawnSync('bash', [shell, 'cover', ...args], {
encoding: 'utf8', env: {
...process.env, NODE_OPTIONS: `--import=${fake}`, NETEASE_TEST_SCENARIO: scenario,
PI_SKILL_NETEASE_MUSIC_COOKIE: 'test', PI_SKILL_NETEASE_MUSIC_QUALITY: 'lossless', ...extraEnv,
},
});
}
function coverError(p, status, error) {
assert.equal(p.status, status, p.stderr);
assert.equal(p.stdout, '');
const lines = p.stderr.trim().split('\n').map((s) => JSON.parse(s));
assert.equal(lines.at(-1).error, error, p.stderr);
assert.equal(typeof lines.at(-1).hint, 'string');
return lines.slice(0, -1);
}
test('歌曲映射保留既有字段顺序,追加 cover 并按 al / album / picUrl 回退', () => {
for (const [scenario, cover] of [['cover', coverUrl], ['cover-album', `${coverUrl}?album`], ['cover-song', `${coverUrl}?song`], ['', '']]) {
const { out } = run(['song', '1000'], scenario);
assert.deepEqual(Object.keys(out), ['id', 'name', 'artist', 'album', 'ms', 'vip', 'cover']);
assert.deepEqual(out, { id: 1000, name: '歌曲 1000', artist: '歌手', album: '专辑', ms: 180000, vip: false, cover });
const search = run(['search', '关键词', '--limit', '1'], scenario).out;
assert.equal(search.songs[0].cover, cover);
}
});
test('cover 按 id / 关键词返回单个 JSON;搜索缺封面时只补查选中的歌曲', () => {
for (const [arg, scenario] of [['1000', 'cover'], ['关键词', 'cover'], ['关键词', 'cover-search-no-art']]) {
const p = coverShell([arg], scenario);
assert.equal(p.status, 0, p.stderr);
assert.deepEqual(JSON.parse(p.stdout), { id: 1000, name: '歌曲 1000', artist: '歌手', cover: coverUrl });
const calls = p.stderr.trim().split('\n').map((s) => JSON.parse(s));
assert.deepEqual(calls.map((c) => c.path), arg === '1000' ? ['v3/song/detail']
: scenario === 'cover-search-no-art' ? ['search/get/web', 'v3/song/detail'] : ['search/get/web']);
if (scenario === 'cover-search-no-art') assert.deepEqual(JSON.parse(calls[1].data.c), [{ id: 1000 }]);
}
});
test('cover --download 保存原始字节和实际大小,查询/下载不改队列或登录文件', (t) => {
const dir = coverDir(t);
const state = join(dir, 'local/skills/netease-music');
mkdirSync(state, { recursive: true });
const originals = {
'config.json': '{"cookie":"MUSIC_U=fixture","quality":"lossless"}',
'queue.json': '[{"id":42}]', 'queue.m3u8': '#EXTM3U\nexisting\n',
};
for (const [name, contents] of Object.entries(originals)) writeFileSync(join(state, name), contents);
const env = { PI_AGENT_DIR: dir, PI_SKILL_NETEASE_MUSIC_COOKIE: '', PI_SKILL_NETEASE_MUSIC_QUALITY: '' };
const query = coverShell(['1000', '--json'], 'cover', env);
assert.equal(query.status, 0, query.stderr);
assert.deepEqual(JSON.parse(query.stdout), { id: 1000, name: '歌曲 1000', artist: '歌手', cover: coverUrl });
const file = join(dir, '封面 bytes.png');
const p = coverShell(['1000', '--download', file], 'cover', env);
assert.equal(p.status, 0, p.stderr);
assert.deepEqual(JSON.parse(p.stdout), { id: 1000, cover: coverUrl, file, bytes: coverBytes.length });
assert.deepEqual(readFileSync(file), coverBytes);
assert.deepEqual(readdirSync(state).sort(), Object.keys(originals).sort());
for (const [name, contents] of Object.entries(originals)) assert.equal(readFileSync(join(state, name), 'utf8'), contents);
assert.deepEqual(readdirSync(dir).sort(), ['local', '封面 bytes.png']);
});
test('cover 下载拒绝覆盖已有文件,--force 才允许替换', (t) => {
const file = join(coverDir(t), 'cover.png');
writeFileSync(file, 'existing image');
coverError(coverShell(['1000', '--download', file]), 2, 'usage');
assert.equal(readFileSync(file, 'utf8'), 'existing image');
const p = coverShell(['1000', '--download', file, '--force']);
assert.equal(p.status, 0, p.stderr);
assert.deepEqual(readFileSync(file), coverBytes);
assert.deepEqual(JSON.parse(p.stdout), { id: 1000, cover: coverUrl, file, bytes: coverBytes.length });
});
test('cover 缺歌曲/封面时返回 notfound JSON,不下载、不落盘', (t) => {
const file = join(coverDir(t), 'cover.png');
for (const scenario of ['cover-no-song', 'cover-missing']) {
for (const arg of ['1000', '关键词']) {
const calls = coverError(coverShell([arg, '--download', file], scenario), 4, 'notfound');
assert.ok(!calls.some((c) => c.path === 'artwork'));
assert.equal(existsSync(file), false);
}
}
});
test('cover 下载限制声明/实际字节数,失败不截断已有文件,恰好 10 MB 允许', (t) => {
const file = join(coverDir(t), 'cover.png');
for (const scenario of ['cover-large-header', 'cover-large-stream', 'cover-false-length']) {
coverError(coverShell(['1000', '--download', file], scenario), 5, 'external');
assert.equal(existsSync(file), false);
writeFileSync(file, 'existing image');
coverError(coverShell(['1000', '--download', file, '--force'], scenario), 5, 'external');
assert.equal(readFileSync(file, 'utf8'), 'existing image');
rmSync(file);
}
const p = coverShell(['1000', '--download', file], 'cover-exact-limit');
assert.equal(p.status, 0, p.stderr);
assert.equal(JSON.parse(p.stdout).bytes, 10_000_000);
assert.equal(readFileSync(file).length, 10_000_000);
});
test('cover 空图片/404/非图片响应为错误 JSON,不创建目标文件', (t) => {
const file = join(coverDir(t), 'cover.png');
for (const [scenario, status, error] of [['cover-empty-image', 4, 'notfound'], ['cover-http-missing', 4, 'notfound'], ['cover-non-image', 5, 'external']]) {
coverError(coverShell(['1000', '--download', file], scenario), status, error);
assert.equal(existsSync(file), false);
}
});
test('cover 非法/缺失参数在请求前报 usage JSON', () => {
for (const args of [[], ['1000', '--download'], ['1000', '--download', ''], ['1000', '--download', '--force'], ['1000', '--force'], ['1000', '--unknown']]) {
assert.deepEqual(coverError(coverShell(args), 2, 'usage'), []);
}
});
function jsonLines(file) {
return existsSync(file) ? readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : [];
}
function player(t, ids = [1000, 1001, 1002], initial = {}) {
const dir = mkdtempSync(join(runtime, 'player-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const local = join(dir, 'local/skills/netease-music');
mkdirSync(local, { recursive: true });
const stateFile = join(dir, 'mpv.json');
const songs = ids.map((id) => ({ id, name: `歌曲 ${id}`, artist: '歌手', album: '专辑', ms: 180000, vip: false, cover: '', level: 'lossless' }));
const entries = ids.map((id) => ({ title: `[#${id}] 歌手 - 歌曲 ${id}`, filename: `https://audio.invalid/${id}.flac` }));
writeFileSync(stateFile, JSON.stringify({ entries, pos: ids.length ? 1 % ids.length : -1, pause: false,
elapsed: 37, shuffle: false, loopFile: 'no', loopList: 'no', ...initial }));
writeFileSync(join(local, 'config.json'), '{"cookie":"fixture unchanged"}');
writeFileSync(join(local, 'queue.json'), JSON.stringify({ songs, skipped: 0, source: 'fixture' }));
writeFileSync(join(local, 'songs.json'), JSON.stringify(songs));
writeFileSync(join(local, 'queue.m3u8'), '#EXTM3U\n' + entries.map((e) => `#EXTINF:180,${e.title}\n${e.filename}\n`).join(''));
writeFileSync(join(local, 'urls.txt'), ids.map((id, i) => `${id}\tlossless\t${entries[i].filename}\n`).join(''));
writeFileSync(join(local, 'urls_at'), '123');
const env = { ...process.env, NODE_OPTIONS: `--import=${fake}`, NETEASE_COOKIE: '',
PI_AGENT_DIR: dir, NETEASE_MPV_DIR: local, NETEASE_TEST_MPV_STATE: stateFile,
NETEASE_TEST_API_CALLS: join(dir, 'api.calls'), NETEASE_TEST_SCENARIO: '',
PI_SKILL_NETEASE_MUSIC_COOKIE: 'test', PI_SKILL_NETEASE_MUSIC_QUALITY: 'lossless' };
return {
dir, local, stateFile,
state: () => JSON.parse(readFileSync(stateFile, 'utf8')),
calls: () => jsonLines(`${stateFile}.calls`),
apiCalls: () => jsonLines(env.NETEASE_TEST_API_CALLS),
queue: () => JSON.parse(readFileSync(join(local, 'queue.json'), 'utf8')),
run: (args, scenario = '') => spawnSync('bash', [shell, ...args], { encoding: 'utf8', env: { ...env, NETEASE_TEST_SCENARIO: scenario }, timeout: 15000 }),
mpv: (args) => spawnSync(process.execPath, [mpv, ...args], { encoding: 'utf8', env, timeout: 5000 }),
};
}
function okJson(p) {
assert.equal(p.status, 0, `${p.stderr}\n${p.stdout}`);
return JSON.parse(p.stdout);
}
function apiFailure(args, scenario, code, kind) {
const p = spawnSync(process.execPath, ['--import', fake, api, ...args], {
encoding: 'utf8', env: { ...process.env, NETEASE_COOKIE: '', NETEASE_TEST_SCENARIO: scenario },
});
return coverError(p, code, kind);
}
function localFiles(p) {
return Object.fromEntries(['queue.json', 'queue.m3u8', 'songs.json', 'urls.txt', 'urls_at', 'config.json']
.map((file) => [file, existsSync(join(p.local, file)) ? readFileSync(join(p.local, file), 'utf8') : null]));
}
const writes = (calls) => calls.filter(([op]) => op !== 'get_property');
test('repeat 三模式切换清掉另一个循环属性;on/无参数保持整列循环,状态兼容旧布尔字段', (t) => {
const p = player(t);
for (const [arg, mode] of [['single', 'single'], ['list', 'list'], ['single', 'single'], ['off', 'off'], ['on', 'list'], [undefined, 'list']]) {
const out = okJson(p.mpv(['repeat', ...(arg ? [arg] : [])]));
assert.equal(out.repeat_mode, mode);
assert.equal(out.repeat, mode === 'list');
const state = p.state();
assert.equal(state.loopFile, mode === 'single' ? 'inf' : 'no');
assert.equal(state.loopList, mode === 'list' ? 'inf' : 'no');
const status = okJson(p.mpv(['status']));
assert.equal(status.repeat_mode, mode);
assert.equal(status.repeat, mode === 'list');
}
const out = okJson(p.run(['repeat', 'single', '--json']));
assert.equal(out.repeat_mode, 'single');
assert.equal(out.repeat, 'false');
assert.equal(out.song_id, '1001');
const human = p.run(['status']);
assert.equal(human.status, 0, human.stderr);
assert.match(human.stdout, /^正在播放:歌曲 1001 - 歌手\n/);
assert.match(human.stdout, /\nrepeat_mode=single\n/);
assert.deepEqual(p.apiCalls(), []);
});
test('repeat/remove/clear 参数错误在 IPC 前拒绝;mpv 拒绝变更不能回成功', (t) => {
const p = player(t);
for (const args of [['repeat', 'bogus'], ['repeat', 'list', 'single'], ['remove'], ['remove', '0'], ['remove', '-1'], ['remove', '1.2'], ['remove', '9007199254740992'], ['clear', 'extra']]) {
const result = p.mpv(args);
assert.equal(result.status, 2, result.stderr);
assert.equal(JSON.parse(result.stderr).error, 'usage');
}
assert.deepEqual(p.calls(), []);
assert.equal(p.run(['repeat', 'list', 'single']).status, 2);
assert.deepEqual(p.calls(), []);
const rejected = player(t, [1000, 1001], { fail: ['set_property', 'loop-file'] });
assert.equal(rejected.run(['repeat', 'single']).status, 5);
});
test('play-ids 批量详情保持输入顺序和重复项,分批查询;默认/指定 start 从 1 开始', () => {
const { out, calls } = run(['play-ids', '1002,1000,1002', '--start', '3']);
assert.deepEqual(out.songs.map((s) => s.id), [1002, 1000, 1002]);
assert.equal(out.start, 3);
assert.equal(out.count, 3);
assert.equal(out.total, 3);
assert.equal(calls.length, 1);
assert.equal(run(['play-ids', '1000']).out.start, 1);
const ids = Array.from({ length: 205 }, (_, i) => i + 1000);
const batch = run(['play-ids', ids.join(',')]);
assert.deepEqual(batch.out.songs.map((s) => s.id), ids);
assert.equal(batch.calls.length, 2);
});
test('play-ids 非法 id/start 在请求前失败,缺详情不会退化成错误队列', () => {
for (const args of [[], [''], ['1000,'], [',1000'], ['1000,,1001'], ['1000', '1001'], ['0'], ['-1'], ['1.2'], ['1000, 1001'], ['9007199254740992'],
['1000', '--start'], ['1000', '--start', '0'], ['1000', '--start', '2'], ['1000', '--start', '-1'], ['1000', '--start', '1.5'], ['1000', '--start', 'Infinity'], ['1000', '--start', '1e0']]) {
assert.deepEqual(apiFailure(['play-ids', ...args], '', 2, 'usage'), [], args.join(' '));
}
assert.equal(apiFailure(['play-ids', '1000,1004'], 'missing-song', 4, 'notfound').length, 1);
});
test('play-ids 替换并从指定歌曲起播;不可播的前项跳过后仍对齐原序号,保留上一条快照', (t) => {
const p = player(t, [1010, 1011], { shuffle: true });
const out = okJson(p.run(['play-ids', '1004,1001,1000', '--start', '3', '--json'], 'missing-url'));
assert.equal(out.start, '3');
assert.equal(out.song_id, '1000');
assert.equal(out.queue, '2');
assert.match(out.skipped, /^1/);
assert.deepEqual(p.queue().songs.map((s) => s.id), [1001, 1000]);
assert.equal(p.state().pos, 1);
assert.equal(p.state().pause, false);
assert.equal(p.state().shuffle, false);
assert.equal(p.state().entries.length, 2);
assert.deepEqual(JSON.parse(readFileSync(join(p.local, 'queues/prev.json'))).songs.map((s) => s.id), [1010, 1011]);
const cmds = writes(p.calls());
assert.deepEqual(cmds.slice(0, 5).map((c) => c.slice(0, 2)), [
['set_property', 'shuffle'], ['set_property', 'pause'], ['loadlist', join(p.local, 'queue.m3u8')], ['set_property', 'playlist-pos'], ['set_property', 'pause'],
]);
assert.equal(cmds[1][2], true);
assert.equal(readFileSync(join(p.local, 'config.json'), 'utf8'), '{"cookie":"fixture unchanged"}');
const duplicates = okJson(p.run(['play-ids', '1000,1001,1000', '--start', '3', '--json']));
assert.equal(duplicates.song_id, '1000');
assert.equal(p.state().pos, 2);
assert.equal(p.state().entries.length, 3);
okJson(p.run(['play-ids', '1002,1001', '--json']));
assert.equal(p.state().pos, 0);
});
test('play-ids 选中项无地址、无详情或参数错误时不替换队列、不发播放 IPC', (t) => {
for (const [args, scenario, status] of [
[['1000,1004', '--start', '2'], 'missing-url', 4], [['1000'], 'no-urls', 4],
[['1004'], 'missing-song', 4], [['1000,1001', '--start', '3'], '', 2],
[['1000', '--start'], '', 2], [['1000', '--shuffle'], '', 2],
]) {
const p = player(t);
const before = localFiles(p), state = p.state();
const result = p.run(['play-ids', ...args], scenario);
assert.equal(result.status, status, result.stderr);
assert.equal(result.stdout, '');
assert.deepEqual(localFiles(p), before);
assert.deepEqual(p.state(), state);
assert.deepEqual(writes(p.calls()), []);
assert.ok(!existsSync(join(p.local, 'queues/prev.json')));
}
});
test('remove 按显示顺序移除,当前项选下一首/末尾选上一首,保留暂停;空队列停止', (t) => {
for (const c of [
{ ids: [1000, 1001, 1002], pos: 1, remove: 2, want: [1000, 1002], current: 1002, elapsed: 0 },
{ ids: [1000, 1001, 1002], pos: 2, remove: 3, want: [1000, 1001], current: 1001, elapsed: 0, pause: true },
{ ids: [1000, 1001, 1002], pos: 1, remove: 1, want: [1001, 1002], current: 1001, elapsed: 37 },
{ ids: [1000, 1001, 1002], pos: 1, remove: 3, want: [1000, 1001], current: 1001, elapsed: 37 },
{ ids: [1000, 1001, 1000], pos: 0, remove: 3, want: [1000, 1001], current: 1000, elapsed: 37 },
{ ids: [1000], pos: 0, remove: 1, want: [], current: undefined },
{ ids: [1000], pos: 0, remove: 1, want: [], current: undefined, pause: true },
]) {
const p = player(t, c.ids, { pos: c.pos, pause: !!c.pause, loopFile: 'inf' });
const out = okJson(p.run(['remove', String(c.remove), '--json']));
assert.equal(out.removed, String(c.remove));
assert.deepEqual(p.queue().songs.map((s) => s.id), c.want);
assert.equal(p.state().entries.length, c.want.length);
assert.equal(p.state().pause, !!c.pause);
assert.equal(p.state().loopFile, 'inf');
assert.equal(out.song_id, c.current === undefined ? undefined : String(c.current));
if (c.current !== undefined) assert.equal(p.state().elapsed, c.elapsed);
else { assert.equal(p.state().pos, -1); assert.equal(out.state, 'idle'); }
assert.deepEqual(JSON.parse(readFileSync(join(p.local, 'songs.json'))).map((s) => s.id), c.want);
assert.equal(readFileSync(join(p.local, 'queue.m3u8'), 'utf8').split('\n').filter((s) => s.startsWith('https:')).length, c.want.length);
assert.equal(readFileSync(join(p.local, 'urls.txt'), 'utf8').trim().split('\n').filter(Boolean).length, c.want.length);
assert.deepEqual(p.apiCalls(), []);
}
});
test('remove 开随机后使用 mpv 的队列序号;queue/status 与本地文件一致', (t) => {
const p = player(t, [1000, 1001, 1002]);
const state = p.state();
state.entries = [state.entries[2], state.entries[0], state.entries[1]];
state.pos = 1; state.shuffle = true;
writeFileSync(p.stateFile, JSON.stringify(state));
const out = okJson(p.run(['remove', '1', '--json']));
assert.equal(out.song_id, '1000');
assert.equal(out.queue_pos, '1/2');
assert.equal(out.shuffle, 'true');
assert.deepEqual(p.queue().songs.map((s) => s.id), [1000, 1001]);
const queue = p.run(['queue']);
assert.equal(queue.status, 0, queue.stderr);
assert.match(queue.stdout, /1\. 歌手 - 歌曲 1000.*正在放/);
assert.doesNotMatch(queue.stdout, /1002/);
});
test('remove 越界/错误参数或 IPC 拒绝不改本地文件,clear 被拒绝也不删文件', (t) => {
for (const args of [['remove'], ['remove', '0'], ['remove', '-1'], ['remove', '1.5'], ['remove', '4'], ['remove', '1', '2'], ['clear', 'extra']]) {
const p = player(t), before = localFiles(p);
assert.ok([2, 4].includes(p.run(args).status), args.join(' '));
assert.deepEqual(localFiles(p), before);
assert.deepEqual(writes(p.calls()), []);
}
for (const [args, fail] of [[['remove', '3'], ['playlist-remove']], [['clear'], ['stop']]]) {
const p = player(t, [1000, 1001, 1002], { fail }), before = localFiles(p);
const result = p.run(args);
assert.equal(result.status, 5, result.stderr);
assert.deepEqual(localFiles(p), before);
assert.equal(result.stdout, '');
}
});
test('clear 停止并清空 mpv、本地队列和保活,保留配置与快照,重复调用成功', (t) => {
const p = player(t, [1000, 1001, 1002], { pause: true });
mkdirSync(join(p.local, 'queues'));
writeFileSync(join(p.local, 'queues/saved.json'), '{"songs":[]}');
writeFileSync(join(p.local, 'keepalive.pid'), 'not-a-pid');
for (let i = 0; i < 2; i++) {
const out = okJson(p.run(['clear', '--json']));
assert.equal(out.state, 'idle');
assert.equal(out.song_id, undefined);
assert.equal(out.queue_pos, undefined);
assert.deepEqual(p.state().entries, []);
assert.equal(p.state().pos, -1);
}
for (const file of ['queue.json', 'queue.m3u8', 'songs.json', 'urls.txt', 'urls_at', 'keepalive.pid']) assert.equal(existsSync(join(p.local, file)), false, file);
assert.equal(readFileSync(join(p.local, 'config.json'), 'utf8'), '{"cookie":"fixture unchanged"}');
assert.equal(readFileSync(join(p.local, 'queues/saved.json'), 'utf8'), '{"songs":[]}');
assert.deepEqual(writes(p.calls()), [['stop'], ['playlist-clear'], ['stop'], ['playlist-clear']]);
assert.deepEqual(p.apiCalls(), []);
});
test('所有歌曲列表/搜索/详情透传布尔 liked,缺失/未知时不伪造 false;liked 列表明确为 true', () => {
const args = [
['song', '1000'], ['song', '1001'], ['song', '关键词'], ['search', '关键词'], ['playlist', '1'],
['daily'], ['artist', '1'], ['album', '1'], ['chart', '1'], ['recent'], ['fm'], ['podcast', '1'], ['episode', '1'], ['play-ids', '1000,1001'],
];
for (const command of args) {
const out = run([...command, '--limit', '2'], 'liked-flags').out;
for (const song of out.songs || [out]) assert.equal(song.liked, song.id % 2 === 0, command.join(' '));
const unknown = run([...command, '--limit', '2'], 'liked-unknown');
for (const song of unknown.out.songs || [unknown.out]) assert.equal(Object.hasOwn(song, 'liked'), false);
assert.ok(!unknown.calls.some((c) => c.path === 'song/like/get'));
}
const liked = run(['liked', '--limit', '2']).out;
assert.deepEqual(liked.songs.map((s) => s.liked), [true, true]);
assert.deepEqual(Object.keys(run(['song', '1000'], 'liked-flags').out), ['id', 'name', 'artist', 'album', 'ms', 'vip', 'cover', 'liked']);
});
test('liked-ids 返回完整数值 ID 列表和空表,不把鉴权/限流/坏响应当成未喜欢', (t) => {
const { out, calls } = run(['liked-ids', '--limit', '1', '--offset', '9']);
assert.deepEqual(out, { count: 3, total: 3, ids: [1000, 1002, 1004] });
assert.deepEqual(calls.map((c) => c.path), ['song/like/get']);
assert.deepEqual(run(['liked-ids'], 'empty').out, { count: 0, total: 0, ids: [] });
assert.deepEqual(apiFailure(['liked-ids', 'extra'], '', 2, 'usage'), []);
for (const [scenario, code, error] of [['liked-error', 5, 'external'], ['liked-auth', 3, 'auth'], ['liked-no-ids', 5, 'external'], ['liked-bad-ids', 5, 'external']]) apiFailure(['liked-ids'], scenario, code, error);
const p = player(t), before = localFiles(p);
assert.deepEqual(okJson(p.run(['liked-ids', '--json'])), { count: 3, total: 3, ids: [1000, 1002, 1004] });
assert.match(p.run(['liked-ids']).stdout, /^我喜欢的歌曲 ID(3 首)\ncount=3\nids=1000,1002,1004\n/);
assert.deepEqual(localFiles(p), before);
assert.deepEqual(p.calls(), []);
});
test('dislike 映射 fm/trash,校验 API 返回码和参数;只上报 ban,不喜欢/取消喜欢', () => {
const { out, calls } = run(['dislike', '1001']);
assert.deepEqual(out, { id: 1001, disliked: true, code: 200 });
assert.deepEqual(calls, [{ path: 'radio/trash/add', data: { songId: 1001, time: 25, alg: 'RT', csrf_token: '' } }]);
for (const args of [[], ['0'], ['-1'], ['abc'], ['1.5'], ['Infinity'], ['9007199254740992'], ['1000', '1001']]) assert.deepEqual(apiFailure(['dislike', ...args], '', 2, 'usage'), []);
apiFailure(['dislike', '1001'], 'dislike-error', 5, 'external');
apiFailure(['dislike', '1001'], 'dislike-auth', 3, 'auth');
});
test('dislike 支持当前歌曲或显式 id,按歌曲身份取当前项,不改队列/播放/配置;无当前项则报错', (t) => {
const p = player(t);
const state = p.state();
state.entries.reverse(); state.pos = 0;
writeFileSync(p.stateFile, JSON.stringify(state));
const before = localFiles(p);
assert.equal(okJson(p.run(['dislike', '--json'])).song_id, '1002');
assert.equal(okJson(p.run(['dislike', '1000', '--json'])).song_id, '1000');
assert.equal(p.run(['dislike', '1000'], 'dislike-error').status, 5);
assert.deepEqual(localFiles(p), before);
assert.deepEqual(p.state(), state);
assert.deepEqual(writes(p.calls()), []);
assert.deepEqual(p.apiCalls().map((c) => c.path), ['radio/trash/add', 'radio/trash/add', 'radio/trash/add']);
const empty = player(t, []);
assert.equal(empty.run(['dislike']).status, 4);
assert.deepEqual(empty.apiCalls(), []);
assert.equal(empty.run(['dislike', '1000']).status, 0);
});