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
//! 专辑:搜索(search_type=2)、曲目(`music.musichallAlbum.AlbumSongList` / `GetAlbumSongList`)、
//! 详情(`music.musichallAlbum.AlbumInfoServer` / `GetAlbumDetail`)。Python 版没有专辑功能,以下均以实测为准。
//!
//! 曲目的 `songInfo` 与搜索 `item_song` 结构相同,直接复用 `song_from_item`。
//! 曲序不用 `index_album`:实测它是整张专辑连续编号,且会出现重复(两首都是 10),
//! 改用曲目列表中的位置;`index_cd` 从 0 开始,只有多碟专辑才写碟号。

use anyhow::{Context, Result, bail};
use futures::future::try_join_all;
use serde_json::{Value, json};
use tracing::debug;

use super::search::{PAGE_SIZE, SearchType, clean_text, plan_pages, search_page, song_from_item};
use super::{Session, is_qq_host};
use crate::model::{Album, AlbumHit, Song, tracks_for};

const SONGS_MODULE: &str = "music.musichallAlbum.AlbumSongList";
const SONGS_METHOD: &str = "GetAlbumSongList";
const DETAIL_MODULE: &str = "music.musichallAlbum.AlbumInfoServer";
const DETAIL_METHOD: &str = "GetAlbumDetail";
/// 曲目分页大小。实测 num=1000 也能一次取完,这里保守按 100 翻页。
const SONGS_PAGE: u32 = 100;
/// 翻页上限,防止接口异常时死循环。
const MAX_PAGES: u32 = 50;

impl AlbumHit {
    pub fn from_item(item: &Value) -> Option<AlbumHit> {
        let s = |k: &str| item.get(k).and_then(Value::as_str).map(clean_text);
        let mid = s("albummid").filter(|m| !m.is_empty())?;
        let singer = item
            .get("singer_list")
            .and_then(Value::as_array)
            .map(|a| {
                a.iter()
                    .filter_map(|x| x.get("name").and_then(Value::as_str).map(clean_text))
                    .filter(|n| !n.is_empty())
                    .collect::<Vec<_>>()
                    .join(", ")
            })
            .filter(|n| !n.is_empty())
            .or_else(|| s("singer"))
            .unwrap_or_default();
        Some(AlbumHit {
            id: mid,
            name: s("name").unwrap_or_default(),
            singer,
            song_num: item.get("song_num").and_then(Value::as_u64).unwrap_or(0),
            publish_date: s("publish_date").unwrap_or_default(),
        })
    }
}

/// 专辑的标识:14 位 albummid,或数字 albumID。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AlbumRef {
    Mid(String),
    Id(u64),
}

fn is_mid(s: &str) -> bool {
    s.len() == 14 && s.bytes().all(|b| b.is_ascii_alphanumeric())
}

fn ref_from_token(s: &str) -> Option<AlbumRef> {
    if is_mid(s) {
        Some(AlbumRef::Mid(s.to_string()))
    } else if !s.is_empty() && s.len() <= 20 && s.bytes().all(|b| b.is_ascii_digit()) {
        s.parse().ok().map(AlbumRef::Id)
    } else {
        None
    }
}

/// `--mid` 的取值:albummid 或数字 albumID。
pub fn parse_mid_arg(s: &str) -> Option<AlbumRef> {
    ref_from_token(s.trim())
}

/// 从 QQ 音乐专辑链接中取出专辑标识;不是 QQ 专辑链接返回 None(调用方按关键词处理)。
///
/// 支持:`/albumDetail/{mid}`、`/album/{mid}.html`,以及 `?albummid=` / `?albumMid=` / `?albumId=` / `?albumid=`。
pub fn parse_link(input: &str) -> Option<AlbumRef> {
    let input = input.trim();
    if !input.contains("://") || !is_qq_host(input) {
        return None;
    }
    let (before_frag, _) = input.split_once('#').unwrap_or((input, ""));
    let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
    for pair in query.split('&') {
        if let Some((k, v)) = pair.split_once('=')
            && matches!(
                k,
                "albummid" | "albumMid" | "albumId" | "albumid" | "albumID"
            )
            && let Some(r) = ref_from_token(v)
        {
            return Some(r);
        }
    }
    let segs: Vec<&str> = path.trim_end_matches('/').split('/').collect();
    let pos = segs
        .iter()
        .position(|s| s.eq_ignore_ascii_case("albumDetail") || *s == "album")?;
    let token = segs.get(pos + 1)?;
    ref_from_token(token.trim_end_matches(".html").trim_end_matches(".htm"))
}

