uta 0.1.2

Command-line music search and downloader for QQ Music and NetEase Cloud Music, lossless first, shipped as a single static binary. For learning and research only; non-commercial use.
# 网易云音乐协议

参考 musicdl `musicdl/modules/sources/netease.py` 与 `utils/neteaseutils.py`。专辑、歌手相关的接口 Python 版没有。
下文都是 2026-10-09 的真实请求结果,与 Python 源码冲突的地方以实测为准。

不使用登录 Cookie。musicdl 自带一个默认 `MUSIC_U`,Rust 版不用。

## 总链路

```
search(keyword)
  └─ POST music.163.com/api/cloudsearch/pc(明文)→ result.songs[]
for each song(并行):
  ├─ 第三方:tmetu → chksz → jfjt(无 key;拿到无损及以上即停)
  ├─ 官方:POST interface3.music.163.com/eapi/song/enhance/player/url/v1(eapi 加密)
  ├─ probe:HEAD(失败则 GET 8KB 嗅探)
  └─ lyric:POST interface3.music.163.com/api/song/lyric
download:流式 GET,不需要 Referer;CDN 支持 Range / ETag,可断点续传
```

所有接口都带 `Referer: https://music.163.com/` 和浏览器 UA,返回 JSON 中的 `code == 200` 表示成功。

## 1. 歌曲字段

搜索结果 `result.songs[]`、`v3/song/detail` 的 `songs[]`、专辑的 `songs[]` 结构相同:

| 字段 | 含义 |
|---|---|
| `id` | 数字 id,后续所有接口都用它 |
| `name` | 歌名 |
| `ar[].name` | 歌手 |
| `al.id` / `al.name` / `al.picUrl` | 专辑 id、专辑名、封面 |
| `dt` | 时长(毫秒) |
| `sq.size` / `h.size` / `hr.size` | 无损 / 320k / Hi-Res 文件大小,可能为 null |
| `fee` | 0 免费,8 免费(高音质收费),1 VIP |
| `no` / `cd` | 专辑内曲序 / 碟号(字符串,如 `"01"`) |

- **封面**:`picUrl` 原图可能有数 MB。Rust 版统一改成 https,并加上 `?param=800y800`。
- **版权**:网易没有周杰伦的版权,搜"十一月的萧邦"只会搜到翻唱。

## 2. 搜索

`POST https://music.163.com/api/cloudsearch/pc`,表单 `s=<关键词>&type=<类型>&limit=<条数>&offset=<偏移>`。

- `type`:1 歌曲(`result.songs`)、10 专辑(`result.albums`)、100 歌手(`result.artists`)。
- 单页最多 100 条,`offset` 用来翻页。
- 专辑结果:`id`、`name`、`artist.name` / `artists[]`、`size`(曲目数)、`publishTime`(毫秒)。
- 歌手结果:`id`、`name`、`albumSize`、`musicSize`。搜"田馥甄",第一条就是本人(9548)。

## 3. 取下载直链

### 品质档

| level | 说明 | Rust 档位 |
|---|---|---|
| `jymaster` | 超清母带 | master |
| `sky` | 沉浸环绕声 | atmos |
| `dolby` | 杜比全景声(mp4 封装,不主动请求) | atmos |
| `jyeffect` | 高清臻音 | hires |
| `hires` | Hi-Res | hires |
| `lossless` | 无损 FLAC | sq |
| `exhigh` | 320k MP3 | hq |
| `higher` / `standard` | 192k / 128k | std |

### 3.1 官方 eapi(兜底)

`POST https://interface3.music.163.com/eapi/song/enhance/player/url/v1`,表单 `params=<密文>`,Cookie `os=pc; appver=; osver=; deviceId=pyncm!`。

- **明文 payload**:`{"ids":[id],"level":…,"encodeType":"flac","header":"<JSON 字符串:os/appver/osver/deviceId/requestId>"}`。
- **eapi 加密**:
  1. `digest = md5("nobody" + path + "use" + json + "md5forencrypt")`,`path` 是把 `/eapi/` 换成 `/api/` 之后的路径。
  2. 明文为 `path + "-36cd479b6b5-" + json + "-36cd479b6b5-" + digest`。
  3. 用 AES-128-ECB + PKCS7 加密,key 为 `e82ckenh8dichen8`,输出大写 hex。
  4. 单元测试里的期望值,是用 musicdl 对同一输入实际算出来的。
- **响应**:`data[0].url / level / size / type / freeTrialInfo / code`。
- **实测(免登录)**:
  - 付费歌(fee=1)返回 `code=-110`,`url` 为空。
  - 免费歌(fee=0/8)无论请求哪一档,都被降到 `exhigh`(MP3 320k)。
  - `freeTrialInfo` 不为 null 时是试听片段,Rust 版会丢弃。

