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.
//! 歌手:搜索(search_type=1,结果在 `body.singer`)与专辑列表
//! (`music.musichallAlbum.AlbumListServer` / `GetAlbumList`)。Python 版没有此功能,以下均以实测为准。

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

use super::search::{SearchType, clean_text, search_page};
use super::{Session, is_qq_host};
use crate::model::{AlbumKind, AlbumOrder, ArtistAlbum, SingerHit};

const ALBUMS_MODULE: &str = "music.musichallAlbum.AlbumListServer";
const ALBUMS_METHOD: &str = "GetAlbumList";
/// 实测单页最多返回 80 张(num=100/500 也只给 80)。
const ALBUMS_PAGE: u64 = 80;
/// 翻页上限,防止接口异常时死循环(邓丽君 397 张 = 5 页)。
const MAX_PAGES: u64 = 50;

impl SingerHit {
    pub fn from_item(item: &Value) -> Option<SingerHit> {
        let mid = item
            .get("singerMID")
            .and_then(Value::as_str)
            .filter(|m| !m.is_empty())?
            .to_string();
        let n = |k: &str| item.get(k).and_then(Value::as_u64).unwrap_or(0);
        Some(SingerHit {
            id: mid,
            name: item
                .get("singerName")
                .and_then(Value::as_str)
                .map(clean_text)
                .unwrap_or_default(),
            album_num: n("albumNum"),
            song_num: n("songNum"),
        })
    }
}

impl AlbumKind {
    pub fn from_label(s: &str) -> AlbumKind {
        match s.trim() {
            "录音室专辑" => AlbumKind::Studio,
            "EP" => AlbumKind::Ep,
            "Single" => AlbumKind::Single,
            "演唱会" => AlbumKind::Live,
            _ => AlbumKind::Other,
        }
    }
}

impl ArtistAlbum {
    pub fn from_item(item: &Value) -> Option<ArtistAlbum> {
        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 album_type = s("albumType").unwrap_or_default();
        Some(ArtistAlbum {
            id: mid,
            name: s("albumName").unwrap_or_default(),
            // 合作专辑以 "/" 分隔多位歌手
            singer: s("singerName").unwrap_or_default().replace('/', ", "),
            kind: AlbumKind::from_label(&album_type),
            album_type,
            publish_date: s("publishDate").unwrap_or_default(),
            song_num: item.get("totalNum").and_then(Value::as_u64).unwrap_or(0),
        })
    }
}

impl AlbumOrder {
    fn code(self) -> u32 {
        match self {
            AlbumOrder::New => 0,
            AlbumOrder::Hot => 1,
        }
    }
}

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

/// `--mid` 的取值:14 位 singermid。
pub fn parse_mid_arg(s: &str) -> Option<String> {
    let s = s.trim();
    is_mid(s).then(|| s.to_string())
}

/// 从 QQ 音乐歌手链接中取出 singermid;不是歌手链接返回 None(调用方按关键词处理)。
///
/// 支持:`/singer/{mid}`、`/singer/{mid}.html`,以及 `?singermid=` / `?singerMid=`。
pub fn parse_link(input: &str) -> Option<String> {
    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, "singermid" | "singerMid" | "singer_mid")
            && is_mid(v)
        {
            return Some(v.to_string());
        }
    }
    let segs: Vec<&str> = path.trim_end_matches('/').split('/').collect();
    let pos = segs.iter().position(|s| *s == "singer")?;
    let token = segs.get(pos + 1)?;
    let token = token.trim_end_matches(".html").trim_end_matches(".htm");
    is_mid(token).then(|| token.to_string())
}

/// 搜索歌手,最多 `limit` 条(只取第一页)。
pub async fn search(session: &Session, keyword: &str, limit: usize) -> Result<Vec<SingerHit>> {
    let items = search_page(session, keyword, SearchType::Singer, 1, limit.max(1)).await?;
    let mut seen = std::collections::HashSet::new();
    Ok(items
        .iter()
        .filter_map(SingerHit::from_item)
        .filter(|s| seen.insert(s.id.clone()))
        .take(limit)
        .collect())
}

/// 一页专辑列表:(专辑, total)。
fn parse_album_page(node: &Value) -> (Vec<ArtistAlbum>, u64) {
    let albums = node
        .pointer("/data/albumList")
        .and_then(Value::as_array)
        .map(|a| a.iter().filter_map(ArtistAlbum::from_item).collect())
        .unwrap_or_default();
    let total = node
        .pointer("/data/total")
        .and_then(Value::as_u64)
        .unwrap_or(0);
    (albums, total)
}

