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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
//! 取下载直链:第三方接口(vkeys → tang)优先,官方 GetVkey 兜底。

use std::time::Duration;

use serde_json::{Value, json};
use tracing::{debug, warn};

use super::Session;
use super::api::random_guid;
use crate::model::{
    Level, ProviderCheck, ResolveOptions, Resolved, Song, below_320k, check_provider,
};
use crate::probe::{ProbeResult, probe};

/// vkeys 品质档(从高到低)。
const VKEYS_QUALITIES: &[(u32, &str, Level)] = &[
    (14, "臻品母带2.0", Level::Master),
    (13, "臻品全景声", Level::Atmos),
    (12, "杜比全景声", Level::Atmos),
    (11, "Hi-Res", Level::Hires),
    (10, "SQ无损", Level::Sq),
    (9, "HQ增强", Level::Hq),
    // 有意差异:Python 只用 14→9。实测 9 档的 O800 链接常 404,8 档 M800 稳定可用,
    // 否则 --max-quality hq 时付费歌几乎拿不到链接
    (8, "MP3 320k", Level::Hq),
];

/// 官方品质表(按档位从高到低):(前缀, 扩展名, 说明, 档位)。
pub const OFFICIAL_QUALITIES: &[(&str, &str, &str, Level)] = &[
    ("AI00", ".flac", "臻品母带", Level::Master),
    ("Q000", ".flac", "全景声2.0", Level::Atmos),
    ("Q001", ".flac", "全景声5.1", Level::Atmos),
    ("F000", ".flac", "无损", Level::Sq),
    ("O801", ".ogg", "OGG 640k", Level::Hq),
    ("O800", ".ogg", "OGG 320k", Level::Hq),
    // 有意差异:Python 版 M800 排在 O600/O400 之后,会在两者都可用时选到 192k
    ("M800", ".mp3", "MP3 320k", Level::Hq),
    ("O600", ".ogg", "OGG 192k", Level::Std),
    ("O400", ".ogg", "OGG 96k", Level::Std),
    ("M500", ".mp3", "MP3 128k", Level::Std),
    ("C600", ".m4a", "AAC 192k", Level::Std),
    ("C400", ".m4a", "AAC 96k", Level::Std),
    ("C200", ".m4a", "AAC 48k", Level::Std),
];

const STREAM_DOMAIN: &str = "https://isure.stream.qqmusic.qq.com/";
/// L1 拿到这些格式时视为足够,不再走官方接口(与 Python 版一致)。
const SUFFICIENT_EXTS: &[&str] = &["flac", "ogg"];

/// QQ 的取链接口。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Source {
    Vkeys,
    Tang,
    Official,
}

impl Source {
    fn name(self) -> &'static str {
        match self {
            Source::Vkeys => "vkeys",
            Source::Tang => "tang",
            Source::Official => "官方",
        }
    }
}

/// QQ 流媒体下载要带的 Referer。
const REFERER: &str = "http://y.qq.com";

fn resolved(source: Source, quality: &str, level: Level, probe: ProbeResult) -> Resolved {
    Resolved {
        via: source.name(),
        quality: quality.to_string(),
        level,
        probe,
        referer: Some(REFERER),
        fallback: None,
    }
}

async fn get_json(session: &Session, url: &str, query: &[(&str, &str)]) -> Option<Value> {
    let resp = match crate::net::send(session.client.get(url).query(query)).await {
        Ok(r) => r,
        Err(e) => {
            debug!(url, "请求失败: {e}");
            return None;
        }
    };
    let status = resp.status();
    let text = resp.text().await.ok()?;
    match serde_json::from_str(&text) {
        Ok(v) if status.is_success() => Some(v),
        _ => {
            let head: String = text.chars().take(80).collect();
            debug!(url, %status, "响应不是有效 JSON: {head}");
            None
        }
    }
}

/// 取一个候选 URL 并验证,第三方结果额外做低质过滤。
async fn accept(
    session: &Session,
    song: &Song,
    url: &str,
    third_party: bool,
) -> Option<ProbeResult> {
    if !url.starts_with("http") {
        return None;
    }
    let p = probe(&session.client, url).await?;
    if third_party && below_320k(p.size, song.interval) {
        debug!(mid = %song.id, size = p.size, "低于 320kbps,丢弃");
        return None;
    }
    Some(p)
}