/// 搜索专辑,最多 `limit` 条。
pub async fn search(session: &Session, keyword: &str, limit: usize) -> Result<Vec<AlbumHit>> {
    let pages = plan_pages(limit, PAGE_SIZE.min(limit.max(1)));
    let results = try_join_all(
        pages
            .iter()
            .map(|&(page, size)| search_page(session, keyword, SearchType::Album, page, size)),
    )
    .await?;
    let mut seen = std::collections::HashSet::new();
    Ok(results
        .iter()
        .flatten()
        .filter_map(AlbumHit::from_item)
        .filter(|a| seen.insert(a.id.clone()))
        .take(limit)
        .collect())
}

/// 一页曲目响应里的 (歌曲, index_cd)。
fn parse_song_page(node: &Value) -> Vec<(Song, u32)> {
    node.pointer("/data/songList")
        .and_then(Value::as_array)
        .map(|list| {
            list.iter()
                .filter_map(|x| {
                    let info = x.get("songInfo")?;
                    let cd = info.get("index_cd").and_then(Value::as_u64).unwrap_or(0) as u32;
                    song_from_item(info).map(|s| (s, cd))
                })
                .collect()
        })
        .unwrap_or_default()
}

/// 取全部曲目(自动翻页),返回 (albummid, 曲目),曲目已填好曲序。
async fn fetch_songs(session: &Session, r: &AlbumRef) -> Result<(String, Vec<Song>)> {
    let (mid, id) = match r {
        AlbumRef::Mid(m) => (m.clone(), 0),
        AlbumRef::Id(i) => (String::new(), *i),
    };
    let mut found_mid = mid.clone();
    let mut all: Vec<(Song, u32)> = Vec::new();
    for page in 0..MAX_PAGES {
        let begin = page * SONGS_PAGE;
        let qimei36 = session.qimei36().await;
        let node = session
            .call(
                &qimei36,
                "11",
                SONGS_MODULE,
                SONGS_METHOD,
                json!({"albumMid": mid, "albumID": id, "begin": begin, "num": SONGS_PAGE, "order": 2}),
            )
            .await
            // 实测不存在的专辑返回 code=104400
            .context("获取专辑曲目失败(专辑可能不存在)")?;
        if let Some(m) = node.pointer("/data/albumMid").and_then(Value::as_str)
            && !m.is_empty()
        {
            found_mid = m.to_string();
        }
        let total = node
            .pointer("/data/totalNum")
            .and_then(Value::as_u64)
            .unwrap_or(0) as usize;
        let got = parse_song_page(&node);
        debug!(begin, got = got.len(), total, "专辑曲目页");
        let empty = got.is_empty();
        all.extend(got);
        if empty || all.len() >= total {
            break;
        }
    }
    let mut seen = std::collections::HashSet::new();
    all.retain(|(s, _)| seen.insert(s.id.clone()));
    let cds: Vec<u32> = all.iter().map(|(_, cd)| *cd).collect();
    let songs = all
        .into_iter()
        .zip(tracks_for(&cds))
        .map(|((mut s, _), t)| {
            s.track = Some(t);
            s
        })
        .collect();
    Ok((found_mid, songs))
}

