From c28d3291b0d91bdafa678c0371c039515cb2e50b Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Fri, 2 Oct 2026 07:43:55 +0800 Subject: [PATCH] release v0.1.3 --- MANIFEST.json | 10 +- README.md | 2 +- REFERENCE.md | 333 ---------- VERSION | 1 - package.json | 2 +- scripts/install-cli.sh | 28 - scripts/lib/build-queue.py | 69 -- scripts/lib/mpv-map.py | 145 ----- scripts/lib/queue-append.py | 39 -- scripts/lib/queue-edit.py | 55 -- scripts/lib/snapshot.py | 129 ---- scripts/mpv-ctl.mjs | 309 --------- scripts/netease-api.mjs | 747 ---------------------- scripts/netease-music.sh | 1159 ---------------------------------- scripts/refresh-urls.sh | 100 --- skills/netease-music/VERSION | 2 +- 16 files changed, 8 insertions(+), 3122 deletions(-) delete mode 100644 REFERENCE.md delete mode 100644 VERSION delete mode 100755 scripts/install-cli.sh delete mode 100644 scripts/lib/build-queue.py delete mode 100644 scripts/lib/mpv-map.py delete mode 100644 scripts/lib/queue-append.py delete mode 100644 scripts/lib/queue-edit.py delete mode 100644 scripts/lib/snapshot.py delete mode 100644 scripts/mpv-ctl.mjs delete mode 100644 scripts/netease-api.mjs delete mode 100755 scripts/netease-music.sh delete mode 100755 scripts/refresh-urls.sh diff --git a/MANIFEST.json b/MANIFEST.json index 391c622..ef6093d 100644 --- a/MANIFEST.json +++ b/MANIFEST.json @@ -4,13 +4,13 @@ "summary": "网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)", "tier": 1, "status": "stable", - "version": "c23f1d1", + "version": "8c2afc6", "source": { "path": "skills/netease-music", - "commit": "c23f1d1", - "describe": "c23f1d1", + "commit": "8c2afc6", + "describe": "8c2afc6", "dirty": false, - "packed_at": "2026-10-01T23:43:33.462Z" + "packed_at": "2026-10-01T23:43:55.098Z" }, "platforms": [ "darwin" @@ -327,7 +327,7 @@ { "path": "VERSION", "bytes": 6, - "sha256": "297b50f08386d352" + "sha256": "2d2454ef0edeb462" }, { "path": "contract.lock.json", diff --git a/README.md b/README.md index eaacfec..db58821 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## 安装 ```sh -pi install git:gitea.vhkd.top/geekinney/netease-music.git@v0.1.2 +pi install git:gitea.vhkd.top/geekinney/netease-music.git@v0.1.3 ``` 装完 `pi-skill list` 能看到 `netease-music`,`pi-skill netease-music check` 会告诉还缺什么。 diff --git a/REFERENCE.md b/REFERENCE.md deleted file mode 100644 index 53c0991..0000000 --- a/REFERENCE.md +++ /dev/null @@ -1,333 +0,0 @@ -# netease-music 参考(架构 / 接口 / 播放器 / 坑) - -## 1. 为什么是这套架构 - -以前的做法是模拟鼠标点官方客户端界面:慢(每次要点窗口、等动画)、脆(页面结构一变就废)、功能受界面限制, -连「是不是在播」都只能靠截屏看进度条,结果误判过(时间读数卡住但仍报 paused)。 - -现在三层,各自可单独验证: - -| 层 | 文件 | 职责 | -|---|---|---| -| 取数 | `scripts/netease-api.mjs` | 直连 `music.163.com` 的 weapi(自写加密,**零 npm 依赖**,不跑后台服务) | -| 播放 | `scripts/mpv-ctl.mjs` | 常驻 mpv + unix socket JSON IPC:播放/暂停/切歌/音量/进度/真状态 | -| 队列 | `scripts/lib/build-queue.py` | 歌曲列表 + 播放地址 → `queue.json`(反查 id)+ `queue.m3u8`(mpv 队列,带 EXTINF 标题) | -| 入口 | `scripts/netease-music.sh` | 用户意图 → 上面三层 | - -**为什么不用 NeteaseCloudMusicApi(npm 那个项目)**:要装一大棵依赖树 + 常驻 HTTP 服务,而本机只用到十来个端点; -自写客户端启动 0 开销、不受上游版本变动影响。协议细节见下,改起来有据可依。 - -## 2. 接口协议(实测) - -`POST https://music.163.com/weapi/<路径>?csrf_token=`,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),发布用共享的 -`skill-release cli|package`(声明在 interface.json 的 `publish`,见 skill-interface 的 REFERENCE §12)。**命名规范**:公开镜像仓叫 `<工具>-cli`(这里是 -`netease-music-cli`),装出来的命令仍叫 `<工具>`(`netease-music`)——仓名标交付物类型, -命令名只管好敲。消费者(Emacs 包 etaf-ncm、其他脚本)只依赖本节与 `--json` 输出, -不读实现、不读状态文件。 - -- **版本**:`netease-music version` / `--version`(读 `VERSION`)。破坏性改动抬高 minor(0.x 期间),并在本节记录。 -- **调用**:`netease-music <命令> [参数] [--json] [--quiet]`;完整命令面见 `netease-music help`。 -- **JSON**:`--json` 时 stdout 是单个对象。列表命令的字段与分页语义见 §2.1;状态与队列如下表。 - -| 命令 | 字段 | -|---|---| -| `status --json` | `state`(idle\|playing\|paused) `title` `artist` `song_id` `elapsed` `duration` `volume` `queue_pos`("i/n") `shuffle` `repeat` `vip` `quality` `as_of` | -| `queue --json` | `count` `pos`(1 基) `songs[]`(歌曲对象同 §2.1) | - -- **环境变量**:`NETEASE_MUSIC_STATE_DIR`(默认 `/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/VERSION b/VERSION deleted file mode 100644 index d917d3e..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.2 diff --git a/package.json b/package.json index 9e878e9..6aea2c1 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "netease-music", - "version": "0.1.2", + "version": "0.1.3", "description": "网易云音乐遥控器:一句话对上云里的内容(每日推荐 / 歌单 / 榜单 / 收藏 / FM / 最近 / 播客)并放出来;播放侧管好本机队列现场(快照 / back / 追加)" } diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh deleted file mode 100755 index b33f47b..0000000 --- a/scripts/install-cli.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh -# standalone 安装:把本技能目录当纯 CLI 用(不需要 Pi)。 -# 用法:scripts/install-cli.sh [--prefix DIR];默认 ~/.local。 -set -eu -here="$(cd "$(dirname "$0")/.." && pwd)" -prefix="$HOME/.local" -while [ $# -gt 0 ]; do - case "$1" in - --prefix) prefix="$2"; shift 2 ;; - -h|--help) printf '用法:%s [--prefix DIR](默认 ~/.local;链接 /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 deleted file mode 100644 index 812b1c8..0000000 --- a/scripts/lib/build-queue.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/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 deleted file mode 100644 index 72309bf..0000000 --- a/scripts/lib/mpv-map.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 -"""mpv-map — 把 mpv 的「当前在放哪首 / 队列第几项」映射回 queue.json 里的歌。 - -为什么需要它:mpv 开着 shuffle 时播放顺序是它自己排的,`playlist-pos` -指的是那条打乱过的顺序,直接拿去索引 queue.json 会报错歌(实测: -status 说在放《野心家》,实际在放《All Of Me/Say Something》)。 - -每首歌在队列里有两种稳定身份,建队时都会写进去: - 1. m3u8 的 #EXTINF 标题写成 "[#] <歌手> - <歌名>",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 deleted file mode 100644 index f744193..0000000 --- a/scripts/lib/queue-append.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""把一首歌追加进队列文件,并写出给 mpv 用的「只有这一首」的临时 m3u。 - -参数:queue.json queue.m3u8 tmp.m3u id 歌名 歌手 毫秒 url -输出(JSON):{"ok": true, "count": N, "name", "artist", "id"} 或 {"ok": false, "reason": "duplicate", "count": N} -""" -import json -import sys - - -def main(): - if len(sys.argv) < 9: - print('用法:queue-append.py <queue.json> <queue.m3u8> <tmp.m3u> <id> <歌名> <歌手> <毫秒> <url>', file=sys.stderr) - return 2 - qpath, mpath, tpath, sid, name, artist, ms, url = sys.argv[1:9] - q = json.load(open(qpath, encoding='utf-8')) - songs = q.get('songs') or [] - new = {'id': int(sid), 'name': name, 'artist': artist, 'ms': int(ms or 0)} - if any(s.get('id') == new['id'] for s in songs): - print(json.dumps({'ok': False, 'reason': 'duplicate', 'count': len(songs)}, ensure_ascii=False)) - return 0 - songs.append(new) - q['songs'] = songs - q['count'] = len(songs) - with open(qpath, 'w', encoding='utf-8') as f: - json.dump(q, f, ensure_ascii=False) - # 标题前缀 [#id] 与 build-queue.py 一致,供 mpv-map.py 映射回歌曲 - entry = "#EXTINF:%d,[#%s] %s - %s\n%s\n" % ((new['ms'] or 0) // 1000, new['id'], artist, name, url) - with open(mpath, 'a', encoding='utf-8') as f: - f.write(entry) - with open(tpath, 'w', encoding='utf-8') as f: - f.write('#EXTM3U\n' + entry) - print(json.dumps({'ok': True, 'count': len(songs), 'name': name, 'artist': artist, 'id': new['id']}, - ensure_ascii=False)) - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/scripts/lib/queue-edit.py b/scripts/lib/queue-edit.py deleted file mode 100644 index acfbe55..0000000 --- a/scripts/lib/queue-edit.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -"""准备 remove 的本地队列文件;按 mpv 的实际顺序映射,IPC 成功后才由入口安装。 - -用法:queue-edit.py <local-dir> <playlist.json> <1-based index> <stage-dir> -""" -import importlib.util -import json -from pathlib import Path -import sys -import time - -spec = importlib.util.spec_from_file_location('mpv_map', Path(__file__).with_name('mpv-map.py')) -mpv_map = importlib.util.module_from_spec(spec) -spec.loader.exec_module(mpv_map) - - -def main(): - local, playlist, index, stage = sys.argv[1:] - local, stage = Path(local), Path(stage) - entries = json.loads(Path(playlist).read_text())['entries'] - index = int(index) - 1 - if not 0 <= index < len(entries): - print('error=notfound\nhint=队列里没有这个序号', file=sys.stderr) - return 4 - queue = json.loads((local / 'queue.json').read_text()) - urls = mpv_map.load_urls(local / 'urls.txt') - songs, lines, url_lines = [], ['#EXTM3U'], [] - for i, entry in enumerate(entries): - if i == index: - continue - song = mpv_map.resolve(queue['songs'], urls, entry['title'], entry['filename']) - if not song or not entry['filename']: - print('error=notfound\nhint=mpv 条目无法映射到本地歌曲,队列未修改', file=sys.stderr) - return 4 - songs.append(song) - lines.extend([ - '#EXTINF:%d,[#%s] %s - %s' % ((song.get('ms') or 0) // 1000, song['id'], song['artist'], song['name']), - entry['filename'], - ]) - url_lines.append('%s\t%s\t%s' % (song['id'], song.get('level', ''), entry['filename'])) - queue.update(songs=songs, count=len(songs), as_of=time.strftime('%Y-%m-%dT%H:%M:%S%z')) - queue.pop('start', None) - (stage / 'queue.json').write_text(json.dumps(queue, ensure_ascii=False), encoding='utf-8') - (stage / 'songs.json').write_text(json.dumps(songs, ensure_ascii=False), encoding='utf-8') - (stage / 'queue.m3u8').write_text('\n'.join(lines) + '\n', encoding='utf-8') - (stage / 'urls.txt').write_text('\n'.join(url_lines) + ('\n' if url_lines else ''), encoding='utf-8') - return 0 - - -if __name__ == '__main__': - try: - sys.exit(main()) - except (OSError, ValueError, KeyError) as error: - print('error=external\nhint=无法编辑本地队列:%s' % error, file=sys.stderr) - sys.exit(5) diff --git a/scripts/lib/snapshot.py b/scripts/lib/snapshot.py deleted file mode 100644 index e7649fd..0000000 --- a/scripts/lib/snapshot.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -"""队列快照:把「当前队列」存成可回放的文件。 - -只存歌曲列表(id/歌名/歌手/时长),不存播放地址 —— 那种 URL 二十分钟就过期。 -回放时按列表重新取地址,所以快照可以长期留着。 - -用法: - snapshot.py save <queue.json> <快照文件> <位置> <shuffle:true|false> [名字] - snapshot.py list <快照目录> 列出全部快照(JSON 数组) - snapshot.py songs <快照文件> 只打印歌曲列表(喂给 build_queue) - snapshot.py meta <快照文件> 打印元信息(名字/首数/位置/shuffle/来源/时间) -""" -import datetime -import json -import os -import sys - -KEEP_NAMED = 20 # 命名快照最多留 20 条,多的按时间删最旧(prev.json 不受影响) - - -def _now(): - return datetime.datetime.now().astimezone().isoformat(timespec='seconds') - - -def cmd_save(queue_path, out_path, pos, shuffle, name='', song_id=''): - try: - q = json.load(open(queue_path, encoding='utf-8')) - except Exception: - return 2 - songs = q.get('songs') or [] - if not songs: - return 2 - out = { - 'name': name or q.get('source') or 'unnamed', - 'saved_at': _now(), - 'source': q.get('source') or '', - 'pos': int(pos or -1), - 'shuffle': str(shuffle).lower() == 'true', - # 开随机后 pos 是打乱顺序里的位置,回放靠 song_id 认「当时在放的那首」 - 'song_id': int(song_id) if str(song_id).strip().isdigit() else None, - 'count': len(songs), - 'songs': songs, - } - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, 'w', encoding='utf-8') as f: - json.dump(out, f, ensure_ascii=False) - _prune(os.path.dirname(out_path)) - print('%s\t%d\t%s' % (out['name'], out['count'], out['saved_at'])) - return 0 - - -def _prune(d): - named = [] - for fn in os.listdir(d): - if not fn.endswith('.json') or fn.startswith('.') or fn == 'prev.json': - continue - try: - meta = json.load(open(os.path.join(d, fn), encoding='utf-8')) - except Exception: - continue - named.append((meta.get('saved_at') or '', fn)) - named.sort() - for _, fn in named[:-KEEP_NAMED] if len(named) > KEEP_NAMED else []: - try: - os.remove(os.path.join(d, fn)) - except OSError: - pass - - -def cmd_list(d): - rows = [] - for fn in sorted(os.listdir(d)) if os.path.isdir(d) else []: - if not fn.endswith('.json') or fn.startswith('.'): - continue - try: - meta = json.load(open(os.path.join(d, fn), encoding='utf-8')) - except Exception: - continue - rows.append({ - 'file': fn, - 'name': meta.get('name') or fn[:-5], - 'count': meta.get('count') or len(meta.get('songs') or []), - 'saved_at': meta.get('saved_at') or '', - 'source': meta.get('source') or '', - 'pos': meta.get('pos', -1), - }) - rows.sort(key=lambda r: (r['file'] != 'prev.json', r['saved_at']), reverse=False) - json.dump(rows, sys.stdout, ensure_ascii=False) - return 0 - - -def cmd_songs(p): - try: - d = json.load(open(p, encoding='utf-8')) - except Exception: - return 4 - json.dump(d.get('songs') or [], sys.stdout, ensure_ascii=False) - return 0 - - -def cmd_meta(p): - try: - d = json.load(open(p, encoding='utf-8')) - except Exception: - return 4 - json.dump({k: d.get(k) for k in ('name', 'count', 'pos', 'shuffle', 'song_id', 'source', 'saved_at')}, - sys.stdout, ensure_ascii=False) - return 0 - - -def main(): - if len(sys.argv) < 3: - print(__doc__.strip().splitlines()[0], file=sys.stderr) - return 2 - cmd, args = sys.argv[1], sys.argv[2:] - if cmd == 'save': - return cmd_save(*(args + ['', '', '', '', ''])[:6]) - if cmd == 'list': - return cmd_list(args[0]) - if cmd == 'songs': - return cmd_songs(args[0]) - if cmd == 'meta': - return cmd_meta(args[0]) - print('未知子命令:%s' % cmd, file=sys.stderr) - return 2 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/scripts/mpv-ctl.mjs b/scripts/mpv-ctl.mjs deleted file mode 100644 index 9adcf0c..0000000 --- a/scripts/mpv-ctl.mjs +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env node -// mpv-ctl — 后台 mpv 的生命周期与 IPC 控制(网易云播放的「播放器层」) -// -// 为什么这样:mpv 常驻(--idle=yes)+ unix socket 上跑 JSON IPC, -// 所以播放/暂停/切歌/音量都是毫秒级命令,状态(时间、进度、曲名)也是真值, -// 不再靠截屏看进度条猜。mpv 自己会接着放歌单,不需要守护进程。 -// -// 输出:JSON(内部原语);退出码 0 成功 / 2 用法 / 3 缺依赖 / 5 执行失败 -// 用法: -// mpv-ctl play <m3u|url> [--title T] [--index N] 建队列并开播 -// mpv-ctl status 当前状态(真值) -// mpv-ctl pause|resume|toggle|next|prev|stop -// mpv-ctl volume [0-130] | seek <秒|+n|-n> | shuffle on|off | repeat off|list|single (on=list) -// mpv-ctl append <m3u|url> | remove <1-based index> | clear -import net from 'node:net'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { spawn } from 'node:child_process'; - -const DIR = process.env.NETEASE_MPV_DIR || process.env.NETEASE_MUSIC_STATE_DIR - || path.join(process.env.PI_AGENT_DIR || path.join(os.homedir(), '.pi/agent'), 'local', 'skills', 'netease-music'); -const SOCK = path.join(DIR, 'mpv.sock'); -const LOG = path.join(DIR, 'mpv.log'); -fs.mkdirSync(DIR, { recursive: true }); - -const die = (code, hint, rc) => { - console.error(JSON.stringify({ error: code, hint })); - process.exit(rc); -}; - -function connect(timeoutMs = 1500) { - return new Promise((resolve) => { - const s = net.connect(SOCK); - const t = setTimeout(() => { - s.destroy(); - resolve(null); - }, timeoutMs); - s.once('connect', () => { - clearTimeout(t); - resolve(s); - }); - s.once('error', () => { - clearTimeout(t); - s.destroy(); - resolve(null); - }); - }); -} - -function makeClient(sock) { - let buf = ''; - let id = 0; - const pending = new Map(); - sock.on('data', (d) => { - buf += d.toString(); - let i; - while ((i = buf.indexOf('\n')) >= 0) { - const line = buf.slice(0, i); - buf = buf.slice(i + 1); - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - if (msg.request_id && pending.has(msg.request_id)) { - const { resolve } = pending.get(msg.request_id); - pending.delete(msg.request_id); - resolve(msg.error === 'success' || msg.error === undefined ? msg.data : { __error: msg.error }); - } - } - }); - const send = (command, timeoutMs = 4000) => - new Promise((resolve) => { - const rid = ++id; - const t = setTimeout(() => { - pending.delete(rid); - resolve({ __error: 'timeout' }); - }, timeoutMs); - pending.set(rid, { - resolve: (v) => { - clearTimeout(t); - resolve(v); - }, - }); - sock.write(JSON.stringify({ command, request_id: rid }) + '\n'); - }); - return { send, close: () => sock.end() }; -} - -async function ensureMpv() { - let sock = await connect(300); - if (sock) return makeClient(sock); - const out = fs.openSync(LOG, 'a'); - const child = spawn( - 'mpv', - ['--idle=yes', '--no-video', '--force-window=no', '--audio-display=no', '--no-terminal', `--input-ipc-server=${SOCK}`, '--volume=70'], - { detached: true, stdio: ['ignore', out, out] }, - ); - child.unref(); - const deadline = Date.now() + 8000; - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 250)); - sock = await connect(300); - if (sock) return makeClient(sock); - } - die('deps', `mpv 没起来(日志 ${LOG}):确认 brew install mpv 成功`, 3); -} - -const argv = process.argv.slice(2); -const CMD = argv[0]; -const opts = {}; -const pos = []; -for (let i = 1; i < argv.length; i++) { - if (argv[i] === '--title') opts.title = argv[++i]; - else if (argv[i] === '--index') opts.index = +argv[++i]; - else pos.push(argv[i]); -} - -const GOOD = { - pause: ['set_property', 'pause', true], - resume: ['set_property', 'pause', false], -}; -async function main() { - const repeatMode = (pos[0] || 'on') === 'on' ? 'list' : pos[0]; - if (CMD === 'repeat' && (pos.length > 1 || !['off', 'list', 'single'].includes(repeatMode))) { - die('usage', '用法:repeat off|list|single(on = list)', 2); - } - if (CMD === 'remove' && (pos.length !== 1 || !/^\d+$/.test(pos[0]) || !Number.isSafeInteger(+pos[0]) || +pos[0] < 1)) { - die('usage', '用法:remove <队列序号,从 1 开始>', 2); - } - if (CMD === 'clear' && pos.length) die('usage', '用法:clear', 2); - if (CMD === 'play' && opts.index !== undefined && (!Number.isSafeInteger(opts.index) || opts.index < 0)) { - die('usage', '--index 需要从 0 开始的序号', 2); - } - const c = await ensureMpv(); - const get = (p) => c.send(['get_property', p]); - const checked = async (command) => { - const result = await c.send(command); - if (result && result.__error) die('external', `mpv 报错:${result.__error}`, 5); - return result; - }; - let out = { command: CMD }; - switch (CMD) { - case 'play': { - const target = pos[0]; - if (!target) die('usage', '用法:mpv-ctl play <m3u|url> [--title T] [--index N]', 2); - const isUrl = /^https?:/.test(target); - const isList = /\.(m3u8?|pls)$/i.test(target); - const cmd = isUrl || !isList ? ['loadfile', target, 'replace'] : ['loadlist', target, 'replace']; - if (isUrl && opts.title) cmd.splice(2, 0, `--force-media-title=${opts.title}`); - // Pause first: a load starts the first entry immediately, so an indexed - // start (or any later adjustment) would leak a fraction of the wrong - // song as a click. Silence the swap, position, then resume. - await checked(['set_property', 'pause', true]); - const r = await c.send(cmd); - if (r && r.__error) die('external', `mpv 拒绝播放:${r.__error}`, 5); - if (opts.index !== undefined) await checked(['set_property', 'playlist-pos', opts.index]); - await checked(['set_property', 'pause', false]); - out.playing = true; - break; - } - case 'append': { - const target = pos[0]; - if (!target) die('usage', '用法:mpv-ctl append <m3u|url>', 2); - const isUrl = /^https?:/.test(target); - const r = await c.send(isUrl ? ['loadfile', target, 'append'] : ['loadlist', target, 'append']); - if (r && r.__error) die('external', `mpv 拒绝追加:${r.__error}`, 5); - out.count = await get('playlist-count'); - break; - } - case 'pause': - case 'resume': - case 'toggle': { - let cmd = GOOD[CMD]; - if (CMD === 'toggle') cmd = ['set_property', 'pause', !(await c.send(['get_property', 'pause']))]; - const r = await c.send(cmd); - if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5); - out.paused = await get('pause'); - break; - } - case 'next': - case 'prev': { - const r = await c.send([CMD === 'next' ? 'playlist-next' : 'playlist-prev', 'force']); - if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5); - break; - } - case 'stop': - case 'clear': { - await checked(['stop']); - await checked(['playlist-clear']); - out.stopped = true; - break; - } - case 'remove': { - const index = +pos[0] - 1; - const [count, current, paused] = await Promise.all([get('playlist-count'), get('playlist-pos'), get('pause')]); - if (!Number.isInteger(count) || index >= count) die('notfound', `队列里没有第 ${pos[0]} 首`, 4); - if (index === current) { - if (count === 1) { - await checked(['stop']); - await checked(['playlist-clear']); - } else { - // 先选下一首,末尾则选上一首;不受循环模式影响,并保留暂停状态。 - await checked(['set_property', 'playlist-pos', index + 1 < count ? index + 1 : index - 1]); - await checked(['playlist-remove', index]); - await checked(['set_property', 'pause', paused === true]); - } - } else await checked(['playlist-remove', index]); - out.removed = index + 1; - out.count = await get('playlist-count'); - break; - } - case 'seek': { - const v = pos[0] ?? '0'; - const r = await c.send(['seek', v, 'relative']); - if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5); - break; - } - case 'volume': { - if (pos[0]) { - await c.send(['set_property', 'volume', Number(pos[0])]); - } - out.volume = await get('volume'); - break; - } - case 'shuffle': { - const on = (pos[0] || 'on') !== 'off'; - await checked(['set_property', 'shuffle', on]); - out.shuffle = on; - break; - } - case 'repeat': { - // 每次同时设置两个属性,避免从 single 切换后仍单曲循环。 - await checked(['set_property', 'loop-file', repeatMode === 'single' ? 'inf' : 'no']); - await checked(['set_property', 'loop-playlist', repeatMode === 'list' ? 'inf' : 'no']); - out.repeat = repeatMode === 'list'; // 保留原字段含义:是否整列循环。 - out.repeat_mode = repeatMode; - break; - } - case 'playlist': { - const [ppos, list] = await Promise.all([get('playlist-pos'), get('playlist')]); - const entries = Array.isArray(list) ? list : []; - out = { - command: 'playlist', - pos: Number.isInteger(ppos) ? ppos : -1, - count: entries.length, - entries: entries.map((e, i) => ({ - index: i + 1, - title: typeof e.title === 'string' ? e.title : '', - filename: typeof e.filename === 'string' ? e.filename : '', - duration: Math.round(e.duration || 0), - })), - }; - break; - } - case 'jump': { - const n = Number(pos[0]); - if (!Number.isInteger(n) || n < 1) die('usage', '用法:mpv-ctl jump <队列第几首,从 1 开始>', 2); - const count = await get('playlist-count'); - if (!Number.isFinite(count) || count < 1) die('notfound', '队列是空的(先 play/daily/liked/artist/album 建队列)', 4); - if (n > count) die('notfound', `队列只有 ${count} 首,没有第 ${n} 首`, 4); - const r = await c.send(['set_property', 'playlist-pos', n - 1]); - if (r && r.__error) die('external', `mpv 报错:${r.__error}`, 5); - await c.send(['set_property', 'pause', false]); - out = { command: 'jump', jumped: n, count }; - break; - } - case 'status': { - const [idle, paused, tpos, dur, vol, ppos, pcount, title, path_, loop, fileLoop, shuffle] = await Promise.all([ - get('idle-active'), - get('pause'), - get('time-pos'), - get('duration'), - get('volume'), - get('playlist-pos'), - get('playlist-count'), - get('media-title'), - get('path'), - get('loop-playlist'), - get('loop-file'), - get('shuffle'), - ]); - out = { - command: 'status', - idle: idle === true, - playing: idle === false && paused === false, - paused: paused === true, - pos: Number.isFinite(ppos) ? ppos : -1, - count: Number.isFinite(pcount) ? pcount : 0, - elapsed: Number.isFinite(tpos) ? Math.round(tpos) : null, - duration: Number.isFinite(dur) ? Math.round(dur) : null, - volume: Number.isFinite(vol) ? vol : null, - title: typeof title === 'string' ? title : '', - path: typeof path_ === 'string' ? path_ : '', - repeat: loop === 'inf', - repeat_mode: fileLoop === 'inf' ? 'single' : loop === 'inf' ? 'list' : 'off', - shuffle: shuffle === true, - }; - break; - } - default: - die('usage', `未知命令 ${CMD || '(空)'}:play/append/status/playlist/jump/remove/clear/pause/resume/toggle/next/prev/stop/volume/seek/shuffle/repeat`, 2); - } - console.log(JSON.stringify(out)); - c.close(); -} -main().catch((e) => die('internal', String(e && e.message ? e.message : e), 5)); diff --git a/scripts/netease-api.mjs b/scripts/netease-api.mjs deleted file mode 100644 index f67161b..0000000 --- a/scripts/netease-api.mjs +++ /dev/null @@ -1,747 +0,0 @@ -#!/usr/bin/env node -// netease-api — 网易云接口客户端(自包含,零 npm 依赖) -// -// 原理:网页端所有请求都走 /weapi/<路径>,参数用「AES-128-CBC 两次 + RSA 加密密钥」包装, -// 这套加密是固定常量(见下方 NONCE/IV/MODULUS),所以不需要跑任何 API 服务,直接调。 -// 登录态就是 MUSIC_U cookie,由调用方从环境变量 NETEASE_COOKIE 传进来。 -// -// 输出:统一 JSON 到 stdout(内部原语,不给人读);出错时 status 非 0 + {"error":..,"hint":..} -// 用法:node netease-api.mjs <命令> [参数] -// 列表命令支持 --offset N(默认 0):跳过 N 条;count 是本页条数,total 是分页前总数 -// search <关键词> [--limit 20] 搜歌 -// song <关键词|id> 解析成一首歌(关键词取最匹配一条) -// play-ids <id,id,...> [--start N] 批量详情(只取数);start 从 1 开始,默认 1 -// cover <关键词|id> [--download PATH] [--force] 封面 JSON;可下载图片(最多 10 MB) -// url <id...> [--level lossless] 取播放地址(有 VIP 就取无损) -// daily [--limit 40] 每日推荐 -// playlist <id|链接> [--limit 500] 歌单全部歌曲 -// artist <关键词|id> [--limit 50] 歌手热门 50 首 -// album <关键词|id> [--limit 200] 专辑整张 -// albums <歌手|id> [--limit 60] 歌手的专辑列表(含搜索索引里没有的老专辑) -// playlists <关键词> [--limit 20] 搜歌单(别人做的) -// charts [--limit 100] 排行榜列表 -// chart <名字|id> [--limit 500] 放某个榜单 -// myalbums / myartists [--limit 60] 我收藏的专辑 / 歌手 -// fm [--limit 12] 私人 FM -// recent [--limit 30] 最近播放(一周) -// podcasts [--recommend] [--limit 50] 我的播客订阅(或推荐) -// podcast <名字|id> [--limit 3] 某播客最新 N 期 -// episode <节目id> 某一期节目 -// liked [--limit 1000] 我喜欢的音乐(按收藏时间由近到远) -// liked-ids 我喜欢的歌曲 ID 全表(只读) -// dislike <id> 私人 FM 垃圾桶(fm/trash) -// like <id> [--unlike] 喜欢/取消喜欢 -// lyric <id> [--trans] 歌词(--trans 带翻译) -// myplaylists [--limit 100] 我的歌单(含「我喜欢的音乐」) -// session 登录态与账号信息 -// uid 当前账号 uid(未登录报错) -// qr-start 生成扫码登录的二维码内容与 key -// qr-poll <key> [--timeout 120] 轮询扫码结果,成功时输出 cookie -import crypto from 'node:crypto'; -import { writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; - -const NONCE = '0CoJUm6Qyw8W8jud'; -const IV = '0102030405060708'; -const PUBKEY = '010001'; -const MODULUS = - '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'; -const UA = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; -const COOKIE = process.env.NETEASE_COOKIE || ''; - -function aesEncrypt(text, key) { - const c = crypto.createCipheriv('aes-128-cbc', Buffer.from(key), Buffer.from(IV)); - return Buffer.concat([c.update(text, 'utf8'), c.final()]).toString('base64'); -} -function modPow(b, e, m) { - let r = 1n; - b %= m; - while (e > 0n) { - if (e & 1n) r = (r * b) % m; - b = (b * b) % m; - e >>= 1n; - } - return r; -} -function rsaEncrypt(text) { - const hex = Buffer.from(text).reverse().toString('hex'); - return modPow(BigInt('0x' + hex), BigInt('0x' + PUBKEY), BigInt('0x' + MODULUS)) - .toString(16) - .padStart(256, '0'); -} -function cookieValue(name) { - const m = (COOKIE || '').match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)')); - return m ? m[1] : ''; -} - -function weapi(data) { - const secret = crypto.randomBytes(8).toString('hex').slice(0, 16); - const plain = JSON.stringify({ ...data, csrf_token: cookieValue('__csrf') }); - return { - params: aesEncrypt(aesEncrypt(plain, NONCE), secret), - encSecKey: rsaEncrypt(secret), - }; -} - -class ApiError extends Error { - constructor(code, hint) { - super(hint); - this.code = code; - this.hint = hint; - } -} - -async function plainPost(path, data) { - let res; - try { - res = await fetch('https://music.163.com/' + path, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Referer: 'https://music.163.com/', - 'User-Agent': UA, - ...(COOKIE ? { Cookie: COOKIE } : {}), - }, - body: new URLSearchParams(data).toString(), - }); - } catch (e) { - throw new ApiError('external', '连不上 music.163.com:' + e.message); - } - lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : []; - const text = await res.text(); - if (!text) throw new ApiError('external', `接口返回空(HTTP ${res.status})`); - return JSON.parse(text); -} - -let lastSetCookie = []; - -// 登录会分多步下发 Set-Cookie,同名会有多条;按名字去重(取最后一条),避免服务端看到重复 cookie -function jarToCookie(jar) { - const map = new Map(); - for (const c of jar) { - const kv = c.split(';')[0]; - const i = kv.indexOf('='); - if (i > 0) map.set(kv.slice(0, i).trim(), kv.slice(i + 1)); - } - return [...map].map(([k, v]) => `${k}=${v}`).join('; '); -} -async function weapiPost(path, data = {}) { - const body = new URLSearchParams(weapi(data)).toString(); - let res; - try { - res = await fetch('https://music.163.com/weapi/' + path + '?csrf_token=' + encodeURIComponent(cookieValue('__csrf')), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Referer: 'https://music.163.com/', - 'User-Agent': UA, - ...(COOKIE ? { Cookie: COOKIE } : {}), - }, - body, - }); - } catch (e) { - throw new ApiError('external', '连不上 music.163.com:' + e.message); - } - lastSetCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : []; - const text = await res.text(); - let json; - try { - json = JSON.parse(text); - } catch { - throw new ApiError('external', `接口返回的不是 JSON(HTTP ${res.status}):${text.slice(0, 120)}`); - } - return json; -} - -const song = (s) => ({ - id: s.id, - name: s.name, - artist: (s.ar || s.artists || []).map((a) => a.name).join(' / '), - album: (s.al || s.album || {}).name || '', - ms: s.dt || s.duration || 0, - vip: s.fee === 1 || s.fee === 4, - cover: String(s.al?.picUrl || s.album?.picUrl || s.picUrl || ''), - // 没有明确布尔值就不猜;调用方可用 liked-ids 一次取全表后按 id 补齐。 - ...(typeof s.liked === 'boolean' ? { liked: s.liked } : {}), -}); -const num = (v) => (Number.isFinite(+v) ? +v : 0); - -function songId(value) { - if (!/^\d+$/.test(value || '') || !Number.isSafeInteger(+value) || +value < 1) { - throw new ApiError('usage', '歌曲 id 需要正整数'); - } - return +value; -} - -async function cmdPlayIds(args, o) { - if (args.length !== 1) throw new ApiError('usage', '用法:play-ids <id,id,...> [--start N]'); - const ids = args[0].split(',').map(songId); - if (!Number.isSafeInteger(o.start) || o.start < 1 || o.start > ids.length) { - throw new ApiError('usage', '--start 需要输入列表内的序号(从 1 开始)'); - } - const songs = []; - for (let i = 0; i < ids.length; i += 200) songs.push(...await songsByIds(ids.slice(i, i + 200))); - if (songs.length !== ids.length) throw new ApiError('notfound', '输入列表中有歌曲详情缺失,队列未替换'); - return { count: songs.length, total: ids.length, start: o.start, songs }; -} - -// 搜索必须走明文接口:weapi 版的 cloudsearch 现在对匿名请求回空体(不报错,就空) -async function cmdSearch(kw, o) { - const j = await plainPost('api/search/get/web', { s: kw, type: 1, limit: o.limit, offset: o.offset, total: true }); - const list = (j.result && j.result.songs) || []; - return { kw, count: list.length, total: j.result?.songCount ?? list.length, songs: list.map(song) }; -} -async function cmdSong(kw) { - if (/^\d+$/.test(kw)) { - const j = await weapiPost('v3/song/detail', { c: JSON.stringify([{ id: +kw }]) }); - const s = (j.songs || []).find((s) => +s.id === +kw); - if (!s) throw new ApiError('notfound', `没有这首歌:id=${kw}`); - return song(s); - } - const r = await cmdSearch(kw, { limit: 5, offset: 0 }); - if (!r.songs.length) throw new ApiError('notfound', `搜不到「${kw}」`); - return r.songs[0]; -} - -const MAX_COVER_BYTES = 10_000_000; -async function downloadCover(cover, file, force) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 30_000); - let body; - try { - const url = new URL(cover); - if (!['http:', 'https:'].includes(url.protocol)) throw new ApiError('external', '封面地址必须是 HTTP(S) URL'); - // 图片请求不携带登录 cookie;先完整读取有上限的响应,再打开目标文件。 - const res = await fetch(url, { - headers: { Referer: 'https://music.163.com/', 'User-Agent': UA }, - signal: controller.signal, - }); - if (!res.ok) throw new ApiError([404, 410].includes(res.status) ? 'notfound' : 'external', `封面下载失败(HTTP ${res.status})`); - const type = res.headers.get('content-type'); - if (type && !/^image\//i.test(type)) throw new ApiError('external', `封面响应不是图片:${type}`); - if (Number(res.headers.get('content-length')) > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)'); - if (!res.body) throw new ApiError('notfound', '封面图片为空'); - const chunks = []; - let bytes = 0; - for await (const chunk of res.body) { - bytes += chunk.byteLength; - if (bytes > MAX_COVER_BYTES) throw new ApiError('external', '封面超过 10 MB(10000000 字节)'); - chunks.push(chunk); - } - if (!bytes) throw new ApiError('notfound', '封面图片为空'); - body = Buffer.concat(chunks, bytes); - } catch (e) { - if (e instanceof ApiError) throw e; - throw new ApiError(controller.signal.aborted ? 'timeout' : 'external', '封面下载失败:' + e.message); - } finally { - clearTimeout(timer); - controller.abort(); - } - try { - await writeFile(file, body, { flag: force ? 'w' : 'wx' }); - } catch (e) { - if (e.code === 'EEXIST') throw new ApiError('usage', `文件已存在:${file}(覆盖需 --force)`); - throw new ApiError('external', `无法写入封面:${e.message}`); - } - return body.length; -} - -async function cmdCover(kw, o) { - if (!kw.trim() || pos.some((a) => a.startsWith('--'))) throw new ApiError('usage', 'cover 需要歌曲关键词或 id'); - if (o.download !== undefined && (!o.download.trim() || o.download.startsWith('--'))) throw new ApiError('usage', '--download 需要文件路径'); - if (o.force && o.download === undefined) throw new ApiError('usage', '--force 只能与 --download 一起使用'); - let s = await cmdSong(kw); - // 明文搜索可能省略图片 URL;只在缺封面时补查选中歌曲的详情。 - if (!s.cover && !/^\d+$/.test(kw)) s = await cmdSong(String(s.id)); - const { id, name, artist, cover } = s; - if (!cover) throw new ApiError('notfound', `这首歌没有封面:id=${id}`); - if (o.download === undefined) return { id, name, artist, cover }; - const file = resolve(o.download); - const bytes = await downloadCover(cover, file, o.force); - return { id, cover, file, bytes }; -} -async function cmdUrl(ids, o) { - const level = o.level; - const j = await weapiPost('song/enhance/player/url/v1', { - ids: JSON.stringify(ids.map(Number)), - level, - encodeType: ['standard', 'higher'].includes(level) ? 'aac' : 'flac', - }); - return { - level: j.data && j.data[0] && j.data[0].level, - items: (j.data || []).map((d) => ({ id: d.id, url: d.url, br: d.br, level: d.level, size: d.size, fee: d.fee })), - }; -} -async function cmdDaily(o) { - const j = await weapiPost('v2/discovery/recommend/songs', {}); - const list = (j.data && j.data.dailySongs) || j.recommend || []; - if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '每日推荐返回空:' + JSON.stringify(j).slice(0, 160)); - const songs = list.slice(o.offset, o.offset + o.limit).map(song); - return { count: songs.length, total: list.length, songs }; -} -async function cmdPlaylist(idOrKw, o) { - let id = String(idOrKw); - if (!/^\d+$/.test(id)) { - const hit = await searchFirst(String(idOrKw), 1000, 'playlists', 'playlist'); - id = hit.id; - } - const j = await weapiPost('v6/playlist/detail', { id: +id, n: o.limit, s: 8 }); - if (!j.playlist) throw new ApiError('notfound', `没有这个歌单:id=${id}`); - const tracks = j.playlist.tracks || []; - let songs = tracks.slice(o.offset, o.offset + o.limit).map(song); - // detail 不认 offset,内嵌 tracks 也可能截断;按完整 trackIds 取本页,缺的详情才补查。 - if (Array.isArray(j.playlist.trackIds)) { - const ids = j.playlist.trackIds.slice(o.offset, o.offset + o.limit).map((t) => t.id); - const byId = new Map(tracks.map((s) => [s.id, song(s)])); - const missing = ids.filter((id) => !byId.has(id)); - for (const s of await songsByIds(missing)) byId.set(s.id, s); - songs = ids.map((id) => byId.get(id)).filter(Boolean); - } - return { - id: j.playlist.id, - name: j.playlist.name, - count: songs.length, - total: j.playlist.trackCount ?? tracks.length, - songs, - }; -} -const norm = (v) => String(v || '').toLowerCase().replace(/\s+/g, '').replace(/[((].*$/, ''); - -// 同名/别名优先;专辑再按曲目数排(正版专辑 > 同名单曲/翻唱) -function pickBest(list, kw, kind) { - const want = norm(kw); - const score = (it) => { - const names = [it.name, ...(it.alias || [])].map(norm); - if (want && names.includes(want)) return 2; - if (want && names.some((n) => n && (n.includes(want) || want.includes(n)))) return 1; - return 0; - }; - const ranked = list.map((it) => ({ s: score(it), it })).sort((a, b) => b.s - a.s); - let cands = ranked.filter((r) => r.s === ranked[0].s).map((r) => r.it); - if (ranked[0].s === 2 && kind === 'album') cands = cands.slice().sort((a, b) => (b.size || 0) - (a.size || 0)); - return cands[0]; -} - -// 搜歌手/专辑/歌单/播客:先走 weapi cloudsearch(已登录时索引全,明文库里连周杰伦《范特西》都没有), -// 拿不到再回退明文 api/search/get/web -async function searchList(kw, type, key, limit = 10, offset = 0) { - let result; - try { - const j = await weapiPost('cloudsearch/get/web', { s: kw, type, limit, offset, total: true }); - result = j.result; - } catch { /* 回退明文 */ } - // 有总数的空页就是到末尾了,不换搜索索引重新找一页。 - if (!(result?.[key]?.length) && !(offset > 0 && result?.playlistCount != null)) { - const j = await plainPost('api/search/get/web', { s: kw, type, limit, offset, total: true }); - result = j.result; - } - const list = result?.[key] || []; - return { list, total: result?.playlistCount ?? list.length }; -} -async function searchFirst(kw, type, key, kind) { - const { list } = await searchList(kw, type, key); - if (!list.length) throw new ApiError('notfound', `搜不到「${kw}」`); - return pickBest(list, kw, kind); -} - -// 歌手热门:v1/artist/<id> 的 hotSongs;老接口 artist/top/song 作回退 -async function cmdArtist(kw, o) { - const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id; - let j = await weapiPost(`v1/artist/${id}`, {}); - let list = j.hotSongs || []; - let name = (j.artist && j.artist.name) || ''; - if (!list.length) { - j = await weapiPost('artist/top/song', { id }); - list = j.songs || []; - } - if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有可播的歌:${kw}`); - const songs = list.slice(o.offset, o.offset + o.limit).map(song); - return { id, name: name || kw, count: songs.length, total: list.length, songs }; -} - -// 歌手的专辑列表(含经典老专辑,搜索索引里没有的也能从这里拿到 id) -async function cmdAlbums(kw, o) { - const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 100, 'artists', 'artist')).id; - const j = await weapiPost(`artist/albums/${id}`, { limit: o.limit, offset: o.offset, total: true }); - const list = j.hotAlbums || []; - if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这位歌手没有专辑:${kw}`); - return { - id, - name: (list[0] && list[0].artist && list[0].artist.name) || kw, - count: list.length, - total: j.artist?.albumSize ?? j.total ?? list.length, - albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, year: (a.publishTime && new Date(a.publishTime).getFullYear()) || 0 })), - }; -} - -// 专辑:v1/album/<id> 的 songs(整张,按专辑顺序) -async function cmdAlbum(kw, o) { - const id = /^\d+$/.test(kw) ? +kw : (await searchFirst(kw, 10, 'albums', 'album')).id; - const j = await weapiPost(`v1/album/${id}`, {}); - const list = j.songs || []; - if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这张专辑没有可播的歌:${kw}`); - const songs = list.slice(o.offset, o.offset + o.limit).map(song); - return { id, name: (j.album && j.album.name) || kw, count: songs.length, total: j.album?.size ?? j.album?.songCount ?? list.length, songs }; -} - -// ---------- 歌单搜索 / 榜单 / 收藏 / FM / 最近 / 播客 ---------- - -async function cmdPlaylists(kw, o) { - const { list, total } = await searchList(kw, 1000, 'playlists', o.limit, o.offset); - if (!list.length && !o.offset) throw new ApiError('notfound', `搜不到歌单「${kw}」`); - return { - kw, - count: list.length, - total, - playlists: list.map((p) => ({ - id: p.id, - name: p.name, - count: p.trackCount || 0, - creator: (p.creator || {}).nickname || '', - sub: p.subscribed === true, - })), - }; -} - -async function cmdCharts(o) { - const j = await weapiPost('toplist', {}); - const list = j.list || []; - if (!list.length && (!o.offset || j.code !== 200)) throw new ApiError('external', '榜单接口没返回列表'); - const page = list.slice(o.offset, o.offset + o.limit); - return { - count: page.length, - total: j.total ?? j.count ?? list.length, - charts: page.map((c) => ({ id: c.id, name: c.name, count: c.trackCount, freq: c.updateFrequency })), - }; -} - -async function cmdChart(kw, o) { - let id = /^\d+$/.test(kw) ? +kw : 0; - let name = kw; - if (!id) { - const j = await weapiPost('toplist', {}); - const hit = pickBest(j.list || [], kw, 'chart'); - if (!hit) throw new ApiError('notfound', `没有这个榜单:${kw}(用 charts 看全部)`); - id = hit.id; - name = hit.name; - } - const r = await cmdPlaylist(String(id), o); - return { ...r, chart_id: id, chart: name }; -} - -async function cmdMyAlbums(o) { - const j = await weapiPost('album/sublist', { limit: o.limit, offset: o.offset, total: true }); - const list = j.data || []; - return { - count: list.length, - total: j.count ?? j.total ?? list.length, - albums: list.map((a) => ({ id: a.id, name: a.name, size: a.size, artist: (a.artists || []).map((x) => x.name).join(' / ') })), - }; -} - -async function cmdMyArtists(o) { - const j = await weapiPost('artist/sublist', { limit: o.limit, offset: o.offset, total: true }); - const list = j.data || []; - return { count: list.length, total: j.count ?? j.total ?? list.length, artists: list.map((a) => ({ id: a.id, name: a.name, albums: a.albumSize, songs: a.musicSize })) }; -} - -// 私人 FM:接口一次固定给 3 首且不认 limit,多拉几次凑够(去重) -async function cmdFm(o) { - const want = Math.min(o.limit, 30); - const seen = new Set(); - const out = []; - for (let i = 0; i < 12 && out.length < want; i++) { - const j = await weapiPost('radio/get', {}); - const batch = j.data || []; - if (!batch.length) break; - for (const s of batch) if (!seen.has(s.id)) { seen.add(s.id); out.push(song(s)); } - } - if (!out.length) throw new ApiError('external', '私人 FM 没返回歌曲'); - const picked = out.slice(0, want); - return { count: picked.length, songs: picked }; -} - -async function cmdRecent(o) { - const uid = await currentUid(); - const j = await weapiPost('v1/play/record', { uid, type: 1 }); // type=1:最近一周 - const list = j.weekData || j.allData || []; - const items = list.slice(o.offset, o.offset + o.limit).filter((x) => x.song).map((x) => ({ ...song(x.song), plays: x.playCount })); - if (!items.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', '最近播放是空的'); - return { count: items.length, total: j.total ?? j.count ?? list.length, songs: items }; -} - -async function cmdPodcasts(o) { - // 两个端点都认 limit、不认 offset;拉到页尾再本地截,避免每页重复第一页。 - const j = await weapiPost(o.recommend ? 'djradio/recommend/v1' : 'djradio/get/subed', { - limit: o.offset + o.limit, offset: o.offset, - }); - const list = j.djRadios || []; - const page = list.slice(o.offset, o.offset + o.limit); - if (!list.length && (!o.offset || j.code !== 200)) { - throw new ApiError('notfound', o.recommend ? '没有推荐播客' : '没订阅播客(加 --recommend 看推荐的)'); - } - return { - kind: o.recommend ? 'recommend' : 'subscribed', - count: page.length, - total: j.count ?? j.total ?? list.length, - podcasts: page.map((r) => ({ id: r.id, name: r.name, episodes: r.programCount, dj: (r.dj || {}).nickname || '' })), - }; -} - -// 放某播客的最新 N 期:名字先在订阅+推荐里找,找不到再搜播客 -async function cmdPodcast(kw, o) { - let id = /^\d+$/.test(kw) ? +kw : 0; - let name = kw; - if (!id) { - const subs = (await weapiPost('djradio/get/subed', { limit: 100, offset: 0 })).djRadios || []; - const rec = (await weapiPost('djradio/recommend/v1', { limit: 100, offset: 0 })).djRadios || []; - const hit = pickBest([...subs, ...rec], kw, 'djradio'); - if (hit) { - id = hit.id; - name = hit.name; - } else { - const s = await searchFirst(kw, 1009, 'djRadios', 'djradio'); - id = s.id; - name = s.name; - } - } - const j = await weapiPost('dj/program/byradio', { radioId: id, limit: o.limit, offset: o.offset, asc: false }); - const progs = j.programs || []; - const songs = progs - .filter((p) => p.mainSong && p.mainSong.id) - .map((p) => ({ ...song(p.mainSong), programId: p.id, program: p.name || '' })); - if (!songs.length && (!o.offset || j.code !== 200)) throw new ApiError('notfound', `这个播客没有可播的节目:${kw}`); - return { id, name, count: songs.length, total: j.count ?? j.total ?? progs[0]?.radio?.programCount ?? progs.length, songs }; -} - -async function cmdEpisode(pid, o) { - const j = await weapiPost('dj/program/detail', { id: +pid }); - const p = j.program || {}; - const ms = p.mainSong || {}; - if (!ms.id) throw new ApiError('notfound', `没有这期节目:${pid}`); - return { - id: +pid, - name: p.name || ms.name || '', - dj: (p.dj || {}).nickname || '', - count: 1, - songs: [{ ...song(ms), programId: +pid, program: p.name || '' }], - }; -} - -// 「我喜欢的音乐」顺序 = 收藏时间由近到远(与客户端一致): -// song/like/get 返回的 ids 顺序与收藏时间无关(实测最老收藏的排第 1), -// 真值是 specialType=5 歌单的 trackIds[].at(收藏时间毫秒戳),按它倒序再取详情。 -async function cmdLiked(o) { - const pid = await likedPlaylistId(); - if (!pid) throw new ApiError('notfound', '找不到「我喜欢的音乐」歌单'); - // n=0 省掉内嵌 tracks,trackIds 仍全量返回 - const j = await weapiPost('v6/playlist/detail', { id: pid, n: 0, s: 8 }); - const tids = ((j.playlist || {}).trackIds || []).slice().sort((a, b) => (b.at || 0) - (a.at || 0)); - const ids = tids.slice(o.offset, o.offset + o.limit).map((x) => x.id); - const songs = (await songsByIds(ids)).map((s) => ({ ...s, liked: true })); - return { count: songs.length, total: tids.length, songs }; -} -async function songsByIds(ids) { - if (!ids.length) return []; - const det = await weapiPost('v3/song/detail', { c: JSON.stringify(ids.map((id) => ({ id }))) }); - const byId = new Map((det.songs || []).map((s) => [s.id, song(s)])); - return ids.map((id) => byId.get(id)).filter(Boolean); -} -async function likedPlaylistId() { - const uid = await currentUid(); - const j = await weapiPost('user/playlist', { uid, limit: 1000, offset: 0, includeVideo: true }); - const p = (j.playlist || []).find((x) => x.specialType === 5) || (j.playlist || [])[0]; - return p && p.id; -} - -async function cmdLike(id, o) { - const like = !o.unlike; - const j = await weapiPost('radio/like', { alg: 'itembased', trackId: +id, like, time: 3 }); - if (j.code === 200) return { id: +id, liked: like, via: 'radio/like', code: j.code }; - const first = `radio/like code=${j.code}:${j.message || j.msg || ''}`; - // 风控(-460)时退一步:直接对「我喜欢的音乐」歌单做增删,走另一套风控 - const pid = await likedPlaylistId(); - if (pid) { - const j2 = await weapiPost('playlist/manipulate/tracks', { - op: like ? 'add' : 'del', - pid, - trackIds: JSON.stringify([+id]), - imme: 'true', - }); - if (j2.code === 200 || (like && j2.code === 502)) { - // 502 = 歌单内歌曲重复 → 目标状态已经达成,按幂等成功处理 - return { id: +id, liked: like, via: 'playlist/manipulate/tracks', already: j2.code === 502, fallback_from: first }; - } - throw new ApiError('external', `网易云拒绝了这次操作(${first};playlist/manipulate code=${j2.code}:${j2.message || j2.msg || ''})`); - } - throw new ApiError('external', `网易云拒绝了这次操作(${first})`); -} -async function cmdLyric(id, o) { - const j = await weapiPost('song/lyric', { id: +id, lv: -1, kv: -1, tv: -1 }); - return { id: +id, lyric: (j.lrc && j.lrc.lyric) || '', trans: o.trans ? (j.tlyric && j.tlyric.lyric) || '' : undefined }; -} -async function cmdSession() { - const j = await weapiPost('w/nuser/account/get', {}); - return { - loggedIn: !!(j.profile && j.profile.userId), - uid: num(j.profile && j.profile.userId), - nick: (j.profile && j.profile.nickname) || '', - vip: (j.profile && j.profile.vipType) || 0, - }; -} -async function currentUid() { - const s = await cmdSession(); - if (!s.loggedIn) throw new ApiError('auth', '没登录:先跑 pi-skill netease-music login(手机扫码)'); - return s.uid; -} -async function cmdMyPlaylists(o) { - const uid = await currentUid(); - // user/playlist 的 limit 不稳定,非零 offset 还会多跳过置顶歌单;从头取再本地分页。 - const j = await weapiPost('user/playlist', { uid, limit: o.offset + o.limit, includeVideo: true }); - const list = j.playlist || []; - const page = list.slice(o.offset, o.offset + o.limit); - return { - count: page.length, - total: j.total ?? j.count ?? list.length, - playlists: page.map((p) => ({ id: p.id, name: p.name, count: p.trackCount, special: p.specialType || 0, sub: p.subscribed === true })), - }; -} -async function cmdLikeState(id) { - // 这个端点回的是「我喜欢的歌曲 id 全表」(key 是 ids),不是单曲对象 - const j = await weapiPost('song/like/get', { trackIds: JSON.stringify([Number(id)]) }); - if (j.code !== 200) throw new ApiError('external', `查喜欢状态失败(code=${j.code}):${j.message || j.msg || ''}`); - const ids = (Array.isArray(j.ids) ? j.ids : []).map(Number); - return { id: Number(id), liked: ids.includes(Number(id)), liked_total: ids.length, code: j.code }; -} -async function cmdLikedIds(args) { - if (args.length) throw new ApiError('usage', '用法:liked-ids'); - const j = await weapiPost('song/like/get', { trackIds: '[]' }); - if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `查喜欢列表失败(code=${j.code}):${j.message || j.msg || ''}`); - if (!Array.isArray(j.ids)) throw new ApiError('external', '喜欢列表缺少 ids,不能当成空列表'); - const ids = j.ids.map(Number); - if (ids.some((id) => !Number.isSafeInteger(id) || id < 1)) throw new ApiError('external', '喜欢列表含无效歌曲 id'); - return { count: ids.length, total: ids.length, ids }; -} -async function cmdDislike(args) { - if (args.length !== 1) throw new ApiError('usage', '用法:dislike <歌曲id>'); - const id = songId(args[0]); - // NeteaseCloudMusicApi 的 fm/trash 对应此 weapi 写端点。 - const j = await weapiPost('radio/trash/add', { songId: id, time: 25, alg: 'RT' }); - if (j.code !== 200) throw new ApiError(j.code === 301 ? 'auth' : 'external', `私人 FM 不喜欢失败(code=${j.code}):${j.message || j.msg || ''}`); - return { id, disliked: true, code: j.code }; -} -async function cmdSmsSend(phone) { - const j = await weapiPost('sms/captcha/sent', { cellphone: String(phone), ctcode: '86' }); - if (j.code !== 200) throw new ApiError('external', `发验证码没成功(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`); - return { sent: true }; -} -async function cmdSmsLogin(phone, code) { - const j = await weapiPost('login/cellphone', { phone: String(phone), countrycode: '86', captcha: String(code), rememberLogin: true }); - const cookie = jarToCookie(lastSetCookie); - if (!/MUSIC_U=/.test(cookie)) throw new ApiError('auth', `登录没成(code=${j.code}):${j.message || JSON.stringify(j).slice(0, 120)}`); - return { state: 'ok', cookie }; -} -async function cmdQrStart() { - const j = await weapiPost('login/qrcode/unikey', { type: 1, header: {} }); - if (!j.unikey) throw new ApiError('external', '拿不到二维码 key:' + JSON.stringify(j).slice(0, 160)); - return { key: j.unikey, url: 'https://music.163.com/login?codekey=' + j.unikey }; -} -async function cmdQrPoll(key, o) { - const deadline = Date.now() + o.timeout * 1000; - let state = 'waiting'; - while (Date.now() < deadline) { - const j = await weapiPost('login/qrcode/client/login', { key, type: 1 }); - if (j.code === 800) return { state: 'expired', code: j.code }; - if (j.code === 802 && state !== 'scanned') { - state = 'scanned'; - console.error('已扫到,等你在手机上点「确认登录」…'); - } - if (j.code === 803) { - const cookie = jarToCookie(lastSetCookie); - if (!/MUSIC_U=/.test(cookie)) throw new ApiError('external', '登录成功了但没拿到 MUSIC_U:' + cookie.slice(0, 120)); - return { state: 'ok', cookie }; - } - await new Promise((r) => setTimeout(r, 2000)); - } - return { state, code: 801 }; -} - -// ---------- 参数解析 ---------- -const argv = process.argv.slice(2); -const CMD = argv[0]; -const opts = { limit: 0, offset: 0, start: 1, level: 'lossless', trans: false, unlike: false, timeout: 120 }; -const pos = []; -for (let i = 1; i < argv.length; i++) { - const a = argv[i]; - if (a === '--limit') opts.limit = +argv[++i]; - else if (a === '--offset') opts.offset = +argv[++i]; - else if (a === '--start') { - const value = argv[++i]; - opts.start = /^\d+$/.test(value || '') ? +value : NaN; - } - else if (a === '--level') opts.level = argv[++i]; - else if (a === '--trans') opts.trans = true; - else if (a === '--unlike') opts.unlike = true; - else if (a === '--timeout') opts.timeout = +argv[++i]; - else if (a === '--recommend') opts.recommend = true; - else if (a === '--download') opts.download = argv[++i] ?? ''; - else if (a === '--force') opts.force = true; - else pos.push(a); -} -const DEFAULTS = { search: 20, daily: 40, playlist: 500, liked: 1000, myplaylists: 100, artist: 50, album: 200, albums: 60, - playlists: 20, charts: 100, myalbums: 60, myartists: 60, fm: 12, recent: 30, podcasts: 50, podcast: 3 }; -if (!opts.limit) opts.limit = DEFAULTS[CMD] || 20; - -const rawPost = (path, data) => (String(path).startsWith('api/') ? plainPost(path, data) : weapiPost(path, data)); - -const HANDLERS = { - search: () => cmdSearch(pos.join(' '), opts), - song: () => cmdSong(pos.join(' ')), - 'play-ids': () => cmdPlayIds(pos, opts), - cover: () => cmdCover(pos.join(' '), opts), - url: () => cmdUrl(pos, opts), - daily: () => cmdDaily(opts), - playlist: () => cmdPlaylist(String(pos[0]).replace(/.*[?&#/]id=/, ''), opts), - playlists: () => cmdPlaylists(pos.join(' '), opts), - charts: () => cmdCharts(opts), - chart: () => cmdChart(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts), - myalbums: () => cmdMyAlbums(opts), - myartists: () => cmdMyArtists(opts), - fm: () => cmdFm(opts), - recent: () => cmdRecent(opts), - podcasts: () => cmdPodcasts(opts), - podcast: () => cmdPodcast(String(pos.join(' ')), opts), - episode: () => cmdEpisode(pos[0], opts), - artist: () => cmdArtist(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts), - album: () => cmdAlbum(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts), - albums: () => cmdAlbums(String(pos.join(' ')).replace(/.*[?&#/]id=/, ''), opts), - liked: () => cmdLiked(opts), - 'liked-ids': () => cmdLikedIds(pos), - dislike: () => cmdDislike(pos), - like: () => cmdLike(pos[0], opts), - lyric: () => cmdLyric(pos[0], opts), - myplaylists: () => cmdMyPlaylists(opts), - session: () => cmdSession(), - uid: () => currentUid().then((uid) => ({ uid })), - raw: () => rawPost(pos[0], JSON.parse(pos[1] || '{}')), - 'like-state': () => cmdLikeState(pos[0]), - 'sms-send': () => cmdSmsSend(pos[0]), - 'sms-login': () => cmdSmsLogin(pos[0], pos[1]), - 'qr-start': () => cmdQrStart(), - 'qr-poll': () => cmdQrPoll(pos[0], opts), -}; - -if (!HANDLERS[CMD]) { - console.error(JSON.stringify({ error: 'usage', hint: `未知命令 ${CMD || '(空)'};可用:${Object.keys(HANDLERS).join(' ')}` })); - process.exit(2); -} -try { - if (!Number.isSafeInteger(opts.offset) || opts.offset < 0) throw new ApiError('usage', '--offset 需要非负整数'); - const out = await HANDLERS[CMD](); - console.log(JSON.stringify(out)); -} catch (e) { - const code = e instanceof ApiError ? e.code : 'internal'; - console.error(JSON.stringify({ error: code, hint: e.hint || String(e) })); - process.exit(code === 'usage' ? 2 : code === 'auth' ? 3 : code === 'notfound' ? 4 : 5); -} diff --git a/scripts/netease-music.sh b/scripts/netease-music.sh deleted file mode 100755 index a8e54aa..0000000 --- a/scripts/netease-music.sh +++ /dev/null @@ -1,1159 +0,0 @@ -#!/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 - -# 入口可能被安装成符号链接(install-cli.sh),先解到真实路径再定位仓库根。 -SELF="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "${BASH_SOURCE[0]}")" -SKILL_DIR="$(cd "$(dirname "$SELF")/.." && 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/refresh-urls.sh b/scripts/refresh-urls.sh deleted file mode 100755 index 3d91255..0000000 --- a/scripts/refresh-urls.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash -# refresh-urls.sh — 队列地址保活 -# -# 网易云的播放直链是限时签名 URL:实测取出来约 5 分钟就 403 -# (路径里的时间戳 + vuutv/authSecret 签名,服务端一核,过期就拒绝)。 -# 长队列预取一批地址,放到后半段会集体失效,听感是「歌还在放、就是没声音」。 -# -# 本脚本由入口的 keepalive_start 拉起(一次只跑一个),每 90 秒看一次: -# 这批地址取出来多久了(urls_at)× mpv 播到第几首(playlist-pos) -# 一到 4 分钟,就把「当前这首之后还没播的」批量重取,原地换掉 m3u 里对应的行。 -# mpv 是按需读取下一首的,所以换 URL 不影响正在放的那首。 -# 队列被换掉 / 停止 / mpv 退出,脚本自己收工。 -set -u -SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -LOCAL_DIR="${NETEASE_MUSIC_STATE_DIR:-${PI_AGENT_DIR:-$HOME/.pi/agent}/local/skills/netease-music}" -QUEUE="$LOCAL_DIR/queue.json" -M3U="$LOCAL_DIR/queue.m3u8" -URLS_AT="$LOCAL_DIR/urls_at" -SOCK="$LOCAL_DIR/mpv.sock" -API="$SKILL_DIR/scripts/netease-api.mjs" -QUALITY="$(python3 -c 'import json,sys -try: print(json.load(open(sys.argv[1])).get("quality") or "lossless") -except Exception: print("lossless")' "$LOCAL_DIR/config.json" 2>/dev/null)" -QUALITY="${NETEASE_MUSIC_QUALITY:-${PI_SKILL_NETEASE_MUSIC_QUALITY:-$QUALITY}}" -COOKIE="${NETEASE_MUSIC_COOKIE:-${PI_SKILL_NETEASE_MUSIC_COOKIE:-$(python3 -c 'import json,sys -try: print(json.load(open(sys.argv[1])).get("cookie") or "") -except Exception: print("")' "$LOCAL_DIR/config.json" 2>/dev/null)}}" -log() { printf '%s %s\n' "$(date '+%H:%M:%S')" "$*"; } -mpv_query() { # mpv_query <属性>:拿不到就打印空 - python3 - "$SOCK" "$1" <<'PY' 2>/dev/null -import json, socket, sys -try: - s = socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect(sys.argv[1]) - s.sendall(json.dumps({"command": ["get_property", sys.argv[2]], "request_id": 1}).encode() + b"\n") - r = json.loads(s.recv(65536).decode()) - print(r.get("data") if r.get("error") == "success" else "") -except Exception: - print("") -PY -} -refetch_from() { # refetch_from <起始序号 0-based>:重取这一首之后的地址,原地换 m3u - local from="$1" tmp_ids tmp_urls n - tmp_ids="$(mktemp)"; tmp_urls="$(mktemp)" - python3 - "$QUEUE" "$from" > "$tmp_ids" <<'PY' -import json, sys -q = json.load(open(sys.argv[1])) -for i, s in enumerate(q["songs"]): - if i >= int(sys.argv[2]): - print(s["id"]) -PY - [ -s "$tmp_ids" ] || { rm -f "$tmp_ids" "$tmp_urls"; return 1; } - n="$(wc -l < "$tmp_ids" | tr -d ' ')" - log "重取地址:从第 $((from + 1)) 首起共 $n 首" - while read -r chunk; do - NETEASE_COOKIE="$COOKIE" node "$API" url $chunk --level "$QUALITY" 2>/dev/null \ - | python3 -c 'import json,sys -for it in (json.load(sys.stdin).get("items") or []): - print("%s\t%s" % (it.get("id"), it.get("url") or ""))' >> "$tmp_urls" - done < <(python3 -c 'import sys -ids = [l.strip() for l in sys.stdin if l.strip()] -for i in range(0, len(ids), 50): print(" ".join(ids[i:i+50]))' < "$tmp_ids") - python3 - "$M3U" "$QUEUE" "$tmp_urls" "$from" <<'PY' -import json, sys -mpath, qpath, upath, start = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]) -urls = {} -for line in open(upath): - sid, _, url = line.rstrip("\n").partition("\t") - if url: - urls[sid] = url -order = [str(s["id"]) for s in json.load(open(qpath))["songs"]] -lines = open(mpath).read().split("\n") -head = [i for i, l in enumerate(lines) if l.startswith("#EXTINF")] -changed = 0 -for pos, i in enumerate(head): - if pos < start or pos >= len(order) or i + 1 >= len(lines): - continue - u = urls.get(order[pos]) - if u: - lines[i + 1] = u - changed += 1 -open(mpath, "w").write("\n".join(lines)) -print("换成新地址的条数:%d/%d" % (changed, len(head) - start)) -PY - log "重取完成(从第 $((from + 1)) 首起)" - rm -f "$tmp_ids" "$tmp_urls" -} -log "地址保活启动" -while :; do - sleep 90 - [ -n "$(mpv_query idle-active)" ] || { log "mpv 没了,收工"; exit 0; } - [ -s "$QUEUE" ] || { log "队列没了,收工"; exit 0; } - age=$(( $(date +%s) - $(cat "$URLS_AT" 2>/dev/null || echo 0) )) - [ "$age" -lt 240 ] && continue - # 「当前放到第几首」按歌认:mpv 开了 shuffle 时 playlist-pos 不是 queue.json 的序号 - title="$(mpv_query media-title)" - curl="$(mpv_query path)" - pos="$(python3 "$SKILL_DIR/scripts/lib/mpv-map.py" index "$QUEUE" "$LOCAL_DIR/urls.txt" "$title" "$curl" 2>/dev/null)" - case "$pos" in ''|*[!0-9]*) pos=0 ;; esac - if refetch_from "$pos"; then date +%s > "$URLS_AT"; else log "重取失败,90 秒后再试"; fi -done \ No newline at end of file diff --git a/skills/netease-music/VERSION b/skills/netease-music/VERSION index d917d3e..b1e80bb 100644 --- a/skills/netease-music/VERSION +++ b/skills/netease-music/VERSION @@ -1 +1 @@ -0.1.2 +0.1.3