async fn via_vkeys(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
    let base = session.config.vkeys.url.trim();
    for &(q, label, level) in VKEYS_QUALITIES.iter().filter(|q| q.2 <= max_level) {
        let qs = q.to_string();
        let Some(v) = get_json(session, base, &[("mid", &song.id), ("quality", &qs)]).await else {
            continue;
        };
        // 有意差异:某档无 url 时降档继续(Python 版直接放弃该接口)
        let Some(url) = v.pointer("/data/url").and_then(Value::as_str) else {
            debug!(mid = %song.id, q, "vkeys 该档无 url");
            continue;
        };
        if let Some(p) = accept(session, song, url, true).await {
            return Some(resolved(Source::Vkeys, label, level, p));
        }
        debug!(mid = %song.id, q, "vkeys 链接无效");
    }
    None
}

/// tang 响应里可用的字段(从高到低)。实测还有 `song_play_url_accom`,
/// 但它的 media mid 与原曲不同,是**伴奏**,不能用;hq/standard/fq 都是 ≤192k 的 m4a,会被低质过滤丢弃。
const TANG_FIELDS: &[(&str, &str, Level)] = &[
    ("song_play_url_pq", "全景声", Level::Atmos),
    ("song_play_url_sq", "SQ无损", Level::Sq),
];

/// 从 tang 响应中按档位从高到低取出 (说明, 档位, URL)。
pub fn tang_urls(v: &Value, max_level: Level) -> Vec<(&'static str, Level, String)> {
    TANG_FIELDS
        .iter()
        .filter(|f| f.2 <= max_level)
        .filter_map(|&(field, label, level)| {
            let url = v.get(field)?.as_str()?;
            url.starts_with("http")
                .then(|| (label, level, url.to_string()))
        })
        .collect()
}

/// tang 被限流时返回 HTTP 200 + 纯文本"请求过于频繁"。
pub fn tang_rate_limited(body: &str) -> bool {
    let b = body.trim_start();
    !b.starts_with('{') && b.contains("频繁")
}

/// 请求 tang:全局并发受限;被限流时退避重试,重试耗尽则熔断(本次运行不再请求)。
async fn tang_json(session: &Session, mid: &str) -> Option<Value> {
    use std::sync::atomic::Ordering;
    const ATTEMPTS: u64 = 3;
    let url = session.config.tang.url.trim();
    for attempt in 1..=ATTEMPTS {
        if session.tang_exhausted.load(Ordering::Relaxed) {
            return None;
        }
        let text = {
            let _permit = session.tang_permits.acquire().await.ok()?;
            let resp = match crate::net::send(session.client.get(url).query(&[("mid", mid)])).await
            {
                Ok(r) => r,
                Err(e) => {
                    debug!(mid, "tang 请求失败: {e}");
                    return None;
                }
            };
            resp.text().await.ok()?
        };
        if tang_rate_limited(&text) {
            debug!(mid, attempt, "tang 限流,稍后重试");
            if attempt < ATTEMPTS {
                tokio::time::sleep(Duration::from_millis(1500 * attempt)).await;
            }
            continue;
        }
        return match serde_json::from_str(&text) {
            Ok(v) => Some(v),
            Err(_) => {
                let head: String = text.chars().take(80).collect();
                debug!(mid, "tang 响应不是有效 JSON: {head}");
                None
            }
        };
    }
    // 只在第一次熔断时提示
    if !session.tang_exhausted.swap(true, Ordering::Relaxed) {
        warn!("tang 请求过于频繁,本次运行不再使用 tang(实测约 1 分钟后恢复)");
    }
    None
}

async fn via_tang(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
    let v = tang_json(session, &song.id).await?;
    for (label, level, url) in tang_urls(&v, max_level) {
        if let Some(p) = accept(session, song, &url, true).await {
            return Some(resolved(Source::Tang, label, level, p));
        }
    }
    debug!(mid = %song.id, "tang 无可用链接");
    None
}

/// 第三方接口:vkeys → tang。拿到 SQ 及以上即停;否则继续问下一个,取档位最高的。
async fn via_l1(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
    let cfg = &session.config;
    let mut best: Option<Resolved> = None;
    for source in [Source::Vkeys, Source::Tang] {
        if best
            .as_ref()
            .is_some_and(|b| b.level >= Level::Sq.min(max_level))
        {
            break;
        }
        let r = match source {
            Source::Vkeys if cfg.vkeys.enabled() => via_vkeys(session, song, max_level).await,
            Source::Tang if cfg.tang.enabled() => via_tang(session, song, max_level).await,
            _ => None,
        };
        if let Some(r) = r
            && best.as_ref().is_none_or(|b| r.level > b.level)
        {
            best = Some(r);
        }
    }
    best
}