/// 详情:(专辑名, 歌手, 发行日期)。
fn parse_detail(node: &Value) -> (String, Vec<String>, String) {
    let s = |p: &str| {
        node.pointer(p)
            .and_then(Value::as_str)
            .map(clean_text)
            .unwrap_or_default()
    };
    let singers = node
        .pointer("/data/singer/singerList")
        .and_then(Value::as_array)
        .map(|a| {
            a.iter()
                .filter_map(|x| x.get("name").and_then(Value::as_str).map(clean_text))
                .filter(|n| !n.is_empty())
                .collect()
        })
        .unwrap_or_default();
    (
        s("/data/basicInfo/albumName"),
        singers,
        s("/data/basicInfo/publishDate"),
    )
}

pub async fn fetch(session: &Session, r: &AlbumRef) -> Result<Album> {
    let (mid, songs) = fetch_songs(session, r).await?;
    if songs.is_empty() {
        bail!("专辑没有可用曲目");
    }
    let qimei36 = session.qimei36().await;
    let detail = session
        .call(
            &qimei36,
            "11",
            DETAIL_MODULE,
            DETAIL_METHOD,
            json!({ "albumMid": mid }),
        )
        .await;
    let (mut name, mut singers, publish_date) = match detail {
        Ok(node) => parse_detail(&node),
        Err(e) => {
            debug!("获取专辑详情失败,改用曲目信息: {e:#}");
            Default::default()
        }
    };
    // 详情缺失时用第一首歌的专辑名与歌手兜底
    if name.is_empty() {
        name = songs[0].album.clone();
    }
    if singers.is_empty() {
        singers = songs[0].singers.clone();
    }
    Ok(Album {
        id: mid,
        name,
        singers,
        publish_date,
        songs,
    })
}

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

    #[test]
    fn links() {
        let mid = Some(AlbumRef::Mid("0024bjiL2aocxT".into()));
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT"),
            mid
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT/"),
            mid
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/yqq/album/0024bjiL2aocxT.html#stat=x"),
            mid
        );
        assert_eq!(
            parse_link(
                "https://i.y.qq.com/n2/m/share/details/album.html?albummid=0024bjiL2aocxT&x=1"
            ),
            mid
        );
        assert_eq!(
            parse_link("https://i.y.qq.com/n2/m/share/details/album.html?albumId=60671"),
            Some(AlbumRef::Id(60671))
        );
        // 非专辑链接 / 非 QQ 域名 / 关键词
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/playlist/7039749142"),
            None
        );
        assert_eq!(
            parse_link("https://evil.com/albumDetail/0024bjiL2aocxT"),
            None
        );
        assert_eq!(parse_link("十一月的萧邦"), None);
        assert_eq!(parse_link("0024bjiL2aocxT"), None);
    }

    #[test]
    fn mid_arg() {
        assert_eq!(
            parse_mid_arg(" 0024bjiL2aocxT "),
            Some(AlbumRef::Mid("0024bjiL2aocxT".into()))
        );
        assert_eq!(parse_mid_arg("60671"), Some(AlbumRef::Id(60671)));
        assert_eq!(parse_mid_arg("十一月的萧邦"), None);
        assert_eq!(parse_mid_arg("0024bjiL2aoc"), None);
    }

    #[test]
    fn tracks_single_disc() {
        let t = tracks_for(&[0, 0, 0]);
        assert_eq!(
            t.iter()
                .map(|t| (t.number, t.total, t.disc))
                .collect::<Vec<_>>(),
            [(1, 3, None), (2, 3, None), (3, 3, None)]
        );
        assert!(tracks_for(&[]).is_empty());
    }

    #[test]
    fn tracks_multi_disc() {
        // 实测"宝丽金极品音色系列1盒2CD":碟 0 有 21 首、碟 1 有 15 首
        let mut cds = vec![0; 21];
        cds.extend(vec![1; 15]);
        let t = tracks_for(&cds);
        assert_eq!(t.len(), 36);
        assert_eq!((t[0].number, t[0].total, t[0].disc), (1, 36, Some((1, 2))));
        assert_eq!((t[21].number, t[21].disc), (22, Some((2, 2))));
        assert_eq!(t[35].number, 36);
    }

    #[test]
    fn hit_from_item() {
        // 实测字段(搜索"十一月的萧邦",search_type=2)
        let item = json!({"albummid": "0024bjiL2aocxT", "name": "<em>十一月的萧邦</em>",
            "singer": "周杰伦", "singer_list": [{"name": "周杰伦"}], "song_num": 12,
            "publish_date": "2005-11-01", "id": 60671});
        let h = AlbumHit::from_item(&item).unwrap();
        assert_eq!(
            (h.id.as_str(), h.name.as_str(), h.singer.as_str()),
            ("0024bjiL2aocxT", "十一月的萧邦", "周杰伦")
        );
        assert_eq!((h.song_num, h.publish_date.as_str()), (12, "2005-11-01"));
        assert!(AlbumHit::from_item(&json!({"name": "无 mid"})).is_none());
    }

    #[test]
    fn song_page_and_detail() {
        let page = json!({"data": {"albumMid": "0024bjiL2aocxT", "totalNum": 2, "songList": [
            {"songInfo": {"mid": "001zMQr71F1Qo8", "title": "夜曲", "index_cd": 0, "index_album": 1,
                          "singer": [{"name": "周杰伦"}], "album": {"mid": "0024bjiL2aocxT", "name": "十一月的萧邦"}}},
            {"songInfo": {"mid": "", "title": "坏数据"}},
            {"isThemeSong": 0}
        ]}});
        let got = parse_song_page(&page);
        assert_eq!(got.len(), 1);
        assert_eq!((got[0].0.title.as_str(), got[0].1), ("夜曲", 0));

        let detail = json!({"data": {"basicInfo": {"albumMid": "0024bjiL2aocxT", "albumName": "十一月的萧邦",
            "publishDate": "2005-11-01"}, "singer": {"singerList": [{"name": "周杰伦"}]}}});
        let (name, singers, date) = parse_detail(&detail);
        assert_eq!(
            (name.as_str(), date.as_str()),
            ("十一月的萧邦", "2005-11-01")
        );
        assert_eq!(singers, ["周杰伦"]);
    }

    #[test]
    fn dir_name() {
        let a = Album {
            id: "m".into(),
            name: "十一月的萧邦".into(),
            singers: vec!["周杰伦".into()],
            publish_date: String::new(),
            songs: vec![],
        };
        assert_eq!(a.dir_name(), "周杰伦 - 十一月的萧邦");
    }
}

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

    fn session() -> Session {
        let client = reqwest::Client::builder()
            .connect_timeout(Duration::from_secs(5))
            .timeout(Duration::from_secs(15))
            .build()
            .unwrap();
        Session::new(client, Config::default())
    }

    #[tokio::test]
    #[ignore = "需要网络"]
    async fn album_real() {
        let s = session();
        let hits = search(&s, "十一月的萧邦", 5).await.unwrap();
        assert!(
            hits.iter()
                .any(|h| h.id == "0024bjiL2aocxT" && h.singer == "周杰伦")
        );

        let a = fetch(&s, &AlbumRef::Mid("0024bjiL2aocxT".into()))
            .await
            .unwrap();
        assert_eq!(
            (a.name.as_str(), a.publish_date.as_str()),
            ("十一月的萧邦", "2005-11-01")
        );
        assert_eq!(a.singers, ["周杰伦"]);
        assert_eq!(a.songs.len(), 12);
        assert_eq!(a.songs[0].title, "夜曲");
        assert_eq!(a.songs[11].track.unwrap().number, 12);

        // 数字 ID 与 mid 等价
        let b = fetch(&s, &AlbumRef::Id(60671)).await.unwrap();
        assert_eq!(b.id, "0024bjiL2aocxT");

        // 多碟专辑
        let c = fetch(&s, &AlbumRef::Mid("001oz4vo1MOXFk".into()))
            .await
            .unwrap();
        assert_eq!(c.songs.len(), 36);
        assert_eq!(c.songs[35].track.unwrap().disc, Some((2, 2)));

        assert!(
            fetch(&s, &AlbumRef::Mid("000000000000xx".into()))
                .await
                .is_err()
        );
    }
}