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