/// 官方 GetVkey 的请求参数:一次请求带上全部品质的文件名。
pub fn official_param(mid: &str, guid: &str) -> Value {
    let n = OFFICIAL_QUALITIES.len();
    json!({
        "filename": OFFICIAL_QUALITIES
            .iter()
            .map(|(p, e, ..)| format!("{p}{mid}{mid}{e}"))
            .collect::<Vec<_>>(),
        "guid": guid,
        "songmid": vec![mid; n],
        "songtype": vec![0; n],
    })
}

/// 从 GetVkey 响应中取出可用的 (品质说明, 档位, 完整 URL),按品质从高到低。
pub fn official_urls(node: &Value) -> Vec<(&'static str, Level, String)> {
    let Some(infos) = node.pointer("/data/midurlinfo").and_then(Value::as_array) else {
        return Vec::new();
    };
    let mut out = Vec::new();
    for &(prefix, ext, label, level) in OFFICIAL_QUALITIES {
        let hit = infos.iter().find(|m| {
            m.get("filename")
                .and_then(Value::as_str)
                .is_some_and(|f| f.starts_with(prefix) && f.ends_with(ext))
        });
        let purl = hit.and_then(|m| {
            [m.get("purl"), m.get("wifiurl")]
                .into_iter()
                .flatten()
                .filter_map(Value::as_str)
                .find(|s| !s.is_empty())
        });
        if let Some(purl) = purl {
            out.push((label, level, format!("{STREAM_DOMAIN}{purl}")));
        }
    }
    out
}

async fn via_official(session: &Session, song: &Song, max_level: Level) -> Option<Resolved> {
    let qimei36 = session.qimei36().await;
    let node = match session
        .call(
            &qimei36,
            "19",
            "music.vkey.GetVkey",
            "UrlGetVkey",
            official_param(&song.id, &random_guid()),
        )
        .await
    {
        Ok(n) => n,
        Err(e) => {
            debug!(mid = %song.id, "GetVkey 失败: {e:#}");
            return None;
        }
    };
    for (label, level, url) in official_urls(&node)
        .into_iter()
        .filter(|u| u.1 <= max_level)
    {
        if let Some(p) = accept(session, song, &url, false).await {
            return Some(resolved(Source::Official, label, level, p));
        }
    }
    debug!(mid = %song.id, "官方接口无可用链接");
    None
}

/// 逐个检查取链接口(`uta doctor`):第三方用付费歌 `paid`,官方用免费歌 `free`(免登录拿不到付费歌)。
pub async fn check(session: &Session, paid: &Song, free: &Song) -> Vec<ProviderCheck> {
    let max = Level::Master;
    let cfg = &session.config;
    let (vkeys, tang, official) = tokio::join!(
        check_provider("vkeys", cfg.vkeys.enabled(), via_vkeys(session, paid, max)),
        check_provider("tang", cfg.tang.enabled(), via_tang(session, paid, max)),
        check_provider("官方", true, via_official(session, free, max)),
    );
    let mut tang = tang;
    if tang.result.is_none()
        && session
            .tang_exhausted
            .load(std::sync::atomic::Ordering::Relaxed)
    {
        tang.note = Some("请求过于频繁(约 1 分钟后恢复)".into());
    }
    vec![vkeys, tang, official]
}

