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.
# QQ 音乐协议说明(移植依据)

所有内容整理自 musicdl v2.14.0 源码,并于 2026-10-08 用真实请求核对过。
Python 参考源码([musicdl](https://github.com/CharlesPikachu/musicdl) 仓库内路径;以此为准,有疑问就去读):

- `musicdl/modules/sources/qq.py` — 客户端主流程
- `musicdl/modules/utils/qqutils.py` — 常量、签名、qimei、公共参数
- `musicdl/modules/utils/misc.py` — `AudioLinkTester`(链接验证)
- `musicdl/modules/utils/songinfoutils.py` — 歌词保存、标签写入
- `musicdl/modules/sources/base.py` — 下载流程

## 总链路

```
search(keyword)
  └─ POST musicu.fcg  DoSearchForQQMusicMobile  → item_song[]
for each song (并行):
  ├─ resolve_url: vkeys(14→9) → xcvts(4 档) → 官方 GetVkey(品质表)
  │    (Rust 版:vkeys(14→8) → tang → 官方 GetVkey)
  ├─ probe: HEAD(失败则 GET 8KB 嗅探)→ 格式 + 大小
  ├─ 过滤: 码率 < 320kbps 丢弃;--lossless 时非无损丢弃
  └─ lyric: GET fcg_query_lyric_new.fcg(base64)
选择(交互)
download: 流式 GET(Referer: http://y.qq.com)→ 写 .lrc → 写标签 + 封面
```

---

## 1. 公共参数 `comm`

所有 `musicu.fcg` 请求的 body 结构:

```json
{
  "comm": {
    "cv": 13020508, "v": 13020508, "QIMEI36": "<q36>",
    "ct": "11", "tmeAppID": "qqmusic", "format": "json",
    "inCharset": "utf-8", "outCharset": "utf-8", "uid": "3931641530"
  },
  "<module>.<method>": { "module": "<module>", "method": "<method>", "param": { ... } }
}
```

- 版本:`version = "13.2.5.8"`,`version_code = 13020508`。
- 有登录凭据时追加 `"qq": musicid, "authst": musickey, "tmeLoginType": "1"|"2"`(musickey 以 `W_X` 开头为 1,否则 2)。见 `qqutils.py` `Credential` / `buildcommonparams`。
- 取 vkey 时 `ct` 覆盖为 `"19"`。
- param 中的 bool 转为 0/1。
- 序列化:`separators=(",", ":")`、`ensure_ascii=False`,UTF-8 字节作为 body。

### QIMEI36

`qqutils.py` `obtainqimei`:

1. 随机 16 字符 `crypt_key`、`nonce`(字符集 `adbcdef1234567890`)。
2. `key = b64(RSA_PKCS1v15(PUBLIC_KEY, crypt_key))`。
3. `params = b64(AES-128-CBC(key=iv=crypt_key, PKCS7, json(device_payload)))`。
4. `extra = '{"appKey":"' + APP_KEY + '"}'`,`sign = md5(key + params + str(ts*1000) + nonce + SECRET + extra)`。
5. `POST https://api.tencentmusic.com/tme/trpc/proxy`,header 含
   `method: GetQimei`、`service: trpc.tme_datasvr.qimeiproxy.QimeiProxy`、`appid: qimei_qq_android`、
   `sign: md5("qimei_qq_androidpzAuCmaFAaFaHrdakPjLIEqKrGnSOOvH" + str(ts))`、`user-agent: QQMusic`、`timestamp: str(ts)`;
   body `{"app":0,"os":1,"qimeiParams":{key,params,time:str(ts),nonce,sign,extra}}`。
6. 响应 `data` 是 JSON 字符串,再解析取 `data.q36`。
7. Python 版失败时回落常量 `q36 = "6c9d3cd110abca9b16311cee10001e717614"`。

> ⚠️ **实测(2026-10-08):搜索接口按 QIMEI36 限流,上面这个回落常量不可靠。**
> 用该常量连续搜索,会周期性进入持续约 30 秒的窗口:响应 `code=0`,但 `item_song` 为空,
> 与"真的没有结果"无法区分。同一时刻换任意新的 QIMEI36(真实 qimei 或 36 位随机 hex)均正常返回;
> 换 `uid`、去掉 `uid`、换 `DoSearchForQQMusicDesktop` 都无效。推测该常量被大量客户端共用而被限流。
> Rust 版做法:启动时请求真实 qimei(`qimei` feature,默认开启),失败则用 36 位随机 hex;
> 搜索返回空结果时更换 QIMEI36 重试(最多 3 次)。**不要使用这个回落常量。**

PEM 公钥在 qqutils.py 中是单行超长 base64,不符合 RFC 7468(每行 ≤64 字符),
Rust 的 `pem-rfc7468` 会拒绝解析;Rust 版只保存 base64 主体并按 DER 解析。

`SECRET`、`APP_KEY`、`PUBLIC_KEY`、设备字段(`Device` dataclass)都在 `qqutils.py` 顶部。
~~MVP 可以先直接用回落常量 q36~~ —— 见上方实测,已改为默认请求 qimei。

### 签名 sign(仅加密端点 `musics.fcg` 需要)

`qqutils.py` `sign`:对 `orjson.dumps(request)`(紧凑 JSON)做 SHA1 大写 hex,按下标表取字符 + 与 SCRAMBLE 表异或后 base64 去掉 `\/+=`,拼成 `zzc...` 小写。
MVP 使用非加密端点 `musicu.fcg`,**不需要签名**。

---

## 2. 搜索

`POST https://u.y.qq.com/cgi-bin/musicu.fcg`

Headers:`User-Agent: Mozilla/5.0 ... Chrome/148`、`Referer: https://y.qq.com/`、`Origin: https://y.qq.com/`

module/method:`music.search.SearchCgiService` / `DoSearchForQQMusicMobile`

```json
"param": {"searchid": "<随机>", "query": "夜曲", "search_type": 0,
          "num_per_page": 10, "page_num": 1, "highlight": 1, "grp": 1}
```

- `searchid = rand(1..20)*18014398509481984 + rand(0..4194304)*4294967296 + (now_ms % 86400000)`,转字符串。
- `search_type`:0 歌曲、2 专辑、3 歌单(见 `SearchType`)。
- 分页:要 N 条就按 `num_per_page` 拆成多页,各页并行请求。

响应路径:`["music.search.SearchCgiService.DoSearchForQQMusicMobile"].data.body.item_song[]`

每首歌关键字段(实测):

```json
{"mid": "001zMQr71F1Qo8", "title": "夜曲", "interval": 226,
 "singer": [{"mid": "0025NhlN2yWrP4", "name": "周杰伦"}],
 "album":  {"mid": "0024bjiL2aocxT", "name": "十一月的萧邦", "title": "十一月的萧邦"},
 "file":   {"media_mid": "0024jrso28p8VA", "size_flac": 26691277, "size_320mp3": 9075745,
            "size_hires": 0, "hires_bitdepth": 0, ...}}
```

- ⚠️ `highlight=1` 时 `title`、`album.title` 等字段会带 `<em>…</em>` 高亮标签(实测 `album.title = "<em>十一月的萧邦</em>"`)。
  Python 版靠 `legalizestring` 隐式剥离;Rust 版取专辑名优先用 `album.name`(无标签),并统一剥离 HTML 标签。
- 空结果:限流时同样返回 `code=0` + 空 `item_song`,见上文 QIMEI36 一节。
- 封面:`https://y.gtimg.cn/music/photo_new/T002R800x800M000{album.mid}.jpg`
- `file.size_*` 可用于在解析直链前预判可用品质(Python 版未利用,可作为优化)。
- 缺专辑信息时补查:`music.pf_song_detail_svr` / `get_song_detail_yqq`,param `{"song_mid": mid}`,取 `songinfo.data.track_info`。
  实测 `track_info` 与 `item_song` 结构相同(mid/title/name/interval/singer/album/file),Rust 版 `get <mid>` 直接复用同一解析。
  不存在的 mid:节点 `code = 404`,`track_info` 各字段为空串/0。
- 实测(2026-10-09)param 也可用数字 `{"song_id": 102065756}`,返回同样结构;不存在的 songid 返回 `code = 500`。
- 歌曲链接(`get` 接受):`/n/ryqq/songDetail/{mid}`、`/n/yqq/song/{mid}.html`、`/v8/playsong.html?songmid=…&songid=…`(同时有时优先 mid);分享短链先跟随跳转再从最终 URL 中解析。

---

## 3. 取下载直链

按顺序尝试,拿到"有效且是音频"的链接即停。

### 3.1 vkeys(L1,无鉴权,实测最快 ~0.2s)

`GET https://api.vkeys.cn/music/tencent/song/link?mid={mid}&quality={q}`

- 依次尝试 `q = 14, 13, 12, 11, 10, 9`
  (14 臻品母带2.0 / 13 臻品全景声 / 12 杜比全景声 / 11 Hi-Res / 10 SQ无损 / 9 HQ增强)
- 响应:`{"code":0,"data":{"url":"http://ws.stream.qqmusic.qq.com/F000....flac?...","kbps":"942kbps",...}}`
- ⚠️ Python 版遇到某档 `data.url` 为空就 `break` 整个接口(不降档)。实测 12 档常常为空、而 11/10 有,**Rust 版应改为 `continue` 降档**。
- 实测某些档返回的 URL 会 404(如 11 档 `RS01...flac`),必须经过 probe 验证。
- 实测(2026-10-08,夜曲 `001zMQr71F1Qo8`,每次 ~0.2s):

  | q | 结果 |
  |---|---|
  | 14 | `AI00….flac`,`kbps: "5497kbps"`,155,620,200 字节(臻品母带,单首 150–200MB 很常见) |
  | 13 | `Q000….flac`,921kbps,26,097,982 字节 |
  | 12 | `{"code":110000,"message":"HTTP客户端异常:音源获取失败。"}`,无 `data` |
  | 11 | `RS01….flac`,`kbps: "0kbps"`,HEAD **404** |
  | 10 | `F000….flac`,942kbps,26,691,277 字节(= 搜索结果 `size_flac`) |
  | 9 | `O800….ogg`,344kbps,HEAD **404** |

  因此按 Python 的"首个有效档即停",默认几乎总是拿到 14 档臻品母带。同一首歌的 14/13 档偶有时有时无。
- 更低档位(Python 未使用,2026-10-08 实测夜曲 / 一路向北):

  | q | 结果 |
  |---|---|
  | 8 | `M800….mp3`,320kbps,HEAD 200(9,075,745 字节 = 搜索结果 `size_320mp3`),**稳定可用** |
  | 7 / 6 | 两首中各有一个返回 `code 110000`;有 url 时为 `O600….ogg` / `M500….mp3` |
  | 5 | `O400….ogg` ~90kbps |
  | 4 | `C600….m4a` 192kbps |

  9 档的 `O800….ogg` 两首都 404,所以 Rust 版在 9 档后追加 8 档(HQ 档位),否则 `--max-quality hq` 时付费歌几乎拿不到链接。
  7 档及以下低于 320kbps,会被低质过滤丢弃,不使用。
- Rust 版 `--max-quality <master|atmos|hires|sq|hq|std>` 档位映射:
  14→master,13/12→atmos,11→hires,10→sq,9/8→hq;tang pq→atmos、sq→sq;
  官方 AI00→master,Q000/Q001→atmos,F000→sq,O801/O800/M800→hq,其余→std。

### 3.2 xcvts(L1,需 apiKey)——已下架,Rust 版已移除

Python 版 `GET https://api.xcvts.cn/api/music/qq?apiKey={key}&mid={mid}&type={quality}`,`type` 为 `臻品母带`/`臻品全景声`/`臻品2.0`/`SQ无损`,直链在 `data.music`。
实测 2026-10-08 返回 HTTP 200 + 纯文本"接口已下架或不存在"。Rust 版 2026-10-09 起移除该接口;旧配置里的 `[xcvts]` 段只告警、不报错。

### 3.2b tang(Python L2,Rust 版作为 vkeys 的无损备用源)

`GET https://tang.api.s01s.cn/music_open_api.php?mid={mid}`(无鉴权,单次约 1s)

- 一次返回全部品质(实测夜曲):

  | 字段 | 文件 | 说明 |
  |---|---|---|
  | `song_play_url_pq` | `Q000….flac`,923kbps | 全景声,与 vkeys 13 档同一文件 |
  | `song_play_url_sq` | `F000….flac`,944kbps | SQ 无损,与 vkeys 10 档同一文件 |
  | `song_play_url_accom` | `O801003KMMlh4Cgwwk.ogg` | ⚠️ media mid 与原曲不同,是**伴奏**,不能用 |
  | `song_play_url_hq` / `_standard` / `_fq` | C600 / C400 / C200 `.m4a` | ≤192k,会被低质过滤丢弃 |

  链接域名为 `isure6.stream.qqmusic.qq.com`。另有 `song_size_*`、`kbps_*`、`song_name`、`album_name` 等字段。没有臻品母带(AI00)。
- 免费歌 `sq`/`pq` 为空串;不存在的 mid 各字段为 null,HTTP 仍是 200。
- ⚠️ **限流**:被限流时返回 HTTP 200 + 纯文本"请求过于频繁"。实测串行、并发 2 都正常,并发 8 会被限流;
  另有时间窗口内的总量配额。配额实测(2026-10-08,冷却 90s 后串行请求,单次样本):
  前 **17 次**成功,第 18 次(开始后 24.7s)被限流;之后每 10s 探测一次,限流 **约 41s** 后恢复。
  即大约每分钟 17 次左右。退避 1.5s/3s 等不过这个窗口,所以重试耗尽即熔断。
  熔断实测:只开 tang 解析 20 首,16 首拿到无损,触发限流后只告警一次,其余直接放弃,共 19s。
  Rust 版:全局最多 2 个并发请求;被限流时退避重试(1.5s、3s),仍失败则本次运行熔断不再请求。
- Rust 版第三方接口顺序:vkeys → tang,拿到 SQ 及以上即停;否则继续问下一个,取档位最高的。
  只开 tang 时(模拟 vkeys 失效)十一月的萧邦 10 首全部拿到全景声 / SQ。
- 同一后端的 `api.nki.pw`(需 key)与 `api.hk0.cc` 响应格式相同但每次 11–13s,不接入。

### 3.2c 其他 L1 现状(2026-10-08)

- `api.317ak.com`:网站在,接口带 Python 版两把 ckey 均返回 **403**(key 失效)。
- `api.xingmian.bbroot.com`:接口 **404**,域名首页已无法连接。
- L2 的 `api.chksz.com` 读超时(>20s);`qqovo.top` 返回 403 `{"error": "请求无效,请刷新页面后重试"}`。

### 3.3 官方 GetVkey(兜底;有登录凭据时是唯一路径)

`POST musicu.fcg`,module/method:`music.vkey.GetVkey` / `UrlGetVkey`,`comm.ct = "19"`

```json
"param": {"filename": ["F000{mid}{mid}.flac"], "guid": "<32位随机hex>",
          "songmid": ["{mid}"], "songtype": [0]}
```

品质表(从高到低,`qqutils.py` `SongFileType.SORTED_QUALITIES`):

| 前缀 | 扩展名 | 含义 |
|---|---|---|
| AI00 | .flac | 臻品母带 |
| Q000 | .flac | 全景声 2.0 |
| Q001 | .flac | 全景声 5.1 |
| F000 | .flac | 无损 |
| O801 / O800 / O600 / O400 | .ogg | 640/320/192/96k |
| M800 / M500 | .mp3 | 320k / 128k |
| C600 / C400 / C200 | .m4a | 192/96/48k |

Rust 版把 M800 挪到 O800 之后(Python 原顺序中 M800 排在 O600/O400 之后,两者都可用时会选到更低码率)。

响应:`["music.vkey.GetVkey.UrlGetVkey"].data.midurlinfo[0].purl`(或 `wifiurl`),为空表示无权限(此时 `result = 22`)。

**批量请求(Rust 版用法)**:`filename` / `songmid` / `songtype` 三个数组可一次带上全部 13 个品质,
`midurlinfo` 逐项返回(以 `filename` 对应),实测结果与逐个请求一致。Python 版是逐档串行请求,最多 13 次;
Rust 版 1 次请求后按品质表从高到低挑首个能通过 probe 的。
实测免费歌曲"小星星"(`002MicCm2pZIuc`)无凭据时 O801 / O600 / O400 / M500 / C400 / C200 有 purl,其余 `result=22`;
付费歌曲(`pay.pay_play = 1`,如夜曲)全部为空。
完整 URL = `https://isure.stream.qqmusic.qq.com/` + purl。
无 VIP 凭据时通常只能拿到 M500 / C400 这类低品质。

加密端点(`music.vkey.GetEVkey` / `CgiGetEVkey`,`musics.fcg?sign=`,文件为 `.mflac/.mgg`,带 `ekey`)**不在范围内**。

---

## 3.4 歌单(qq.py `parseplaylist`)

`GET https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg`

- params:`disstid={id}&type=1&json=1&utf8=1&onlysong=0&format=json`
- header:`Referer: https://y.qq.com/n/ryqq/playlist/{id}`
- 响应 `cdlist[0]`:`dissname`(歌单名)、`songnum`、`songlist[]`。Python 还兜底 `cdlist[0].list` / 顶层 `songlist`。
- 实测(2026-10-08):214 首的歌单一次返回全部,约 0.3s,**不分页**。
- ⚠️ `songlist` 每项是**旧格式字段**,与搜索 `item_song` 不同:

  ```json
  {"songmid": "0039MnYb0qxYhV", "songname": "晴天", "interval": 269,
   "singer": [{"id": 4558, "mid": "0025NhlN2yWrP4", "name": "周杰伦"}],
   "albummid": "000MkMni19ClKG", "albumname": "叶惠美",
   "sizeflac": 55397039, "size320": 10792943, ...}
  ```
- 不存在的 ID:`{"code": -1, "cdnum": 0, "cdlist": [], ...}`。
- `dissname` 也可能带 `<em>` 标签(从搜索结果拿到的歌单名实测带),统一剥离。
- 歌单 ID 来源:纯数字;链接的 `?id=` / `?disstid=`;路径最后一段(`/playlist/{id}`、`{id}.html`)。
  Python 会先 HEAD 跟随跳转再取 ID(兼容 `c6.y.qq.com/base/fcgi-bin/u?__=…` 短链),并要求域名属于
  `y.qq.com / i.y.qq.com / m.y.qq.com / c.y.qq.com / c6.y.qq.com / music.qq.com`。
  Rust 版:能直接取到 ID 就不发请求;取不到且是 QQ 域名时 GET 跟随跳转后再取。**短链跳转未实测**(手头没有短链样本)。
- 找歌单 ID 的办法:搜索 `search_type=3`,响应 `body.item_songlist[].dissid`。
- Python 把歌单下载到以歌单名命名的子目录,Rust 版同样是 `<输出目录>/<歌单名>/`。

## 3.5 专辑(Python 版没有,以下全部为实测)

**搜索专辑**:同歌曲搜索,`search_type = 2`,结果在 `body.item_album[]`:

```json
{"albummid": "0024bjiL2aocxT", "name": "<em>十一月的萧邦</em>", "singer": "周杰伦",
 "singer_list": [{"name": "周杰伦"}], "song_num": 12, "publish_date": "2005-11-01", "id": 60671}
```

**曲目**:`POST musicu.fcg`,`music.musichallAlbum.AlbumSongList` / `GetAlbumSongList`

```json
"param": {"albumMid": "0024bjiL2aocxT", "albumID": 0, "begin": 0, "num": 100, "order": 2}
```

- 也可用数字 ID:`{"albumMid": "", "albumID": 60671, ...}`,响应 `data.albumMid` 会给出 mid。
- 响应 `data.totalNum`、`data.curBegin`、`data.songList[].songInfo`;`songInfo` 与搜索 `item_song` **结构相同**。
- 分页:`begin=5, num=5` 返回第 6–10 首;`num=1000` 也能一次返回全部(12 首 / 36 首的专辑实测)。Rust 版按 100 翻页。
- 不存在的专辑:节点 `code = 104400`。
- ⚠️ 曲序字段 `songInfo.index_album` **不可靠**:整张专辑连续编号(第 2 张碟从 21 继续,而不是从 1 重新开始),
  且会重复——实测"宝丽金极品音色系列1盒2CD"(`001oz4vo1MOXFk`)中"初次尝到寂寞"和"难忘初恋情人"都是 10。
  Rust 版写入标签的曲序用**曲目列表中的位置**(1..N,`order=2` 即专辑页显示顺序),总数为 N。
- `songInfo.index_cd` 从 0 开始(上面的 2CD 专辑为 0 / 1,单碟专辑全是 0)。只有多碟时才写碟号 `index_cd + 1` / 总碟数。

**详情**:`music.musichallAlbum.AlbumInfoServer` / `GetAlbumDetail`,param `{"albumMid": mid}`

- `data.basicInfo.albumName`、`publishDate`、`albumType`(如"录音室专辑");`data.singer.singerList[].name`。

**专辑链接**:`/n/ryqq/albumDetail/{mid}`、`/n/yqq/album/{mid}.html`、`?albummid=` / `?albumId=`(移动端分享页)。

Rust 版 `uta album` 下载到 `<输出目录>/<歌手> - <专辑名>/`,文件名不带曲序,曲序/碟号写入标签
(Vorbis `TRACKNUMBER`/`TRACKTOTAL`/`DISCNUMBER`/`DISCTOTAL`、ID3 `TRCK`/`TPOS`、MP4 `trkn`/`disk`),已有则不覆盖。

## 3.6 歌手与歌手专辑(Python 版没有,以下全部为实测 2026-10-09)

### 搜索歌手

同搜索接口,`search_type = 1`。⚠️ 结果**不在** `item_singer`,而在 `data.body.singer[]`:

- `singerMID`(14 位)、`singerID`、`singerName`、`singerName_hilight`(带 `<em>`)、`albumNum`、`songNum`、`mvNum`、`singerPic`
- 搜"周杰伦"第一条即本人(`0025NhlN2yWrP4`,43 张专辑);搜 "jay" 第一条是同名的另一位歌手 "JAY"。
  Rust 版:名字与关键词完全一致(忽略大小写)直接选用,否则交互选择(`-y` / `--json` / 非交互取第 1 个)。

### 专辑列表

`music.musichallAlbum.AlbumListServer` / `GetAlbumList`

```json
"param": {"singerMid": "0025NhlN2yWrP4", "singerID": 0, "order": 0, "begin": 0, "num": 80, "songNumTag": 1}
```

- 响应 `data.total`、`data.albumList[]`:`albumMid`、`albumID`、`albumName`、`albumType`、`publishDate`、
  `totalNum`(曲目数,**需 `songNumTag=1`**,否则为 0)、`singerName`(合作专辑以 `/` 分隔多人)。
- `order`:0 = 发行时间从新到旧,1 = 热度。
- **单页最多 80 张**:`num=100/500` 也只返回 80。按 `begin += 80` 翻页无重复(邓丽君 397 张、刘德华 166 张实测取全)。
- `singerMid` 为空时用 `singerID` 也可(周杰伦 4558 → total 43)。
- 不存在的 singermid:`code = 104400`,`albumList` 为空。
- `albumType` 实测取值:`录音室专辑`、`EP`、`Single`、`演唱会`、`人声音频`。
  Rust 版 `--type`:studio / ep / single / live,其余归 other。
- 歌手链接:`/n/ryqq/singer/{mid}`、`/n/yqq/singer/{mid}.html`、`?singermid=`。
- 选中专辑后按 3.5 取曲目与详情,下载流程与 `album` 子命令相同。

## 4. 链接验证 probe

`misc.py` `AudioLinkTester.test`:

1. `HEAD url`(跟随重定向)→ 2xx 时依次用 URL 扩展名 / Content-Disposition / Content-Type 推断格式,`Content-Length` 为大小。
   ⚠️ 实测 `ws.stream.qqmusic.qq.com` 对 `.flac` 也返回 `Content-Type: audio/x-ogg`,**必须 URL 扩展名优先**。
2. 推断失败 → `GET url`(stream)读前 8KB,按文件头嗅探(`fLaC`、`ID3`/`\xFF\xFB`、`OggS`、`ftyp` 等)。
3. 非 2xx 或推断不出音频格式 → 无效。

过滤规则(Python 版):`file_size_bytes * 8 < 320000 * duration_s` 视为低质丢弃。

---

## 5. 歌词

`GET https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg`

- params:`songmid={mid}&g_tk=5381&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&platform=yqq`
- header:`Referer: https://y.qq.com/portal/player.html`
- 响应 `lyric` 字段为 base64 编码的 LRC。
- 实测:无歌词(含不存在的 mid)返回 `{"retcode":1101,"code":1101,"subcode":1101}`,没有 `lyric` 字段。
- 清理规则(Python `cleanlrc`):统一换行;每行去掉首尾空白、BOM、零宽字符、NBSP;丢弃空行和只有时间戳的行(如 `[00:10.00]`)。

---

## 6. 下载与后处理

### 断点续传(Rust 版,实测 2026-10-08)

- `ws.stream.qqmusic.qq.com` 与 `isure6.stream.qqmusic.qq.com` 都支持 Range:`Range: bytes=1000-1999` → `206`,
  `Content-Range: bytes 1000-1999/26691277`;开放区间 `bytes=N-` 也可以。
- `ETag` 是内容哈希,两个域名对同一文件给出相同 ETag;`Last-Modified` 也一致。
- `If-Range: <ETag>`:一致 → 206;不一致 → 200 返回整个文件。起点等于文件长度 → `416`。
- Rust 版:首次下载把 ETag 存到 `<文件>.part.etag`;之后(同次重试或下次运行,即使 vkey 已换)发
  `Range: bytes=<已有>-` + `If-Range`,206 则追加,200 则从头写,416 则清除后重下。
  下载失败与 Ctrl-C 都保留 `.part` + `.etag`;写标签/改名失败(文件可能已损坏)才删除。
- 真实验证:母带夜曲下载到 8.3MB 时 Ctrl-C,重新运行从 7.9MB 处续传(BufWriter 未落盘的部分会重下),
  成品音频数据(跳过元数据块)的 SHA-256 与完整下载的一致。


- 下载:流式 GET,header `Referer: http://y.qq.com` + 浏览器 UA;失败可重试一次。
- Rust 版超时:臻品母带单首 150–200MB,无法套用 15s 总超时;下载请求改为"连接 5s + 读取空闲 15s"
  (reqwest `read_timeout`),其余接口请求仍是连接 5s + 总计 15s。
- Rust 版先写 `<文件>.part`,校验字节数与 `Content-Length` 一致后再改名;目标已存在则跳过(Python 是加 `(1)` 后缀另存)。
  同一批内重名(同歌名同歌手的不同版本)时追加 ` [mid]` 区分。
- 文件名需清理非法字符(Python 用 `pathvalidate`)。
- 写 `.lrc` 同名文件。
- 标签(`songinfoutils.py`):标题、歌手、专辑、内嵌歌词(FLAC 用 `LYRICS` Vorbis comment;MP3 用 USLT)、下载封面并内嵌;已有标签默认不覆盖。
- 实测 QQ 下发的文件**自带** `TITLE`/`ARTIST`/`ALBUM`(母带还有 `QMQUALITY=Premium-Master`),没有封面和歌词;
  `ARTIST` 只有第一位歌手(如珊瑚海只有"周杰伦"),按不覆盖规则保持原样。
- Rust 版(lofty 0.25)注意:**不能用通用 `Tag` / `TaggedFile` 写回**——实测会删除 `QMQUALITY` 等非标准字段,
  并把 vendor 字符串改写成 `ENCODER` 字段;`SplitTag`/`MergeTag` 也会丢掉封面和多值歌手。
  必须直接操作具体类型(`FlacFile` + `VorbisComments`、`VorbisFile`、`MpegFile` + `Id3v2Tag`、`Mp4File` + `Ilst`)。
  FLAC 的封面存在 `FlacFile` 本身(PICTURE 块),判断"已有封面"要看文件而不是 Vorbis 注释。
- 封面 `T002R800x800M000{album_mid}.jpg` 实测 800×800 JPEG,约 90KB;同专辑只下载一次。
- Rust 版在 `.part` 上写完标签、重新解析校验通过后才改名;写标签失败但音频仍可解析时保留无标签文件并告警。