/// 取歌手的全部专辑(自动翻页,按 mid 去重)。
pub async fn albums(
    session: &Session,
    singer_mid: &str,
    order: AlbumOrder,
) -> Result<Vec<ArtistAlbum>> {
    let mut all: Vec<ArtistAlbum> = Vec::new();
    let mut begin = 0;
    for _ in 0..MAX_PAGES {
        let qimei36 = session.qimei36().await;
        let node = session
            .call(
                &qimei36,
                "11",
                ALBUMS_MODULE,
                ALBUMS_METHOD,
                json!({
                    "singerMid": singer_mid,
                    "singerID": 0,
                    "order": order.code(),
                    "begin": begin,
                    "num": ALBUMS_PAGE,
                    "songNumTag": 1,
                }),
            )
            .await
            // 实测不存在的 singermid 返回 code=104400
            .context("获取歌手专辑列表失败(歌手可能不存在)")?;
        let (page, total) = parse_album_page(&node);
        debug!(begin, got = page.len(), total, "歌手专辑页");
        if page.is_empty() {
            break;
        }
        begin += page.len() as u64;
        all.extend(page);
        if begin >= total {
            break;
        }
    }
    let mut seen = std::collections::HashSet::new();
    all.retain(|a| seen.insert(a.id.clone()));
    if all.is_empty() {
        bail!("歌手 {singer_mid} 没有专辑");
    }
    Ok(all)
}

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

    #[test]
    fn links() {
        let mid = Some("0025NhlN2yWrP4".to_string());
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/singer/0025NhlN2yWrP4"),
            mid
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/singer/0025NhlN2yWrP4/"),
            mid
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/yqq/singer/0025NhlN2yWrP4.html#stat=x"),
            mid
        );
        assert_eq!(
            parse_link(
                "https://i.y.qq.com/n2/m/share/details/singer.html?singermid=0025NhlN2yWrP4"
            ),
            mid
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT"),
            None
        );
        assert_eq!(
            parse_link("https://evil.com/n/ryqq/singer/0025NhlN2yWrP4"),
            None
        );
        assert_eq!(parse_link("周杰伦"), None);
        assert_eq!(
            parse_mid_arg(" 0025NhlN2yWrP4 "),
            Some("0025NhlN2yWrP4".into())
        );
        assert_eq!(parse_mid_arg("4558"), None);
    }

    #[test]
    fn parse_singer_hit() {
        // 实测字段(2026-10-09,搜索"周杰伦" search_type=1 的 body.singer[0],节选)
        let item = json!({"albumNum": 43, "singerID": 4558, "singerMID": "0025NhlN2yWrP4",
            "singerName": "周杰伦", "singerName_hilight": "<em>周杰伦</em>", "songNum": 1012});
        let h = SingerHit::from_item(&item).unwrap();
        assert_eq!(
            (h.id.as_str(), h.name.as_str(), h.album_num, h.song_num),
            ("0025NhlN2yWrP4", "周杰伦", 43, 1012)
        );
        assert!(SingerHit::from_item(&json!({"singerName": "无 mid"})).is_none());
    }

    #[test]
    fn parse_albums() {
        // 实测字段(2026-10-09,周杰伦 GetAlbumList songNumTag=1,节选)
        let node = json!({"data": {"singerMid": "0025NhlN2yWrP4", "total": 43, "albumList": [
            {"albumMid": "0024bjiL2aocxT", "albumName": "十一月的萧邦", "publishDate": "2005-11-01",
             "totalNum": 12, "albumType": "录音室专辑", "albumID": 8220, "singerName": "周杰伦"},
            {"albumMid": "004Bux530GytNj", "albumName": "恒星不忘 Forever Forever",
             "publishDate": "2025-12-05", "totalNum": 1, "albumType": "Single",
             "singerName": "周杰伦/言承旭/吴建豪/周渝民/五月天 阿信"},
            {"albumName": "缺 mid"}
        ]}});
        let (albums, total) = parse_album_page(&node);
        assert_eq!(total, 43);
        assert_eq!(albums.len(), 2);
        let a = &albums[0];
        assert_eq!(
            (a.name.as_str(), a.kind, a.song_num, a.year()),
            ("十一月的萧邦", AlbumKind::Studio, 12, "2005")
        );
        assert_eq!(albums[1].kind, AlbumKind::Single);
        assert_eq!(
            albums[1].singer,
            "周杰伦, 言承旭, 吴建豪, 周渝民, 五月天 阿信"
        );
        // 不存在的歌手:code=104400 由 call 报错;data 为空列表
        let (empty, t) = parse_album_page(&json!({"data": {"albumList": [], "total": 0}}));
        assert!(empty.is_empty() && t == 0);
    }

    #[test]
    fn kinds() {
        assert_eq!(AlbumKind::from_label("录音室专辑"), AlbumKind::Studio);
        assert_eq!(AlbumKind::from_label("EP"), AlbumKind::Ep);
        assert_eq!(AlbumKind::from_label("演唱会"), AlbumKind::Live);
        assert_eq!(AlbumKind::from_label("人声音频"), AlbumKind::Other);
        assert_eq!(AlbumKind::from_label(""), AlbumKind::Other);
    }
}

#[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 artist_real() {
        let s = session();
        let hits = search(&s, "周杰伦", 5).await.unwrap();
        let jay = crate::model::exact_match(&hits, "周杰伦").unwrap();
        assert_eq!(jay.id, "0025NhlN2yWrP4");
        let list = albums(&s, &jay.id, AlbumOrder::New).await.unwrap();
        assert!(list.len() >= 43);
        assert!(
            list.iter()
                .any(|a| a.id == "0024bjiL2aocxT" && a.song_num == 12)
        );
        // 多页(>80 张)
        let teresa = albums(&s, "000kfe022gdaAn", AlbumOrder::New).await.unwrap();
        assert!(teresa.len() > 160);
        assert!(albums(&s, "xxxxxxxxxxxxxx", AlbumOrder::New).await.is_err());
    }
}