commit 56f3e4ff2bbb4d7732380f685c7dea0d64ce5406 Author: Kinneyzhang Date: Fri Oct 2 07:26:46 2026 +0800 release v0.1.0 diff --git a/REFERENCE.md b/REFERENCE.md new file mode 100644 index 0000000..29033d4 --- /dev/null +++ b/REFERENCE.md @@ -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=`,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/` | 歌手热门 50 首 | 回 `hotSongs`;老接口 `weapi/artist/top/song` 给的是同一份榜单 | +| `weapi/artist/albums/` | 歌手的专辑列表 | 回 `hotAlbums`(含搜索索引里没有的老专辑);按时间倒序,`limit` 给 60 才够到经典专辑 | +| `weapi/v1/album/` | 专辑曲目 | 回 `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:,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=/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 [--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 [--start N]` 返回 `{count,total,start,songs:[...]}`,`start` 仍为输入列表的 1 基序号;它不连接 mpv,也不写队列。 + +**编辑队列**:`remove ` 按 `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 标题写成 `[#] 歌手 - 歌名`(`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`(默认 `/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。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..b41107e --- /dev/null +++ b/SKILL.md @@ -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 用 `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 [--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 `(网易搜索索引会缺正版) | +| `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`。 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/contract.lock.json b/contract.lock.json new file mode 100644 index 0000000..ab1c3f6 --- /dev/null +++ b/contract.lock.json @@ -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": [] +} diff --git a/interface.json b/interface.json new file mode 100644 index 0000000..bbe96db --- /dev/null +++ b/interface.json @@ -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 [--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 :按 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 镜像仓(生成物,不在镜像仓手改)" + } + ] +} diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh new file mode 100755 index 0000000..b33f47b --- /dev/null +++ b/scripts/install-cli.sh @@ -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;链接 /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' diff --git a/scripts/lib/build-queue.py b/scripts/lib/build-queue.py new file mode 100644 index 0000000..812b1c8 --- /dev/null +++ b/scripts/lib/build-queue.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""build-queue — 把歌曲列表 + 播放地址合成 mpv 队列(内部原语,被 netease-music.sh 调用) + +用法: build-queue.py [start,从 1 开始] + songs.json: [{"id","name","artist","ms"}, ...] + urls.txt: 每行 "\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()) diff --git a/scripts/lib/mpv-map.py b/scripts/lib/mpv-map.py new file mode 100644 index 0000000..72309bf --- /dev/null +++ b/scripts/lib/mpv-map.py @@ -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 标题写成 "[#] <歌手> - <歌名>",mpv 的 media-title / 条目 title 原样带回; + 2. 条目 filename 是取地址那一刻的 URL,可对照 urls.txt(\\t<档位>\\t,按去 query 的路径比)。 +旧格式标题(没有 [#id])依次用 URL、标题串「歌手 - 歌名」回退;都对不上退出 3。 + +用法: + mpv-map.py now # → 当前歌曲 JSON(mpv status 的输出) + mpv-map.py index <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()) diff --git a/scripts/lib/queue-append.py b/scripts/lib/queue-append.py new file mode 100644 index 0000000..f744193 --- /dev/null +++ b/scripts/lib/queue-append.py @@ -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()) diff --git a/scripts/lib/queue-edit.py b/scripts/lib/queue-edit.py new file mode 100644 index 0000000..acfbe55 --- /dev/null +++ b/scripts/lib/queue-edit.py @@ -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) diff --git a/scripts/lib/snapshot.py b/scripts/lib/snapshot.py new file mode 100644 index 0000000..e7649fd --- /dev/null +++ b/scripts/lib/snapshot.py @@ -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()) diff --git a/scripts/mpv-ctl.mjs b/scripts/mpv-ctl.mjs new file mode 100644 index 0000000..9adcf0c --- /dev/null +++ b/scripts/mpv-ctl.mjs @@ -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)); diff --git a/scripts/netease-api.mjs b/scripts/netease-api.mjs new file mode 100644 index 0000000..f67161b --- /dev/null +++ b/scripts/netease-api.mjs @@ -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); +} diff --git a/scripts/netease-music.sh b/scripts/netease-music.sh new file mode 100755 index 0000000..e498015 --- /dev/null +++ b/scripts/netease-music.sh @@ -0,0 +1,1157 @@ +#!/usr/bin/env bash +# netease-music — 直接调接口放网易云音乐(不再模拟鼠标点界面) +# +# 架构(三层,各层可单独验证): +# 取数 scripts/netease-api.mjs 直连 music.163.com 的 weapi(自包含加密,零 npm 依赖) +# 播放 scripts/mpv-ctl.mjs 常驻 mpv + unix socket JSON IPC(状态是真值,不靠截屏猜) +# 入口 本脚本 把「用户意图」翻译成上面两层的调用 +# +# 实测结论(勿改): +# * 搜索必须走明文接口 /api/search/get/web:weapi 版 cloudsearch 对匿名请求回空体(HTTP 200 空)。 +# * weapi 的 aesEncrypt 输出是 base64,不是 hex(用 hex 会得到 HTTP 200 空响应)。 +# * mpv 的 `cycle pause` 会报 invalid parameter,改 set_property。 +# * mpv 的 shuffle 会重排它自己的播放顺序:playlist-pos 不能直接索引 queue.json(实测会报错歌, +# status 说在放《野心家》、实际在放《All Of Me》);映射一律走 scripts/lib/mpv-map.py(标题 [#id] → URL → 名字)。 +# * 登录态 = MUSIC_U cookie,来自扫码登录的 Set-Cookie;存 $NETEASE_MUSIC_STATE_DIR/config.json。 +set -uo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# 网易云是国内服务:显式绕开 shell 里的代理变量。 +# 否则 sing-box 一关(7890 端口没了),接口和 mpv/ffmpeg 取音频流会一起挂。 +# node 本来就不认这些变量,但 mpv/ffmpeg/curl 认,所以必须显式清掉。 +unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy 2>/dev/null || true +export NO_PROXY="music.163.com,.163.com,.126.net,.127.net,.heiyu.space,localhost,127.0.0.1${NO_PROXY:+,$NO_PROXY}" +export no_proxy="$NO_PROXY" +# 状态目录(cookie/队列/缓存):默认跟在技能目录旁;standalone 用户用 NETEASE_MUSIC_STATE_DIR 指走。 +LOCAL_DIR="${NETEASE_MUSIC_STATE_DIR:-${PI_AGENT_DIR:-$HOME/.pi/agent}/local/skills/netease-music}" +CFG="$LOCAL_DIR/config.json" +API="$SKILL_DIR/scripts/netease-api.mjs" +MPVCTL="$SKILL_DIR/scripts/mpv-ctl.mjs" +QUEUE="$LOCAL_DIR/queue.json" +M3U="$LOCAL_DIR/queue.m3u8" + +JSON=0; QUIET=0 +RESULT_KEYS=(); RESULT_VALS=() + +usage() { + cat <<'EOF' +netease-music — 网易云音乐:直接调接口播放与控制(不点界面) + +用法:netease-music <命令> [参数] [--json] [--quiet] + +播放: + daily [--limit N] [--shuffle] 每日推荐,整列入队开播 + play <关键词|歌曲id|歌单id|歌单链接> [--shuffle] [--limit N] + 关键词取最匹配的一首;给歌单/歌手/专辑链接都能放 + play-ids <id,id,...> [--start N] 按输入顺序替换队列,N 从 1 开始(默认 1;单个逗号分隔参数) + search <关键词> [--limit N] 只看不播:列出搜索结果 + cover <歌曲关键词|id> [--download PATH] [--force] + 封面 JSON;可下载至 PATH(最多 10 MB,覆盖需 --force) + liked [--shuffle] 我喜欢的音乐(整列入队) + liked-ids [--json] 只读喜欢 ID 全表;JSON 为 {count,total,ids:[数字,...]} + artist <歌手|id> [--limit N] 歌手热门整列入队(默认 50 首) + album <专辑|id> [--limit N] 专辑整张按顺序入队(搜不到正版先用 albums 拿 id) + myplaylists [collected|own] [--limit N] [--offset N] + 我的歌单(含「我喜欢的音乐」id;collected 只看收藏的) + albums <歌手|id> [--limit N] 只看不播:列出歌手的专辑(含搜索里搜不到的老专辑) +歌单与榜单: + playlist <歌单名|id|链接> [--shuffle] 放一张歌单(别人的也行,名字直接写) + playlists <关键词> [--limit N] 只看不播:搜歌单(含作者、是否已收藏) + charts [--limit N] 只看不播:列排行榜(飙升榜/新歌榜/…) + chart <榜名|id> [--shuffle] 放某个榜单(例:chart 飙升榜) + myalbums / myartists [--limit N] 只看不播:我收藏的专辑 / 歌手 + fm [--limit N] [--shuffle] 私人 FM(没听过的) + dislike [歌曲id] 私人 FM 不喜欢;省略 id 用当前歌曲,只上报,不自动切歌 + recent [--limit N] 只看不播:最近播放 + replay [N] 再放一次最近第 N 首(默认 1) + podcasts [--recommend] [--limit N] 只看不播:我的播客订阅(--recommend 看推荐的) + podcast <播客名|id> [--limit N] 放某播客最新 N 期(默认 3) + episode <节目id> 放某一期(id 从 podcast 列表里抄) +队列现场: + queue [--limit N] 看队列:每首歌名、时长,标出正在放的那首 + jump <N> 跳到队列第 N 首(从 1 开始) + add <歌曲|id> 把一首加到队尾(不动正在放的那首) + remove <N> 删除 queue 显示的第 N 首;当前项选下一首,末尾选上一首,删空停止 + clear 停止并清空队列及本地队列文件(保留快照) + queues 看本地的队列快照(每次换队列会自动留一条「上一条」) + queue-save [名字] 把当前队列存成命名快照 + queue-load <名字> 载入某个快照(当前这条会留成「上一条」) + back 切回上一条队列(含当时放到哪儿) +控制: + pause / resume / toggle 暂停 / 继续 / 切换 + next / prev / stop 下一首 / 上一首 / 停止 + volume [0-130] 不填=读当前音量,填了=设置 + seek <秒|+n|-n> 跳进度(相对) + status 当前播放状态(mpv 真值 + 曲目信息) + lyric [--all] 当前这首歌的歌词(默认只显示当前句与下一句) + like / unlike [--unlike] 喜欢 / 取消喜欢当前这首歌 + shuffle on|off 随机开关 + repeat off|list|single 关闭 / 整列循环 / 单曲循环(on = list);status 带 repeat_mode +配置: + setup [--cookie 值] [--quality 级别] 写配置(不带参数=告诉你登录的两种办法) + login [--timeout 300] [--refresh 4] 扫码登录(码 5 分钟过期,自动换新;cookie 只存本机) + login --sms <手机号> [--sms-code <验证码>] 备用:短信验证码登录(两步) + check 自检:依赖 / 登录态 / mpv / 音质 + help [命令] + +输出:首行是人读结论,随后 key=value;读取类命令都带 source=api 与 as_of=<ISO 时刻>(说清数据来自哪个接口、什么时候取的)。 + cover 始终只输出一个 JSON 对象;liked-ids --json 输出完整数字 ID 数组(均不改变队列或登录态)。 +旗标:--json 输出 JSON;--quiet 不打印人读结论行 +分页:列表用 --limit N --offset N;offset 默认 0,跳过 N 条;后端 JSON 的 count 是本页条数,total 是总数。 + 适用 daily / playlist / liked / album / artist / albums / chart / search / playlists / myplaylists / charts / myalbums / myartists / recent / podcasts / podcast。 +环境变量(或 local/skills/netease-music/config.json): + NETEASE_MUSIC_STATE_DIR 状态目录(cookie/队列/缓存,默认 ~/.local/state/netease-music) + NETEASE_MUSIC_COOKIE 登录 cookie(MUSIC_U=…) + NETEASE_MUSIC_QUALITY 音质:standard/higher/exhigh/lossless/hires(默认 lossless) +退出码:0 成功;2 用法错;3 缺依赖或未登录;4 找不到歌曲/歌单;5 执行失败 +EOF +} + +cmd_help() { + case "${1:-}" in + daily) echo "daily — 拉今日「每日推荐」整列入队播放;--limit N 只取前 N 首,--shuffle 随机。" ;; + play) echo "play <关键词|id|歌单链接> — 关键词→搜最匹配一首;纯数字→歌曲 id;歌单链接或 playlist:<id>→整张列表。会跳过无版权/VIP 不可播的。" ;; + play-ids) echo "play-ids <id,id,...> [--start N] — id 列表是单个无空格逗号分隔参数,顺序与重复项保留;N 为输入列表的 1 基序号,默认 1。不支持 --shuffle;指定歌曲不可播/详情缺失时报错,旧队列不变;其他无地址项跳过。例:play-ids 1000,1001,1002 --start 2。" ;; + search) echo "search <关键词> [--limit N] — 只列出结果(id / 歌名 / 歌手 / 时长 / 是否需 VIP),不播放。" ;; + cover) echo "cover <歌曲关键词|id> [--download PATH] [--force] — 输出 {id,name,artist,cover};下载输出 {id,cover,file,bytes},最多 10 MB(10000000 字节),覆盖需 --force;不改变队列或登录态。" ;; + liked) echo "liked — 把「我喜欢的音乐」整列入队播放。" ;; + liked-ids) echo "liked-ids [--json] — 只读喜欢 ID 全表;--json 为 {count,total,ids:[数字,...]},不分页。歌曲 JSON 有明确布尔 liked 才透传,缺字段时用这张全表按 id 判定。" ;; + myplaylists) echo "myplaylists [collected|own] [--limit N] [--offset N] — 分页列出我的歌单;collected/own 在本页内筛选。" ;; + artist) echo "artist <歌手|id> [--limit N] — 歌手热门整列入队(默认 50 首;也可直接给数字 id,如 6452=周杰伦)。" ;; + album) echo "album <专辑|id> [--limit N] — 专辑整张按顺序入队(默认 200 首)。搜不到正版专辑时先跑 albums <歌手> 拿 id。" ;; + albums) echo "albums <歌手|id> [--limit N] — 只列出这位歌手的专辑(id / 专辑名 / 年份 / 曲目数),不播放。" ;; + queue) echo "queue [--limit N] — 打印队列(mpv 真值):每首序号、歌名、时长,正在放的那首带 ← 正在放。" ;; + jump) echo "jump <N> — 跳到队列第 N 首(从 1 开始),并回读状态确认。" ;; + remove) echo "remove <N> — 按 queue 显示的 1 基序号删除;删除当前项选下一首,末尾选上一首,删空则停止;保留暂停状态,同步本地队列文件。" ;; + clear) echo "clear — 停止并清空 mpv 和本地队列文件,收掉保活;配置和快照保留,可重复调用。" ;; + pause|resume|toggle) echo "$1 — 控制播放状态(mpv 真值)。" ;; + next|prev) echo "$1 — 队列上下首。" ;; + volume) echo "volume [0-130] — 不带参数读当前音量,带参数设置。" ;; + lyric) echo "lyric [--all] — 打印当前歌曲歌词;默认只显示当前句与下一句(按播放进度对齐)。" ;; + like) echo "like / unlike — 把当前播放的这首加入/移出「我喜欢的音乐」。" ;; + dislike) echo "dislike [歌曲id] — 提交私人 FM 的 fm/trash;省略 id 用当前歌曲,显式 id 必须是正整数。只上报不喜欢,不自动跳歌或改队列;成功以接口 code=200 为准。" ;; + repeat) echo "repeat off|list|single — 关闭 / 整列循环 / 单曲循环;on 和无参数沿用 list。status 的 repeat_mode 报 off/list/single,原 repeat 布尔字段仍表示整列循环。" ;; + setup) echo "setup — 把 cookie/音质写进 config.json;不带参数只打印怎么登录。" ;; + login) echo "login [--timeout 300] [--refresh 4] — 生成登录二维码(终端 + 图片),手机网易云 App 扫码;成功后把 cookie 存进 config.json。" ;; + status) echo "status — 当前在放什么/进度/音量/队列位置,全部来自 mpv;repeat_mode 为 off/list/single。" ;; + check) echo "check — 只读自检:依赖、登录态、mpv 是否可用、默认音质。" ;; + playlist) echo "playlist <歌单名|id|链接> — 放一张歌单,别人的也行(名字直接写:playlist 夜跑)。" ;; + playlists) echo "playlists <关键词> [--limit N] — 只列出搜到的歌单(id / 名字 / 曲目数 / 作者 / 是否已收藏)。" ;; + charts) echo "charts [--limit N] — 只列出排行榜(id / 名字 / 曲目数 / 更新频率)。" ;; + chart) echo "chart <榜名|id> [--limit N] — 放某个榜单整列(例:chart 飙升榜)。" ;; + myalbums|myartists) echo "$1 [--limit N] — 只列出我收藏的专辑 / 歌手(id 可直接喂 album/artist)。" ;; + fm) echo "fm [--limit N] — 私人 FM(没听过的),整列入队开播。" ;; + recent) echo "recent [--limit N] — 只列出最近播放(一周,含播放次数)。" ;; + replay) echo "replay [N] — 再放一次最近第 N 首(默认 1 = 最近那首)。" ;; + podcasts) echo "podcasts [--recommend] [--limit N] — 只列出我的播客订阅(或推荐的播客)。" ;; + podcast) echo "podcast <播客名|id> [--limit N] — 放某播客最新 N 期(默认 3)。" ;; + episode) echo "episode <节目id> — 放某一期节目(id 从 podcast 输出里抄)。" ;; + queues) echo "queues — 看本地的队列快照:文件、首数、存的时间、来源。" ;; + queue-save) echo "queue-save [名字] — 把当前队列存成命名快照(快照只存歌单列表,地址回放时重取)。" ;; + queue-load) echo "queue-load <名字> — 载入某个快照,回到它当时的位置;当前这条会留成「上一条」。" ;; + back) echo "back — 切回上一条队列(含当时的位置与随机状态);再 back 一次就切回来。" ;; + add) echo "add <歌曲|id> — 把一首加到当前队列尾部,不动正在放的那首。" ;; + *) usage ;; + esac + case "${1:-}" in + daily|playlist|liked|album|artist|albums|chart|search|playlists|myplaylists|charts|myalbums|myartists|recent|podcasts|podcast) + echo "分页:--offset N 从 0 起跳过 N 条,--limit N 最多取 N 条;JSON 的 total 优先用接口总数,缺失时用接口返回的条数。" ;; + esac +} + +die() { printf 'error=%s\nhint=%s\n' "$1" "$2" >&2; exit "${3:-1}"; } +kv() { RESULT_KEYS+=("$1"); RESULT_VALS+=("$2"); } +kv_api_total() { # total 只补进 JSON,保留原有人读结果块 + [ "$JSON" = 1 ] || return 0 + kv total "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["total"])' <<<"$1")" +} +flush_result() { + local head="$1" i + if [ "$JSON" = 1 ]; then + local argv=("$head") + [ "${#RESULT_KEYS[@]}" -gt 0 ] && argv+=("${RESULT_KEYS[@]}") + argv+=(--sep--) + [ "${#RESULT_VALS[@]}" -gt 0 ] && argv+=("${RESULT_VALS[@]}") + python3 - "${argv[@]}" <<'PY' +import json, sys +a = sys.argv[1:] +i = a.index('--sep--') +head, keys, vals = a[0], a[1:i], a[i + 1:] +print(json.dumps({"ok": True, "conclusion": head, **dict(zip(keys, vals))}, ensure_ascii=False)) +PY + else + [ "$QUIET" = 1 ] || printf '%s\n' "$head" + [ "${#RESULT_KEYS[@]}" -gt 0 ] || return 0 + for i in "${!RESULT_KEYS[@]}"; do printf '%s=%s\n' "${RESULT_KEYS[$i]}" "${RESULT_VALS[$i]}"; done + fi +} +now() { date +%Y-%m-%dT%H:%M:%S%z; } +mmss() { local s="${1:-0}"; printf '%d:%02d' $((s / 60)) $((s % 60)); } + +env_get() { local v; v="$(env | sed -n "s/^$1=//p" | head -1)"; printf '%s' "$v"; } +conf_get() { + [ -f "$CFG" ] || return 0 + python3 -c 'import json,sys +try: d=json.load(open(sys.argv[1])) +except Exception: sys.exit(0) +v=d.get(sys.argv[2]) +print(v if isinstance(v,str) else ("" if v is None else v))' "$CFG" "$1" 2>/dev/null +} +conf_set() { # conf_set <键> <值> + mkdir -p "$LOCAL_DIR" + python3 - "$CFG" "$1" "$2" <<'PY' +import json, os, sys +p, k, v = sys.argv[1], sys.argv[2], sys.argv[3] +d = {} +if os.path.exists(p): + try: d = json.load(open(p)) + except Exception: d = {} +d[k] = v +json.dump(d, open(p, 'w'), indent=2, ensure_ascii=False) +PY +} + +need_deps() { + command -v node >/dev/null 2>&1 || die deps "缺 node" 3 + command -v python3 >/dev/null 2>&1 || die deps "缺 python3" 3 + [ -f "$API" ] || die deps "缺脚本 $API" 3 +} + +api() { # api <命令> [参数];stdout=JSON,stderr=错误 JSON + NETEASE_COOKIE="$COOKIE" node "$API" "$@" --offset "$OFFSET" +} +api_or_die() { + local out err rc + err="$(mktemp)"; out="$(api "$@" 2>"$err")"; rc=$? + if [ $rc -ne 0 ]; then + local code hint + code="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("error","external"))' "$err" 2>/dev/null || echo external)" + hint="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hint",""))' "$err" 2>/dev/null || echo "接口调用失败")" + rm -f "$err" + die "$code" "$hint" "$rc" + fi + rm -f "$err" + printf '%s' "$out" +} +api_out() { # api_out <参数...>:与 api_or_die 同,但用在 $( ) 里也能让整个脚本停下来 + local out rc + out="$(api_or_die "$@")"; rc=$? + [ "$rc" -eq 0 ] || exit "$rc" + printf '%s' "$out" +} +mpvctl() { node "$MPVCTL" "$@"; } +mpvctl_or_die() { + local out rc + out="$(mpvctl "$@")"; rc=$? + [ $rc -eq 0 ] || exit $rc + printf '%s' "$out" +} + +# ---------- 队列:把歌曲列表变成 mpv 能放的 m3u8 + 记录 id 的 queue.json ---------- +build_queue() { # stdin = 接口返回的歌曲 JSON 数组 + local raw; raw="$(cat)" + mkdir -p "$LOCAL_DIR" + local stage rc + stage="$(mktemp -d "$LOCAL_DIR/.queue-build.XXXXXX")" || return 5 + printf '%s' "$raw" > "$stage/songs.json" + : > "$stage/urls.txt" + local chunk=() id + for id in $(python3 -c 'import json,sys +print(" ".join(str(s["id"]) for s in json.load(open(sys.argv[1]))))' "$stage/songs.json"); do + chunk+=("$id") + if [ ${#chunk[@]} -ge 50 ]; then + api_or_die url "${chunk[@]}" --level "$QUALITY" | pick_urls >> "$stage/urls.txt" + rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; return "$rc"; } + chunk=() + fi + done + if [ ${#chunk[@]} -gt 0 ]; then + api_or_die url "${chunk[@]}" --level "$QUALITY" | pick_urls >> "$stage/urls.txt" + rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; return "$rc"; } + fi + python3 "$SKILL_DIR/scripts/lib/build-queue.py" "$stage/songs.json" "$stage/urls.txt" "$stage/queue.json" "$stage/queue.m3u8" "$@" + rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; return "$rc"; } + [ "${SKIP_SNAPSHOT:-0}" = 1 ] || snap_auto + keepalive_stop + local file + for file in songs.json urls.txt queue.json queue.m3u8; do + mv "$stage/$file" "$LOCAL_DIR/$file" || { rm -rf "$stage"; return 5; } + done + rmdir "$stage" + date +%s > "$LOCAL_DIR/urls_at" # 这批地址的取用时刻:网易云直链是限时签名,约 5 分钟就 403 +} + +pick_urls() { # 从 url 接口的 JSON 里挑出 <id>\t<服务端给的档位>\t<url>,丢掉拿不到地址的 + python3 -c 'import json,sys +for it in json.load(sys.stdin)["items"]: + if it.get("url"): print("%s\t%s\t%s" % (it["id"], it.get("level") or "", it["url"]))' +} + +# ---------- mpv 顺序 ↔ queue.json:开 shuffle 后两者不同,只能按「歌」认,不能按位置 ---------- +# 每首歌的稳定身份写在 m3u 标题里([#id]),旧队列/地址刷新时回退按 URL、标题串认(见 lib/mpv-map.py)。 +mpv_map() { python3 "$SKILL_DIR/scripts/lib/mpv-map.py" "$@"; } + +song_by_id() { # 从 queue.json 按 id 取歌曲 JSON(单行);取不到给空 + python3 - "$QUEUE" "${1:-}" <<'PY' +import json, sys +try: songs = json.load(open(sys.argv[1]))["songs"] +except Exception: sys.exit(0) +for s in songs: + if str(s["id"]) == sys.argv[2]: + print(json.dumps(s, ensure_ascii=False)); break +PY +} + +cur_song_json() { # 当前 mpv 真正在放的那首(JSON);没有/对不上给空 + local st; st="$(mpvctl status 2>/dev/null)" || return 0 + printf '%s' "$st" > "${TMPDIR:-/tmp}/nm_cur.$$" + mpv_map now "$QUEUE" "$LOCAL_DIR/urls.txt" "${TMPDIR:-/tmp}/nm_cur.$$" 2>/dev/null + rm -f "${TMPDIR:-/tmp}/nm_cur.$$" +} + +cur_id() { cur_song_json | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null; } + +jump_index_of_id() { # <歌曲id> → 该歌在 mpv 当前队列里的序号(1 起);找不到给空 + local idx; mpvctl_or_die playlist > "${TMPDIR:-/tmp}/nm_pl.$$" + idx="$(mpv_map find "$QUEUE" "$LOCAL_DIR/urls.txt" "$1" "${TMPDIR:-/tmp}/nm_pl.$$" 2>/dev/null)" || idx="" + rm -f "${TMPDIR:-/tmp}/nm_pl.$$" + printf '%s' "$idx" +} + +jump_target() { # <队列第 N 首> → mpv 队列序号(随机播放时按歌定位,别跳错歌) + local n="$1" id idx shuf + case "$n" in ''|*[!0-9]*) die usage "jump 需要队列序号(例:netease-music jump 3)" 2 ;; esac + id="$(python3 - "$QUEUE" "$n" <<'PY' +import json, sys +try: songs = json.load(open(sys.argv[1]))["songs"] +except Exception: sys.exit(0) +i = int(sys.argv[2]) - 1 +print(songs[i]["id"] if 0 <= i < len(songs) else "") +PY +)" + [ -n "$id" ] || die notfound "队列里没有第 $n 首(先 queue 看队列)" 4 + idx="$(jump_index_of_id "$id")" + [ -n "$idx" ] && { printf '%s' "$idx"; return 0; } + # 兜底:队列没打乱时序号就是序号;打乱了又定位不到就明确报出来,别静默跳错歌 + shuf="$(mpvctl_or_die status | python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("shuffle",False)).lower())')" + if [ "$shuf" != true ]; then printf '%s' "$n"; else + die notfound "这首歌在当前 mpv 队列里定位不到(标题是旧格式或地址已刷新),随机播放时不能可靠跳转;先 shuffle off 或重放一次队列" 4 + fi +} + +play_queue() { # play_queue [--index N] + local total; total="$(python3 -c 'import json,sys;print(len(json.load(open(sys.argv[1]))["songs"]))' "$QUEUE" 2>/dev/null || echo 0)" + [ "${total:-0}" -gt 0 ] || die notfound "没有一首能播(可能都要 VIP 或没版权):换一首/换张专辑,或先 search 看看" 4 + local sf n + sf="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["skipped"])' "$QUEUE")" + n="$(python3 -c 'import json,sys;print(len(json.load(open(sys.argv[1]))["songs"]))' "$QUEUE")" + kv queue "$n" + [ "$sf" != "0" ] && kv skipped "${sf}(要 VIP 或没版权)" + mpvctl_or_die play "$M3U" "$@" >/dev/null + # mpv 开了 shuffle 时「正在放」不一定是第一首:等它真的开出声再回读(最多 1.5 秒) + local cur="" i=0 + while [ "$i" -lt 10 ]; do + cur="$(cur_song_json)"; [ -n "$cur" ] && break + sleep 0.15; i=$((i + 1)) + done + [ -n "$cur" ] || cur="$(song_by_id "$(python3 -c 'import json,sys;s=json.load(open(sys.argv[1]))["songs"];print(s[0]["id"])' "$QUEUE")")" + local info now_name now_artist now_id now_level + info="$(python3 -c 'import json,sys +s=json.load(sys.stdin) +print("%s\t%s\t%s\t%s" % (s["name"], s["artist"], s["id"], s.get("level") or ""))' <<<"$cur")" + IFS=$'\t' read -r now_name now_artist now_id now_level <<<"$info" + kv now_playing "$now_name - $now_artist" + kv song_id "$now_id" + kv quality "${now_level:-$QUALITY}" + [ -n "$now_level" ] && [ "$now_level" != "$QUALITY" ] && kv quality_requested "$QUALITY" + kv source mpv; kv as_of "$(now)" + flush_result "开始播放:$now_name - ${now_artist}(队列 $n 首)" + keepalive_start +} + +# ---------- 命令 ---------- +cmd_daily() { + local songs; songs="$(api_out daily --limit "$LIMIT")" || exit $? + kv_api_total "$songs" + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $? + apply_shuffle + play_queue +} +cmd_liked() { + local songs; songs="$(api_out liked --limit "${LIMIT:-1000}")" || exit $? + kv_api_total "$songs" + python3 -c 'import json,sys;d=json.load(sys.stdin);print(json.dumps(d["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $? + apply_shuffle + play_queue +} +cmd_playlist() { # cmd_playlist <id|链接> + local id="$1" songs + songs="$(api_out playlist "$id" --limit "${LIMIT:-500}")" || exit $? + kv_api_total "$songs" + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $? + local name; name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$songs")" + apply_shuffle + play_queue +} +cmd_artist() { # cmd_artist <关键词|id> + local q="$1" songs + [ -n "$q" ] || die usage "artist 需要歌手名或 id(例:netease-music artist 陈粒)" 2 + songs="$(api_out artist "$q" --limit "${LIMIT:-50}")" || exit $? + kv_api_total "$songs" + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $? + apply_shuffle + play_queue +} +cmd_album() { # cmd_album <关键词|id> + local q="$1" songs + [ -n "$q" ] || die usage "album 需要专辑名或 id(找 id:netease-music albums <歌手>)" 2 + songs="$(api_out album "$q" --limit "${LIMIT:-200}")" || exit $? + kv_api_total "$songs" + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $? + apply_shuffle + play_queue +} +cmd_albums() { # 歌手的专辑列表(只读) + local q="$1" j n name + [ -n "$q" ] || die usage "albums 需要歌手名或 id(例:netease-music albums 周杰伦)" 2 + j="$(api_out albums "$q" --limit "${LIMIT:-60}")" || exit $? + kv_api_total "$j" + name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$j")" + n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + kv artist "$name"; kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "找到 $n 张专辑:$name" + [ "$JSON" = 1 ] || python3 -c 'import json, sys +for a in json.load(sys.stdin)["albums"]: + print(" %-10s %s (%s, %d 首)" % (a["id"], a["name"], a["year"] or "—", a["size"]))' <<<"$j" +} +cmd_queue() { # 看队列(真值来自 mpv 的 playlist;时长对齐 queue.json) + local st; st="$(mpvctl_or_die playlist)" + python3 - "$st" "${LIMIT:-0}" "$QUEUE" "${TMPDIR:-/tmp}/nm_queue.$$" "${TMPDIR:-/tmp}/nm_queue_list.$$" "$LOCAL_DIR/urls.txt" <<'INNER_PY_END' +import json, re, sys +st = json.loads(sys.argv[1]) +limit = int(sys.argv[2]) if sys.argv[2] else 0 +qpath, kvout, listout, urls_path = sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6] +count = st.get("count", 0); pos = st.get("pos", -1); entries = st.get("entries", []) +q = {} +try: q = json.load(open(qpath)) +except Exception: pass +songs = q.get("songs") or [] +by_id = {str(s["id"]): s for s in songs} +def strip_query(u): return (u or "").split("?")[0] +by_url = {} # 旧格式标题/地址刷新前:条目 filename → 歌 id +try: + for line in open(urls_path, encoding="utf-8"): + parts = line.rstrip("\n").split("\t") + if len(parts) >= 2 and parts[-1]: + by_url[strip_query(parts[-1])] = parts[0] +except OSError: + pass +if limit: entries = entries[:limit] +def entry_song(e): # 身份:标题 [#id] → URL → 标题串「歌手 - 歌名」;都不行就不配时长(宁可缺,不可错) + t = e.get("title") or "" + m = re.match(r"^\[#(\d+)\]\s*", t) + sid = m.group(1) if m else by_url.get(strip_query(e.get("filename") or "")) + if sid and sid in by_id: + return by_id[sid], re.sub(r"^\[#\d+\]\s*", "", t) + for s in songs: + if t.strip() == "%s - %s" % (s.get("artist") or "", s.get("name") or ""): + return s, t + return None, t +with open(kvout, "w") as kf, open(listout, "w") as lf: + kf.write("HEAD\t" + ("队列 %d 首(当前第 %d 首)" % (count, pos + 1) if count else "队列是空的(先 play / daily / liked / artist / album)") + "\n") + for e in entries: + i = e["index"] - 1 + s, title = entry_song(e) + ms = (s.get("ms") or 0) if s else (int(e["duration"]) * 1000 if e.get("duration") else 0) + dur = (" %d:%02d" % (ms // 60000, ms // 1000 % 60)) if ms else "" + lf.write("%4d. %s%s%s\n" % (e["index"], title or "(未知)", dur, " ← 正在放" if i == pos else "")) + kf.write("count\t%d\n" % count) + if pos >= 0: kf.write("pos\t%d\n" % (pos + 1)) + kf.write("source\tmpv\n") +INNER_PY_END + local head; head="$(head -1 "${TMPDIR:-/tmp}/nm_queue.$$" | cut -f2-)" + while IFS=$'\t' read -r k v; do [ "$k" = HEAD ] || kv "$k" "$v"; done < "${TMPDIR:-/tmp}/nm_queue.$$" + kv as_of "$(now)" + flush_result "$head" + [ "$JSON" = 1 ] || cat "${TMPDIR:-/tmp}/nm_queue_list.$$" + rm -f "${TMPDIR:-/tmp}/nm_queue.$$" "${TMPDIR:-/tmp}/nm_queue_list.$$" +} + +cmd_play() { # cmd_play <关键词|id|链接> + local arg="$1" + case "$arg" in + *music.163.com*artist*|artist:*) cmd_artist "${arg#artist:}"; return ;; + *music.163.com*album*|album:*) cmd_album "${arg#album:}"; return ;; + *music.163.com*playlist*|*music.163.com*toplist*|playlist:*|*list=*) + cmd_playlist "${arg#playlist:}"; return ;; + esac + if [ -z "$arg" ]; then die usage "play 需要关键词、歌曲 id 或歌单链接" 2; fi + local one id rc=0 + one="$(api_or_die song "$arg" 2>/dev/null)" || rc=$? + if [ $rc -ne 0 ]; then # 纯数字又说「没这首歌」:多半给的是歌单 id + case "$arg" in + *[!0-9]*) api_or_die song "$arg" >/dev/null ;; # 非数字:把原样报错交给用户 + *) cmd_playlist "$arg" ;; + esac + return + fi + id="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' <<<"$one")" + local songs; songs="$(api_out song "$id")" || exit $? + python3 -c 'import json,sys;s=json.load(sys.stdin);print(json.dumps([s]))' <<<"$songs" | build_queue >/dev/null || exit $? + play_queue +} +cmd_play_ids() { + local j index + [ "${#ARGS[@]}" -eq 1 ] || die usage "用法:play-ids <id,id,...> [--start N]" 2 + [ "$SHUFFLE" = 0 ] || die usage "play-ids 按输入顺序播放,不支持 --shuffle" 2 + j="$(api_out play-ids "${ARGS[0]}" --start "$START")" || exit $? + python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue "$START" >/dev/null || exit $? + index="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["start"] - 1)' "$QUEUE")" + mpvctl_or_die shuffle off >/dev/null + kv start "$START" + play_queue --index "$index" +} +cmd_remove() { + local n="${ARGS[0]:-}" stage rc result + [ "${#ARGS[@]}" -eq 1 ] && [[ "$n" =~ ^[0-9]+$ ]] && [[ "$n" =~ [1-9] ]] || die usage "remove 需要队列序号(从 1 开始)" 2 + mkdir -p "$LOCAL_DIR" + stage="$(mktemp -d "$LOCAL_DIR/.queue-edit.XXXXXX")" || exit 5 + mpvctl playlist > "$stage/playlist.json" + rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; exit "$rc"; } + python3 "$SKILL_DIR/scripts/lib/queue-edit.py" "$LOCAL_DIR" "$stage/playlist.json" "$n" "$stage" + rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; exit "$rc"; } + result="$(mpvctl remove "$n")" + rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; exit "$rc"; } + keepalive_stop + local file + for file in queue.json queue.m3u8 songs.json urls.txt; do + mv "$stage/$file" "$LOCAL_DIR/$file" || { rm -rf "$stage"; exit 5; } + done + rm -rf "$stage" + if [ "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$result")" -gt 0 ]; then keepalive_start; fi + kv command remove; kv removed "$n" + emit_status "已移除队列第 $n 首" +} +cmd_clear() { + [ "${#ARGS[@]}" -eq 0 ] || die usage "用法:clear" 2 + mpvctl_or_die clear >/dev/null + keepalive_stop + rm -f "$QUEUE" "$M3U" "$LOCAL_DIR/songs.json" "$LOCAL_DIR/urls.txt" "$LOCAL_DIR/urls_at" + kv command clear + emit_status "已清空队列" +} +cmd_search() { + local j; j="$(api_out search "$KEYWORD" --limit "$LIMIT")" || exit $? + kv_api_total "$j" + local list count + list="$(python3 -c 'import json,sys +for i, s in enumerate(json.load(sys.stdin)["songs"], 1): + print("%3d. %s — %s [%s] %d:%02d%s id=%s" % (i, s["name"], s["artist"], s["album"], s["ms"]//60000, s["ms"]//1000%60, " VIP" if s["vip"] else "", s["id"]))' <<<"$j")" + count="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + kv count "$count"; kv kw "$KEYWORD"; kv source api; kv as_of "$(now)" + flush_result "搜到 $count 条:$KEYWORD" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} +emit_status() { # emit_status [首行覆盖]:读 mpv 真值并打印状态块 + local st cur; st="$(mpvctl_or_die status)" + cur="$(cur_song_json)" + python3 - "$st" "$QUEUE" "$cur" <<'PY' > "${TMPDIR:-/tmp}/nm_status.$$" +import json, sys +st = json.loads(sys.argv[1]); q = {} +try: q = json.load(open(sys.argv[2])) +except Exception: pass +songs = q.get('songs', []) +try: s = json.loads(sys.argv[3]) if sys.argv[3].strip() else None +except Exception: s = None +if s is None: # 映射不到(旧格式/异常)就退回按位置取 + s = songs[st['pos']] if 0 <= st['pos'] < len(songs) else None +print("HEAD\t" + ("正在播放:" + s['name'] + " - " + s['artist'] if s and st['playing'] else + "已暂停:" + s['name'] + " - " + s['artist'] if s and st['paused'] else + "没有在放" if st['idle'] else "播放中")) +if s: + print(f"title\t{s['name']}"); print(f"artist\t{s['artist']}"); print(f"song_id\t{s['id']}") +print("state\t" + ("idle" if st['idle'] else "playing" if st['playing'] else "paused" if st['paused'] else "idle")) +if st['count']: print(f"queue_pos\t{st['pos']+1}/{st['count']}") +if st['elapsed'] is not None: print(f"elapsed\t{st['elapsed']}") +if st['duration'] is not None: print(f"duration\t{st['duration']}") +print(f"volume\t{st['volume']}"); print("repeat\t" + str(st['repeat']).lower()); print("shuffle\t" + str(st['shuffle']).lower()) +print("repeat_mode\t" + st['repeat_mode']) +print("source\tmpv") +PY + local head; head="${1:-$(head -1 "${TMPDIR:-/tmp}/nm_status.$$" | cut -f2-)}" + while IFS=$'\t' read -r k v; do [ "$k" = HEAD ] || kv "$k" "$v"; done < "${TMPDIR:-/tmp}/nm_status.$$" + rm -f "${TMPDIR:-/tmp}/nm_status.$$" + kv as_of "$(now)" + flush_result "$head" +} +cmd_status() { emit_status; } +current_id() { + local id; id="$(cur_id)" + [ -n "$id" ] || die notfound "取不到当前歌曲(队列信息丢了),先 play/daily 一下" 4 + printf '%s' "$id" +} +cmd_myplaylists() { # 我创建 + 我收藏的歌单;[collected|own] 只列其中一类 + local mode="${1:-}" j list n + j="$(api_out myplaylists --limit "${LIMIT:-100}")" || exit $? + kv_api_total "$j" + n="$(python3 -c 'import json,sys +p = json.load(sys.stdin)["playlists"]; m = sys.argv[1] if len(sys.argv) > 1 else "" +print(sum(1 for x in p if (m != "collected" or x.get("sub")) and (m != "own" or not x.get("sub"))))' "$mode" <<<"$j")" + list="$(python3 -c 'import json,sys +m = sys.argv[1] if len(sys.argv) > 1 else "" +for p in json.load(sys.stdin)["playlists"]: + if m == "collected" and not p.get("sub"): continue + if m == "own" and p.get("sub"): continue + print(" %s %s(%d 首)%s" % (p["id"], p["name"], p["count"], " [收藏的]" if p.get("sub") else ""))' "$mode" <<<"$j")" + kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "我的歌单($n 张${mode:+ · $mode})" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} + +cmd_like() { + local id name flag=true cur + [ "${1:-}" = "--unlike" ] && flag=false + cur="$(cur_song_json)" + if [ -n "$cur" ]; then + id="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' <<<"$cur")" + name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$cur")" + else + id=""; name="" + fi + [ -n "$id" ] || die notfound "取不到当前歌曲(队列信息丢了),先 play/daily 一下" 4 + if [ "$flag" = true ]; then api_or_die like "$id" >/dev/null; else api_or_die like "$id" --unlike >/dev/null; fi + local now_liked lj; lj="$(api_out like-state "$id")" || exit $? + now_liked="$(python3 -c 'import json,sys;print(str(json.load(sys.stdin)["liked"]).lower())' <<<"$lj")" + kv song_id "$id"; kv title "$name"; kv liked "$now_liked"; kv as_of "$(now)" + if [ "$now_liked" = true ]; then flush_result "已加入「我喜欢的音乐」:$name"; else flush_result "已移出「我喜欢的音乐」:$name"; fi +} +cmd_liked_ids() { + local j ids count + [ "${#ARGS[@]}" -eq 0 ] || die usage "用法:liked-ids" 2 + j="$(api_out liked-ids)" || exit $? + if [ "$JSON" = 1 ]; then printf '%s\n' "$j"; return; fi + count="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + ids="$(python3 -c 'import json,sys;print(",".join(map(str,json.load(sys.stdin)["ids"])))' <<<"$j")" + kv count "$count"; kv ids "$ids"; kv_api_total "$j"; kv source api; kv as_of "$(now)" + flush_result "我喜欢的歌曲 ID($count 首)" +} +cmd_dislike() { + local id="${ARGS[0]:-}" j + [ "${#ARGS[@]}" -le 1 ] || die usage "用法:dislike [歌曲id]" 2 + if [ -z "$id" ]; then id="$(current_id)" || exit $?; fi + j="$(api_out dislike "$id")" || exit $? + kv song_id "$id"; kv disliked true; kv source api; kv as_of "$(now)" + flush_result "已加入私人 FM 不喜欢:$id" +} +cmd_lyric() { + local id; id="$(current_id)" + local j; j="$(api_out lyric "$id" ${1:-})" || exit $? + if [ "$JSON" = 1 ]; then + kv lyric "$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("lyric",""))' <<<"$j")" + kv song_id "$id"; kv as_of "$(now)" + flush_result "歌词已取到(id=${id})" + return + fi + if [ "${1:-}" = "--all" ]; then + local name; name="$(song_by_id "$id" | python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])')" + kv song_id "$id"; kv title "$name"; kv source api; kv as_of "$(now)" + flush_result "歌词:$name" + python3 -c 'import json,sys;print(json.load(sys.stdin)["lyric"])' <<<"$j" + return + fi + local pos + pos="$(mpvctl_or_die status | python3 -c 'import json,sys;print(json.load(sys.stdin).get("elapsed") or 0)')" + printf '%s' "$j" > "${TMPDIR:-/tmp}/nm_lyric.$$" + python3 - "$pos" "${TMPDIR:-/tmp}/nm_lyric.$$" <<'PY' +import json, re, sys +cur = float(sys.argv[1]) +lyric = json.load(open(sys.argv[2], encoding="utf-8"))["lyric"] +lines = [] +for ln in lyric.splitlines(): + m = re.match(r"\[(\d+):(\d+(?:\.\d+)?)\](.*)", ln) + if m and m.group(3).strip(): + lines.append((int(m.group(1)) * 60 + float(m.group(2)), m.group(3).strip())) +lines.sort() +if not lines: + print("(纯音乐,没有歌词)") +else: + i = max([k for k, (t, _) in enumerate(lines) if t <= cur] or [0]) + print("当前句:" + lines[i][1]) + if i + 1 < len(lines): + print("下一句:" + lines[i + 1][1]) +PY + rm -f "${TMPDIR:-/tmp}/nm_lyric.$$" +} +cmd_setup() { # setup [--cookie 值] [--quality 级别];不带参数就告诉你怎么登录 + local did="" + while [ $# -gt 0 ]; do + case "$1" in + --cookie) conf_set cookie "$2"; did="$did cookie"; shift ;; + --quality) conf_set quality "$2"; did="$did quality" ;; + esac + shift + done + if [ -z "$did" ]; then + kv config "$CFG" + kv how "netease-music login(推荐,手机扫码 30 秒);或 setup --cookie 'MUSIC_U=…' 粘贴浏览器里的登录 cookie" + kv as_of "$(now)" + flush_result "netease-music 要登录才能用(每日推荐/我喜欢的/点播 VIP):跑 netease-music login" + return + fi + kv saved "$did"; kv config "$CFG"; kv as_of "$(now)" + flush_result "配置已写入:$did" +} + +mask_phone() { local p="$1"; [ ${#p} -ge 8 ] && printf '%s****%s' "${p:0:3}" "${p: -4}" || printf '%s' "$p"; } + +cmd_login() { # 登录:默认扫码(过期自动换码);--sms 手机号 / --sms-code 走短信验证码 + need_deps + local attempt=0 j key url png res state cookie sess nick uid + if [ -n "$SMS_PHONE" ]; then + if [ -n "$SMS_CODE" ]; then + res="$(api_out sms-login "$SMS_PHONE" "$SMS_CODE")" || exit $? + cookie="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["cookie"])' <<<"$res")" + conf_set cookie "$cookie"; COOKIE="$cookie" + sess="$(api_out session)" || exit $? + nick="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["nick"])' <<<"$sess")" + uid="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["uid"])' <<<"$sess")" + kv nick "$nick"; kv uid "$uid"; kv config "$CFG"; kv as_of "$(now)" + flush_result "登录成功:${nick}(cookie 已存 ${CFG})" + else + api_or_die sms-send "$SMS_PHONE" >/dev/null + kv phone "$(mask_phone "$SMS_PHONE")"; kv as_of "$(now)" + flush_result "验证码已发到 $(mask_phone "$SMS_PHONE"):拿到后跑 netease-music login --sms $SMS_PHONE --sms-code <验证码>" + fi + return + fi + while [ "$attempt" -lt "$REFRESH" ]; do + attempt=$((attempt + 1)) + j="$(api_out qr-start)" || exit $? + key="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["key"])' <<<"$j")" + url="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["url"])' <<<"$j")" + png="/tmp/netease-login-qr-${key:0:8}.png" + rm -f /tmp/netease-login-qr-*.png # 旧的码一律删掉,避免扫到作废的那张 + osascript -e 'tell application "Preview" to close (every window whose name contains "netease-login-qr")' >/dev/null 2>&1 || true + if command -v qrencode >/dev/null 2>&1; then + qrencode -o "$png" -s 8 -m 2 "$url" + cp "$png" /tmp/netease-login-qr.png # 稳定路径,方便找 + qrencode -t UTF8 -m 1 "$url" + open -a Preview "$png" 2>/dev/null || open "$png" 2>/dev/null || true + else + kv qr_url "$url" + fi + echo "第 $attempt/$REFRESH 张码:${png}(刚弹到最前面)。用网易云 App 右上角「扫一扫」扫它,扫完在手机上点「确认登录」。过期自动换新。" >&2 + res="$(api_out qr-poll "$key" --timeout "$TIMEOUT")" || exit $? + state="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["state"])' <<<"$res")" + if [ "$state" = "ok" ]; then + cookie="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["cookie"])' <<<"$res")" + conf_set cookie "$cookie" + COOKIE="$cookie" + local sess nick uid + sess="$(api_out session)" || exit $? + nick="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["nick"])' <<<"$sess")" + uid="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["uid"])' <<<"$sess")" + kv nick "$nick"; kv uid "$uid"; kv config "$CFG"; kv as_of "$(now)" + flush_result "登录成功:${nick}(cookie 已存 ${CFG})" + return + fi + [ "$state" = "expired" ] && echo "这张码过期了,自动换一张…" >&2 || die timeout "扫码没完成(state=${state})" 5 + done + die timeout "连着 $REFRESH 张码都没扫成:重跑 netease-music login" 5 +} +cmd_check() { + local ok=true b + for b in node python3 mpv qrencode; do + if command -v "$b" >/dev/null 2>&1; then kv "bin_$b" ok; else kv "bin_$b" missing; ok=false; fi + done + kv quality "$QUALITY" + kv cookie "$([ -n "$COOKIE" ] && echo "已配置(${#COOKIE} 字节)" || echo 未配置)" + [ -n "$COOKIE" ] || ok=false + if command -v node >/dev/null 2>&1; then + local sess; sess="$(api session 2>/dev/null)" + if [ -n "$sess" ]; then + kv logged_in "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["loggedIn"])' <<<"$sess" 2>/dev/null || echo unknown)" + kv nick "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["nick"])' <<<"$sess" 2>/dev/null || echo '')" + kv uid "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["uid"])' <<<"$sess" 2>/dev/null || echo 0)" + fi + kv mpv "$(mpvctl status >/dev/null 2>&1 && echo ok || echo 起不来)" + fi + kv as_of "$(now)" + [ "$ok" = true ] || { flush_result "自检未全绿(看下面字段)"; die deps "未登录跑 netease-music login;缺依赖用 brew install mpv qrencode" 3; } + flush_result "netease-music 自检通过:接口与播放器都可用" +} + +apply_shuffle() { [ "${SHUFFLE:-0}" = 1 ] && mpvctl shuffle on >/dev/null; } + +# ---------- 地址保活:直链约 5 分钟过期,长队列边走边换 ---------- +# 建队时地址是新鲜的(起播零等待);这里后台盯着,快过期就把「还没播到的部分」批量重取、 +# 原地换掉 m3u 里对应的行。KEEPALIVE_PID 单独存,换队列时旧的就收掉。 +KEEPALIVE_PID="$LOCAL_DIR/keepalive.pid" +KEEPALIVE_LOG="$LOCAL_DIR/keepalive.log" + +keepalive_stop() { + [ -s "$KEEPALIVE_PID" ] || return 0 + local pid; pid="$(cat "$KEEPALIVE_PID" 2>/dev/null)" + case "$pid" in ''|*[!0-9]*) ;; *) kill "$pid" 2>/dev/null ;; esac + rm -f "$KEEPALIVE_PID" +} + +keepalive_start() { + keepalive_stop + nohup "$SKILL_DIR/scripts/refresh-urls.sh" > "$KEEPALIVE_LOG" 2>&1 & + echo $! > "$KEEPALIVE_PID" +} + +# ---------- 队列现场:快照 / 切回 / 追加 ---------- +# 一个 mpv 只有一条队列,任何「放 X」都会整列替换;所以建新队列之前, +# 自动把「当前这条」存成上一条快照(只存歌曲列表,地址回放时重取)。 +QUEUES_DIR="$LOCAL_DIR/queues" +SKIP_SNAPSHOT=0 + +mpv_meta() { # 输出 "<pos> <shuffle>";拿不到就给 "-1 false" + local st; st="$(mpvctl status 2>/dev/null)" || { printf '%s' "-1 false"; return; } + python3 -c 'import json,sys +try: + d = json.loads(sys.argv[1]); print("%d %s" % (d.get("pos", -1), str(d.get("shuffle", False)).lower())) +except Exception: print("-1 false")' "$st" +} + +snap_auto() { # 建新队列前留一份上一条(出错也不影响主流程) + [ -s "$QUEUE" ] || return 0 + local m id; m="$(mpv_meta)"; id="$(cur_id)" + python3 "$SKILL_DIR/scripts/lib/snapshot.py" save "$QUEUE" "$QUEUES_DIR/prev.json" \ + "${m%% *}" "${m##* }" "上一条" "${id:-}" >/dev/null 2>&1 || true +} + +snap_save() { # snap_save <文件> <名字> [队列文件]:打一份快照 + local m id; m="$(mpv_meta)"; id="$(cur_id)" + python3 "$SKILL_DIR/scripts/lib/snapshot.py" save "${3:-$QUEUE}" "$1" \ + "${m%% *}" "${m##* }" "$2" "${id:-}" +} + +restore_snapshot() { # restore_snapshot <快照文件>:按快照重建队列,并回到当时的歌与随机状态 + local snap="$1" meta songs pos shuf sid + meta="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" meta "$snap")" || die external "快照读不了:$snap" 5 + songs="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" songs "$snap")" + pos="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("pos",-1))' <<<"$meta")" + shuf="$(python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("shuffle",False)).lower())' <<<"$meta")" + sid="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("song_id") or "")' <<<"$meta")" + SKIP_SNAPSHOT=1 + printf '%s' "$songs" | build_queue >/dev/null || exit $? + SKIP_SNAPSHOT=0 + [ "$shuf" = true ] && mpvctl_or_die shuffle on >/dev/null || mpvctl_or_die shuffle off >/dev/null + # 输出交给调用方(back / queue-load 会自己 emit_status),这里只负责把歌放起来 + # 开随机后「当时的位置」没有意义:优先回到当时在放的那首歌;旧快照没存 id 才退回位置 + if [ -n "$sid" ]; then + play_queue >/dev/null + local idx; idx="$(jump_index_of_id "$sid")" + [ -n "$idx" ] && mpvctl_or_die jump "$idx" >/dev/null + elif [ "${pos:-0}" -gt 0 ] 2>/dev/null; then play_queue --index "$pos" >/dev/null; else play_queue >/dev/null; fi +} + +cmd_queues() { # 列出本地队列快照 + local rows n list + rows="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" list "$QUEUES_DIR")" + n="$(python3 -c 'import json,sys;print(len(json.load(sys.stdin)))' <<<"$rows")" + list="$(python3 -c 'import json,sys +for r in json.load(sys.stdin): + tag = "上一条(back 切回)" if r["file"] == "prev.json" else r["name"] + print(" %s %d 首 %s %s" % (r["file"], r["count"], (r["saved_at"] or "")[:16].replace("T", " "), tag))' <<<"$rows")" + kv count "$n"; kv source local; kv as_of "$(now)" + flush_result "本地队列快照($n 条)" + [ "$JSON" = 1 ] || [ "$n" = 0 ] && { [ "$JSON" = 1 ] || printf ' (还没有快照;每次放新的音乐会自己留一条「上一条」)\n'; } || printf '%s\n' "$list" +} + +cmd_queue_save() { # 把当前队列存成命名快照 + [ -s "$QUEUE" ] || die notfound "现在没有队列可存" 4 + local name="$1" fname cnt out + fname="$(python3 -c 'import re,sys +n = (sys.argv[1] or "").strip(); n = re.sub(r"[^\w.\-]+", "_", n, flags=re.UNICODE)[:40] +print(n)' "$name")" + [ -n "$fname" ] || fname="save-$(date +%Y%m%d-%H%M%S)" + out="$(snap_save "$QUEUES_DIR/$fname.json" "${name:-$fname}")" || die external "存快照失败" 5 + cnt="$(printf '%s' "$out" | cut -f2)" + kv saved "$fname"; kv count "$cnt"; kv source local; kv as_of "$(now)" + flush_result "已存队列快照:${fname}($cnt 首)" +} + +cmd_back() { # 切回上一条队列(含当时的位置;再 back 一次就切回来) + [ -s "$QUEUES_DIR/prev.json" ] || die notfound "没有上一条可切回(用 queues 看有哪些快照)" 4 + local tmp="$QUEUES_DIR/.swap.json" meta name count + [ -s "$QUEUE" ] && snap_save "$tmp" "下一条" >/dev/null 2>&1 + meta="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" meta "$QUEUES_DIR/prev.json")" + name="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("name") or "")' <<<"$meta")" + count="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count") or 0)' <<<"$meta")" + kv back_to "$name"; kv count "$count" + restore_snapshot "$QUEUES_DIR/prev.json" + [ -s "$tmp" ] && mv "$tmp" "$QUEUES_DIR/prev.json" + emit_status "已切回上一条:${name}($count 首)" +} + +cmd_queue_load() { # 载入某个命名快照(当前这条会留成「上一条」,可 back 回来) + local name="$1"; [ -n "$name" ] || die usage "queue-load 需要快照名(用 queues 看有哪些)" 2 + local f="$QUEUES_DIR/$(basename "$name")" + [ -s "$f" ] || f="$QUEUES_DIR/$(basename "$name").json" + [ -s "$f" ] || die notfound "没有这个快照:${name}(queues 看有哪些)" 4 + local meta count + meta="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" meta "$f")" || die external "快照读不了:$f" 5 + count="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count") or 0)' <<<"$meta")" + [ -s "$QUEUE" ] && snap_save "$QUEUES_DIR/prev.json" "上一条" >/dev/null 2>&1 + kv loaded "$(basename "$f" .json)"; kv count "$count" + restore_snapshot "$f" + emit_status "已载入快照:$(basename "$f" .json)($count 首)" +} + +cmd_add() { # 往当前队列尾部加一首(不动正在放的那首) + local q="$1"; [ -n "$q" ] || die usage "add 需要歌曲关键词或 id(例:netease-music add 幻听)" 2 + local song id name artist ms + song="$(api_out song "$q")" || exit $? + id="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' <<<"$song")" + name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$song")" + artist="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["artist"])' <<<"$song")" + ms="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("ms") or 0)' <<<"$song")" + if [ ! -s "$QUEUE" ] || [ ! -s "$M3U" ]; then # 还没队列:这一首就是队列 + python3 -c 'import json,sys;print(json.dumps([json.load(sys.stdin)]))' <<<"$song" | build_queue >/dev/null || exit $? + play_queue; return + fi + local raw url + raw="$(api_out url "$id" --level "$QUALITY")" || exit $? + url="$(python3 -c 'import json,sys +d = json.load(sys.stdin); it = (d.get("items") or [{}])[0]; print(it.get("url") or "")' <<<"$raw")" + [ -n "$url" ] || die notfound "这首拿不到播放地址(可能要 VIP 或已下架):$name" 4 + local tmp="${TMPDIR:-/tmp}/nm_add.$$.m3u" res ok cnt + res="$(python3 "$SKILL_DIR/scripts/lib/queue-append.py" "$QUEUE" "$M3U" "$tmp" "$id" "$name" "$artist" "$ms" "$url")" + ok="$(python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("ok",False)).lower())' <<<"$res")" + if [ "$ok" != true ]; then + rm -f "$tmp" + cnt="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count",0))' <<<"$res")" + kv added no; kv count "$cnt"; kv source local; kv as_of "$(now)" + flush_result "没加:$name - $artist 已经在队列里(队列 $cnt 首)" + return + fi + mpvctl_or_die append "$tmp" >/dev/null; rm -f "$tmp" + cnt="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count",0))' <<<"$res")" + kv added "$name - $artist"; kv count "$cnt" + emit_status "已加到队尾:$name - ${artist}(队列 $cnt 首)" +} + +# ---------- 歌单搜索 / 榜单 / 收藏 / 私人 FM / 最近 / 播客 ---------- + +cmd_playlists() { # 搜歌单(别人做的),只列不播 + local kw="${ARGS[0]:-}"; [ -n "$kw" ] || die usage "playlists 需要关键词(例:netease-music playlists 夜跑)" 2 + local j list n + j="$(api_out playlists "$kw" --limit "${LIMIT:-20}")" || exit $? + kv_api_total "$j" + n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + list="$(python3 -c 'import json,sys +for p in json.load(sys.stdin)["playlists"]: + print(" %s %s(%d 首 · %s)%s" % (p["id"], p["name"], p["count"], p["creator"], " [已收藏]" if p["sub"] else ""))' <<<"$j")" + kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "歌单搜索「${kw}」($n 张)" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} + +cmd_charts() { # 列排行榜 + local j list n + j="$(api_out charts --limit "${LIMIT:-100}")" || exit $? + kv_api_total "$j" + n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + list="$(python3 -c 'import json,sys +for c in json.load(sys.stdin)["charts"]: + print(" %s %s(%s 首 · %s)" % (c["id"], c["name"], c["count"], c["freq"]))' <<<"$j")" + kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "排行榜($n 个)" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} + +cmd_chart() { # 放某个榜单(名字或 id) + local q="$1"; [ -n "$q" ] || die usage "chart 需要榜单名(例:netease-music chart 飙升榜;全部榜单用 charts)" 2 + local j + j="$(api_out chart "$q" --limit "${LIMIT:-500}")" || exit $? + kv_api_total "$j" + kv chart "$(python3 -c 'import json,sys;d=json.load(sys.stdin);print(d.get("chart") or "")' <<<"$j")" + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $? + apply_shuffle + play_queue +} + +cmd_myalbums() { # 我收藏的专辑 + local j list n + j="$(api_out myalbums --limit "${LIMIT:-60}")" || exit $? + kv_api_total "$j" + n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + list="$(python3 -c 'import json,sys +for a in json.load(sys.stdin)["albums"]: + print(" %s %s(%s 首 · %s)" % (a["id"], a["name"], a["size"], a["artist"]))' <<<"$j")" + kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "我收藏的专辑($n 张)" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} + +cmd_myartists() { # 我收藏的歌手 + local j list n + j="$(api_out myartists --limit "${LIMIT:-60}")" || exit $? + kv_api_total "$j" + n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + list="$(python3 -c 'import json,sys +for a in json.load(sys.stdin)["artists"]: + print(" %s %s(专辑 %s 张)" % (a["id"], a["name"], a["albums"]))' <<<"$j")" + kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "我收藏的歌手($n 位)" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} + +cmd_fm() { # 私人 FM + local j + j="$(api_out fm --limit "${LIMIT:-12}")" || exit $? + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $? + apply_shuffle + play_queue +} + +cmd_recent() { # 最近播放(只列) + local j list n + j="$(api_out recent --limit "${LIMIT:-30}")" || exit $? + kv_api_total "$j" + n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + list="$(python3 -c 'import json,sys +for i, s in enumerate(json.load(sys.stdin)["songs"], 1): + print(" %2d %s - %s(播放 %s 次)" % (i, s["name"], s["artist"], s.get("plays", "?")))' <<<"$j")" + kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "最近播放($n 首)" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} + +cmd_replay() { # 再放一次最近第 N 首(默认 1 = 最近那首) + local n="${1:-1}" j id + j="$(api_out recent --limit 30)" || exit $? + id="$(python3 -c 'import json,sys +s = json.load(sys.stdin)["songs"]; i = int(sys.argv[1]) - 1 +print(s[i]["id"] if 0 <= i < len(s) else "")' "$n" <<<"$j")" + [ -n "$id" ] || die notfound "最近播放里没有第 $n 首" 4 + cmd_play "$id" +} + +cmd_podcasts() { # 我的播客订阅(--recommend 看推荐的) + local args=(podcasts --limit "${LIMIT:-50}") j list n + [ "${RECOMMEND:-0}" = 1 ] && args+=(--recommend) + j="$(api_out "${args[@]}")" || exit $? + kv_api_total "$j" + n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")" + list="$(python3 -c 'import json,sys +for p in json.load(sys.stdin)["podcasts"]: + print(" %s %s(%s 期 · %s)" % (p["id"], p["name"], p["episodes"], p["dj"]))' <<<"$j")" + kv count "$n"; kv source api; kv as_of "$(now)" + flush_result "我的播客($n 个)" + [ "$JSON" = 1 ] || printf '%s\n' "$list" +} + +cmd_podcast() { # 放某播客最新 N 期 + local q="$1"; [ -n "$q" ] || die usage "podcast 需要播客名或 id(例:netease-music podcast 黑水公园 --limit 3)" 2 + local j + j="$(api_out podcast "$q" --limit "${LIMIT:-3}")" || exit $? + kv_api_total "$j" + kv podcast "$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("name") or "")' <<<"$j")" + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $? + play_queue +} + +cmd_episode() { # 放某一期节目(要节目 id,从 podcast 列表里抄) + local q="$1"; [ -n "$q" ] || die usage "episode 需要节目 id(从 netease-music podcast <播客> 的输出里抄)" 2 + local j + j="$(api_out episode "$q")" || exit $? + kv episode "$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("name") or "")' <<<"$j")" + python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $? + play_queue +} + +# ---------- 入口 ---------- +CMD="${1:-status}" +case "$CMD" in -h|--help) CMD=help ;; -V|--version) CMD=version ;; esac +if [ $# -gt 0 ]; then shift; fi +ARGS=(); LIMIT=""; OFFSET=0; START=1; KEYWORD=""; QUALITY=""; TIMEOUT=300; REFRESH=4; SHUFFLE=0; SMS_PHONE=""; SMS_CODE=""; RECOMMEND=0 +while [ $# -gt 0 ]; do + case "$1" in + --json) JSON=1 ;; + --quiet|-q) QUIET=1 ;; + --limit) LIMIT="$2"; shift ;; + --offset) + [ $# -gt 1 ] && [[ "$2" =~ ^[0-9]+$ ]] || die usage "--offset 需要非负整数" 2 + OFFSET="$2"; shift ;; + --start) + [ "$CMD" = play-ids ] && [ $# -gt 1 ] && [[ "$2" =~ ^[0-9]+$ ]] || die usage "play-ids --start 需要从 1 开始的序号" 2 + START="$2"; shift ;; + --shuffle) SHUFFLE=1 ;; + --recommend) RECOMMEND=1 ;; + --timeout) TIMEOUT="$2"; shift ;; + --refresh) REFRESH="$2"; shift ;; + --sms) SMS_PHONE="$2"; shift ;; + --sms-code) SMS_CODE="$2"; shift ;; + *) ARGS+=("$1") ;; + esac + shift +done + +COOKIE="$(env_get NETEASE_MUSIC_COOKIE)"; [ -z "$COOKIE" ] && COOKIE="$(env_get PI_SKILL_NETEASE_MUSIC_COOKIE)"; [ -z "$COOKIE" ] && COOKIE="$(conf_get cookie)" +QUALITY="$(env_get NETEASE_MUSIC_QUALITY)"; [ -z "$QUALITY" ] && QUALITY="$(env_get PI_SKILL_NETEASE_MUSIC_QUALITY)"; [ -z "$QUALITY" ] && QUALITY="$(conf_get quality)"; [ -z "$QUALITY" ] && QUALITY=lossless +KEYWORD="${ARGS[0]:-}" + +case "$CMD" in + version) printf '%s %s\n' "netease-music" "$(cat "$SKILL_DIR/VERSION" 2>/dev/null || printf unknown)" ;; + help) cmd_help "${ARGS[0]:-}" ;; + setup) cmd_setup "$@" ;; + login) cmd_login ;; + check) cmd_check ;; + daily) need_deps; LIMIT="${LIMIT:-40}"; cmd_daily ;; + play) need_deps; cmd_play "${ARGS[0]:-}" ;; + play-ids) need_deps; cmd_play_ids ;; + search) need_deps; LIMIT="${LIMIT:-20}"; cmd_search ;; + cover) need_deps; api cover ${ARGS[@]+"${ARGS[@]}"} ;; + liked) need_deps; cmd_liked ;; + liked-ids) need_deps; cmd_liked_ids ;; + artist) need_deps; cmd_artist "${ARGS[0]:-}" ;; + album) need_deps; cmd_album "${ARGS[0]:-}" ;; + albums) need_deps; cmd_albums "${ARGS[0]:-}" ;; + queue) need_deps; cmd_queue ;; + remove) need_deps; cmd_remove ;; + clear) need_deps; cmd_clear ;; + jump) need_deps + jn="$(jump_target "${ARGS[0]:-}")" || exit $? + mpvctl_or_die jump "$jn" >/dev/null; kv command jump; kv jumped "${ARGS[0]}"; emit_status ;; + myplaylists) need_deps; cmd_myplaylists "${ARGS[0]:-}" ;; + playlist) need_deps; [ -n "${ARGS[0]:-}" ] || die usage "playlist 需要歌单名/id/链接(例:netease-music playlist 夜跑)" 2 + cmd_playlist "${ARGS[0]}" ;; + playlists) need_deps; cmd_playlists ;; + charts) need_deps; cmd_charts ;; + chart) need_deps; cmd_chart "${ARGS[0]:-}" ;; + myalbums) need_deps; cmd_myalbums ;; + myartists) need_deps; cmd_myartists ;; + fm) need_deps; cmd_fm ;; + recent) need_deps; cmd_recent ;; + replay) need_deps; cmd_replay "${ARGS[0]:-1}" ;; + podcasts) need_deps; cmd_podcasts ;; + podcast) need_deps; cmd_podcast "${ARGS[0]:-}" ;; + episode) need_deps; cmd_episode "${ARGS[0]:-}" ;; + queues) cmd_queues ;; + queue-save) cmd_queue_save "${ARGS[0]:-}" ;; + queue-load) need_deps; cmd_queue_load "${ARGS[0]:-}" ;; + back) need_deps; cmd_back ;; + add) need_deps; cmd_add "${ARGS[0]:-}" ;; + pause|resume|toggle|next|prev|stop|seek|volume|shuffle|repeat) + need_deps + if [ "$CMD" = repeat ] && [ "${#ARGS[@]}" -gt 1 ]; then die usage "用法:repeat off|list|single(on = list)" 2; fi + case "$CMD" in + seek|volume|shuffle|repeat) mpvctl_or_die "$CMD" "${ARGS[0]:-}" >/dev/null ;; + *) mpvctl_or_die "$CMD" >/dev/null ;; + esac + [ "$CMD" = stop ] && keepalive_stop + kv command "$CMD" + [ -n "${ARGS[0]:-}" ] && kv requested_arg "${ARGS[0]}" + emit_status ;; # 变更后回读真值:命令生效与否,看这里的状态 + status) need_deps; cmd_status ;; + like|unlike) need_deps; [ "$CMD" = unlike ] && set -- --unlike || set --; cmd_like "$@" ;; + dislike) need_deps; cmd_dislike ;; + lyric) need_deps; cmd_lyric "${ARGS[0]:-}" ;; + *) die usage "未知命令:${CMD}(跑 netease-music help)" 2 ;; +esac diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..d06651c --- /dev/null +++ b/scripts/publish.sh @@ -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" diff --git a/scripts/refresh-urls.sh b/scripts/refresh-urls.sh new file mode 100755 index 0000000..3d91255 --- /dev/null +++ b/scripts/refresh-urls.sh @@ -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 \ No newline at end of file diff --git a/tests/fixtures/api-fetch.mjs b/tests/fixtures/api-fetch.mjs new file mode 100644 index 0000000..fd4092b --- /dev/null +++ b/tests/fixtures/api-fetch.mjs @@ -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 }) }; +}; diff --git a/tests/fixtures/mpv-ipc.mjs b/tests/fixtures/mpv-ipc.mjs new file mode 100644 index 0000000..424b06e --- /dev/null +++ b/tests/fixtures/mpv-ipc.mjs @@ -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(); diff --git a/tests/pagination.test.mjs b/tests/pagination.test.mjs new file mode 100644 index 0000000..aab9407 --- /dev/null +++ b/tests/pagination.test.mjs @@ -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); +});