### 3.2 第三方(均无 key)

链接都指向网易官方 CDN(`m*.music.126.net`)。CDN 的 `Content-Type` 会把 FLAC 写成 `audio/mpeg`,所以以 URL 扩展名为准。

| 接口 | 请求 | 成功 | 失败 |
|---|---|---|---|
| tmetu | `GET https://music.tmetu.cn/api/?miss=songAll&id=&level=&withLyric=false`,Referer `https://music.tmetu.cn/` | `code=200`,`data.audioUrl`、`data.level`、`data.size` | 不存在:`code=404`"未找到歌曲信息" |
| chksz | `GET https://api.chksz.com/api/163_music?id=&level=`,Referer/Origin `https://cp.chksz.top` | `code=200`,`data.url`、`data.level`、`data.br`、`data.size` | 该档不可用:HTTP 404 |
| jfjt | `POST https://dm.jfjt.cc/Song_V1`,表单 `url=<id>&level=&type=json`,Referer `https://dm.jfjt.cc/` | `status=200`,`data.url`、`data.level`、`data.size`("95.25MB") | 不存在:`status=404` |

- **tmetu**:上午实测,付费歌《无人知晓》各档都能拿到,jymaster 有 170MB。下午它的上游账号被风控了,返回 `code=500`,提示"请完成验证操作",`extra.needVerify=true`。Rust 版遇到这种情况会熔断,本次运行只告警一次,之后不再请求它。
- **chksz**:付费歌能拿到 jymaster(170MB,4.7Mbps),单次约 1.5s。
- **jfjt**:单次约 0.4s,最快。请求 jymaster 时会自动降档,返回 jyeffect(95MB)。
- **kangqiovo**(`ncm.kangqiovo.com/Song_V1`):和 jfjt 是同一套后端,没有接入。
- **其他**:xiaoqin 需要先调 `/api/ip`;vincentzyu233 返回 `url` 为空;znnu 需要 HMAC 和 AES-GCM,而且密钥要从接口动态获取。这几个都没有接入。
- **Rust 版顺序**:tmetu → chksz → jfjt。
  - 每个接口从上限档位往下试。
  - 拿到无损(sq)及以上就停。
  - 第三方拿到 FLAC 就不再请求官方接口,否则取两边中更大的那个文件。
  - 用 `--lossless` 时,第三方拿不到无损就直接放弃。

## 4. 歌词

`POST https://interface3.music.163.com/api/song/lyric`,表单 `id=&cp=false&tv=0&lv=0&rv=0&kv=0&yv=0&ytv=0&yrv=0`,取 `lrc.lyric`(明文 LRC)。

- 不存在的 id 返回 `uncollected: true`,同时给一行 `[00:00.00]暂无歌词`。纯音乐返回 `nolyric: true`。这两种情况都视为没有歌词。
- 响应里还有 `tlyric`(翻译)、`romalrc`(罗马音)、`yrc`(逐字歌词),Rust 版目前没用到。

## 5. 详情 / 歌单 / 专辑 / 歌手

- **歌曲详情**:`POST https://interface3.music.163.com/api/v3/song/detail`,表单 `c=[{"id":…,"v":0},…]`。
  - 实测一次传 200 个 id,返回 200 条。
  - 不存在的 id 会被直接略过。
- **歌单**:`POST https://music.163.com/api/v6/playlist/detail`,表单 `id=`。
  - 取 `playlist.name` 和 `playlist.trackIds[].id`。`tracks` 里只有前 10 首的完整信息,其余要按 id 批量查详情。
  - 实测热歌榜(3778678):200 首。
- **专辑**:`GET https://music.163.com/api/v1/album/{id}`,取 `album`(`name`、`artists`、`publishTime`)和 `songs[]`。
  - 不存在时返回 `{"resourceState":false,"code":404}`。
  - Rust 版按列表位置写曲序,多碟时再用 `cd` 写碟号。
- **歌手专辑**:`GET https://music.163.com/api/artist/albums/{id}?limit=&offset=`,取 `hotAlbums[]`,用 `more` 判断是否还有下一页。
  - `limit=1000` 能一次取完:陈奕迅 142 张,田馥甄 28 张。
  - 只有按发行时间从新到旧这一种顺序。
  - `type` 有:专辑 / Single / EP;`subType` 有:录音室版 / 现场版 / 伴奏版。
  - 不存在的歌手返回 `code=404`。

## 6. 链接

- **域名**:`music.163.com`(含 `y.` 和 `m.` 子域名),以及分享短链 `163cn.tv`。短链会先跟随跳转。
- **形式**:`/#/song?id=`、`/song?id=`、`/m/song?id=`、`/song/<id>/`。playlist、album、artist 也一样。
- **类型检查**:链接里必须明确出现该资源类型,比如把歌单链接交给 `get` 不会被识别成歌曲。