/// 解析一首歌的下载直链。`lossless_only` 时 L1 拿不到无损就直接放弃。
pub async fn resolve(session: &Session, song: &Song, opts: ResolveOptions) -> Option<Resolved> {
    let l1 = via_l1(session, song, opts.max_level).await;
    if opts.lossless_only {
        return l1.filter(|r| r.probe.is_lossless());
    }
    if let Some(r) = &l1
        && SUFFICIENT_EXTS.contains(&r.probe.ext.as_str())
    {
        return l1;
    }
    let official = via_official(session, song, opts.max_level).await;
    match (l1, official) {
        // 与 Python 一致:两边都有时取文件更大的
        (Some(a), Some(b)) => Some(if a.probe.size > b.probe.size { a } else { b }),
        (a, b) => a.or(b),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn level_tables_sorted() {
        // 各表必须按档位从高到低排列,否则"首个可用即停"会选错
        let sorted = |v: Vec<Level>| v.windows(2).all(|w| w[0] >= w[1]);
        assert!(sorted(VKEYS_QUALITIES.iter().map(|q| q.2).collect()));
        assert!(sorted(OFFICIAL_QUALITIES.iter().map(|q| q.3).collect()));
        // --max-quality sq 时 vkeys 只试 10、9、8 档
        let sq: Vec<u32> = VKEYS_QUALITIES
            .iter()
            .filter(|q| q.2 <= Level::Sq)
            .map(|q| q.0)
            .collect();
        assert_eq!(sq, [10, 9, 8]);
    }

    #[test]
    fn tang_rate_limit_detection() {
        assert!(tang_rate_limited("请求过于频繁"));
        assert!(tang_rate_limited("  请求过于频繁,请稍后再试"));
        assert!(!tang_rate_limited(r#"{"song_name": "夜曲"}"#));
        // 正常 JSON 里歌名恰好含"频繁"不能误判
        assert!(!tang_rate_limited(r#"{"song_name": "频繁"}"#));
    }

    #[test]
    fn tang_fields() {
        // 实测字段(2026-10-08,夜曲)
        let v = json!({
            "song_play_url_sq": "http://isure6.stream.qqmusic.qq.com/F0000024jrso28p8VA.flac?vkey=1",
            "song_play_url_pq": "http://isure6.stream.qqmusic.qq.com/Q000003nAlv92U3mOy.flac?vkey=2",
            "song_play_url_accom": "http://isure6.stream.qqmusic.qq.com/O801003KMMlh4Cgwwk.ogg?vkey=3",
            "song_play_url_hq": "http://isure6.stream.qqmusic.qq.com/C6000024jrso28p8VA.m4a?vkey=4"
        });
        let got = tang_urls(&v, Level::Master);
        assert_eq!(
            got.iter().map(|g| (g.0, g.1)).collect::<Vec<_>>(),
            [("全景声", Level::Atmos), ("SQ无损", Level::Sq)]
        );
        assert!(got.iter().all(|g| !g.2.contains("O801")), "伴奏不能用");
        assert_eq!(tang_urls(&v, Level::Sq).len(), 1);
        assert!(tang_urls(&v, Level::Hq).is_empty());
        // 免费歌 / 不存在的 mid:字段为空串或 null
        assert!(
            tang_urls(
                &json!({"song_play_url_sq": "", "song_play_url_pq": null}),
                Level::Master
            )
            .is_empty()
        );
    }

    #[test]
    fn official_param_shape() {
        let p = official_param("001zMQr71F1Qo8", "g");
        let names = p["filename"].as_array().unwrap();
        assert_eq!(names.len(), OFFICIAL_QUALITIES.len());
        assert_eq!(names[0], "AI00001zMQr71F1Qo8001zMQr71F1Qo8.flac");
        assert_eq!(names[3], "F000001zMQr71F1Qo8001zMQr71F1Qo8.flac");
        assert_eq!(
            p["songmid"].as_array().unwrap().len(),
            OFFICIAL_QUALITIES.len()
        );
        assert_eq!(p["songtype"][0], 0);
        assert_eq!(p["guid"], "g");
    }

    #[test]
    fn official_urls_ordered_by_quality() {
        let node = json!({"data": {"midurlinfo": [
            {"filename": "C400xx.m4a", "purl": "C400xx.m4a?vkey=1"},
            {"filename": "F000xx.flac", "purl": ""},
            {"filename": "M500xx.mp3", "purl": "", "wifiurl": "M500xx.mp3?vkey=2"},
        ]}});
        let urls = official_urls(&node);
        assert_eq!(
            urls,
            vec![
                (
                    "MP3 128k",
                    Level::Std,
                    format!("{STREAM_DOMAIN}M500xx.mp3?vkey=2")
                ),
                (
                    "AAC 96k",
                    Level::Std,
                    format!("{STREAM_DOMAIN}C400xx.m4a?vkey=1")
                ),
            ]
        );
        assert!(official_urls(&json!({})).is_empty());
    }
}

#[cfg(test)]
mod net_tests {
    use super::*;
    use crate::config::Config;
    use std::time::Duration;

    #[tokio::test]
    #[ignore = "需要网络"]
    async fn official_batch_free_song() {
        let client = reqwest::Client::builder()
            .connect_timeout(Duration::from_secs(5))
            .timeout(Duration::from_secs(15))
            .build()
            .unwrap();
        let session = Session::new(client, Config::default());
        // 免费歌曲"小星星":无凭据也能拿到低品质
        let song = Song {
            id: "002MicCm2pZIuc".into(),
            title: "小星星".into(),
            ..Song::default()
        };
        let opts = ResolveOptions {
            lossless_only: false,
            max_level: Level::Master,
        };
        let r = resolve(&session, &song, opts).await.expect("应能解析");
        assert_eq!(r.via, "官方");
        assert_eq!(r.referer, Some(REFERER));
        assert!(r.probe.size > 0);
    }
}