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.
//! 搜索:`music.search.SearchCgiService` / `DoSearchForQQMusicMobile`。

use std::time::Duration;

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

use super::Session;
use super::api::random_searchid;
use super::song::SongRef;
use crate::model::{Song, SourceKind};

const MODULE: &str = "music.search.SearchCgiService";
const METHOD: &str = "DoSearchForQQMusicMobile";
/// 每页条数(与 Python 版 search_size_per_page 默认值一致)。
pub const PAGE_SIZE: usize = 10;
/// 接口偶发返回 code=0 但 item_song 为空,需重试。
const EMPTY_RETRIES: usize = 3;

/// QQ 专辑封面(800×800);没有 albummid 时返回 None。
pub fn cover_url(album_mid: &str) -> Option<String> {
    (!album_mid.is_empty())
        .then(|| format!("https://y.gtimg.cn/music/photo_new/T002R800x800M000{album_mid}.jpg"))
}

/// 从 item_song 单项解析(专辑曲目 `songInfo`、歌曲详情 `track_info` 结构相同)。
pub fn song_from_item(item: &Value) -> Option<Song> {
    let mid = item.get("mid").and_then(Value::as_str)?.to_string();
    if mid.is_empty() {
        return None;
    }
    let s = |v: Option<&Value>| v.and_then(Value::as_str).map(clean_text);
    let title = s(item.get("title"))
        .filter(|t| !t.is_empty())
        .or_else(|| s(item.get("name")))
        .unwrap_or_default();
    let singers = item
        .get("singer")
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .filter_map(|x| s(x.get("name")))
                .filter(|n| !n.is_empty())
                .collect()
        })
        .unwrap_or_default();
    let album_v = item.get("album");
    // highlight=1 时 album.title 带 <em> 高亮标签,优先用 album.name
    let album = s(album_v.and_then(|a| a.get("name")))
        .filter(|t| !t.is_empty())
        .or_else(|| s(album_v.and_then(|a| a.get("title"))))
        .unwrap_or_default();
    let album_mid = s(album_v.and_then(|a| a.get("mid"))).unwrap_or_default();
    let file = item.get("file");
    let size = |k: &str| {
        file.and_then(|f| f.get(k))
            .and_then(Value::as_u64)
            .unwrap_or(0)
    };
    Some(Song {
        source: SourceKind::Qq,
        id: mid,
        title,
        singers,
        album,
        cover_url: cover_url(&album_mid),
        album_id: album_mid,
        interval: item.get("interval").and_then(Value::as_u64).unwrap_or(0),
        size_flac: size("size_flac"),
        size_320mp3: size("size_320mp3"),
        size_hires: size("size_hires"),
        track: None,
    })
}

/// 去掉 HTML 标签(如搜索高亮的 `<em>`)并反转义常见实体。
pub fn clean_text(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut in_tag = false;
    for c in s.chars() {
        match c {
            '<' => in_tag = true,
            '>' if in_tag => in_tag = false,
            _ if !in_tag => out.push(c),
            _ => {}
        }
    }
    out.replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
        .replace("&apos;", "'")
        .replace("&nbsp;", " ")
        .replace("&amp;", "&")
        .trim()
        .to_string()
}

/// 把要取的条数拆成 (page_num, num_per_page) 列表。
pub fn plan_pages(limit: usize, page_size: usize) -> Vec<(usize, usize)> {
    let page_size = page_size.max(1);
    (0..limit.div_ceil(page_size))
        .map(|i| (i + 1, page_size))
        .collect()
}

/// 搜索类型(qqutils.py `SearchType`)。
#[derive(Debug, Clone, Copy)]
pub enum SearchType {
    Song,
    Singer,
    Album,
}

impl SearchType {
    fn code(self) -> u32 {
        match self {
            SearchType::Song => 0,
            SearchType::Singer => 1,
            SearchType::Album => 2,
        }
    }

    fn items_key(self) -> &'static str {
        match self {
            SearchType::Song => "item_song",
            // 实测歌手结果不在 item_singer,而在 body.singer
            SearchType::Singer => "singer",
            SearchType::Album => "item_album",
        }
    }
}

fn parse_items(node: &Value, ty: SearchType) -> Vec<Value> {
    node.pointer(&format!("/data/body/{}", ty.items_key()))
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default()
}

/// 取一页原始结果;空结果时更换 QIMEI36 重试。
pub async fn search_page(
    session: &Session,
    keyword: &str,
    ty: SearchType,
    page_num: usize,
    page_size: usize,
) -> Result<Vec<Value>> {
    for attempt in 1..=EMPTY_RETRIES {
        let param = json!({
            "searchid": random_searchid(),
            "query": keyword,
            "search_type": ty.code(),
            "num_per_page": page_size,
            "page_num": page_num,
            "highlight": 1,
            "grp": 1,
        });
        let qimei36 = session.qimei36().await;
        let node = session
            .call(&qimei36, "11", MODULE, METHOD, param)
            .await
            .with_context(|| format!("搜索第 {page_num} 页失败"))?;
        let items = parse_items(&node, ty);
        if !items.is_empty() {
            debug!(page_num, count = items.len(), "搜索页完成");
            return Ok(items);
        }
        // 当前 QIMEI36 被限流时也是 code=0 + 空列表,与"真没结果"无法区分,只能换 QIMEI36 重试
        if attempt < EMPTY_RETRIES {
            debug!(page_num, attempt, "搜索返回空结果,更换 QIMEI36 后重试");
            session.refresh_qimei().await;
            tokio::time::sleep(Duration::from_millis(300 * attempt as u64)).await;
        }
    }
    warn!(page_num, "多次重试后搜索结果仍为空");
    Ok(Vec::new())
}

/// 搜索歌曲,返回最多 `limit` 条,按 mid 去重并保持接口顺序。
pub async fn search(session: &Session, keyword: &str, limit: usize) -> Result<Vec<Song>> {
    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::Song, page, size)),
    )
    .await?;
    let mut seen = std::collections::HashSet::new();
    Ok(results
        .iter()
        .flatten()
        .filter_map(song_from_item)
        .filter(|s| seen.insert(s.id.clone()))
        .take(limit)
        .collect())
}

/// 按 songmid 或 songid 查歌曲详情(`get` 子命令用);`track_info` 与 `item_song` 结构相同。
pub async fn song_detail(session: &Session, song: &SongRef) -> Result<Song> {
    let param = match song {
        SongRef::Mid(mid) => json!({ "song_mid": mid }),
        SongRef::Id(id) => json!({ "song_id": id }),
    };
    let qimei36 = session.qimei36().await;
    let node = session
        .call(
            &qimei36,
            "11",
            "music.pf_song_detail_svr",
            "get_song_detail_yqq",
            param,
        )
        .await
        // 实测:不存在的 mid 返回 code=404
        .with_context(|| format!("查询歌曲 {song} 失败(可能不存在)"))?;
    node.pointer("/data/track_info")
        .and_then(song_from_item)
        .with_context(|| format!("歌曲 {song} 不存在"))
}

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

    #[test]
    fn strips_highlight_tags() {
        assert_eq!(clean_text("<em>十一月的萧邦</em>"), "十一月的萧邦");
        assert_eq!(clean_text("A &amp; B"), "A & B");
        assert_eq!(clean_text("  夜曲 "), "夜曲");
        assert_eq!(clean_text("a<b"), "a");
    }

    #[test]
    fn pages() {
        assert_eq!(plan_pages(10, 10), vec![(1, 10)]);
        assert_eq!(plan_pages(20, 10), vec![(1, 10), (2, 10)]);
        assert_eq!(plan_pages(25, 10), vec![(1, 10), (2, 10), (3, 10)]);
        assert_eq!(plan_pages(3, 3), vec![(1, 3)]);
        assert!(plan_pages(0, 10).is_empty());
    }

    #[test]
    fn parse_item() {
        let item = json!({
            "mid": "001zMQr71F1Qo8", "title": "夜曲", "name": "夜曲", "interval": 226,
            "singer": [{"mid": "0025NhlN2yWrP4", "name": "周杰伦"}, {"name": "Lara梁心颐"}],
            "album": {"mid": "0024bjiL2aocxT", "name": "十一月的萧邦", "title": "<em>十一月的萧邦</em>"},
            "file": {"media_mid": "0024jrso28p8VA", "size_flac": 26691277, "size_320mp3": 9075745, "size_hires": 0}
        });
        let s = song_from_item(&item).unwrap();
        assert_eq!(
            (s.id.as_str(), s.source),
            ("001zMQr71F1Qo8", SourceKind::Qq)
        );
        assert_eq!(s.title, "夜曲");
        assert_eq!(s.singer_text(), "周杰伦, Lara梁心颐");
        assert_eq!(s.album, "十一月的萧邦");
        assert_eq!(s.interval, 226);
        assert_eq!(s.size_flac, 26691277);
        assert_eq!(
            s.cover_url.as_deref().unwrap(),
            "https://y.gtimg.cn/music/photo_new/T002R800x800M0000024bjiL2aocxT.jpg"
        );
    }

    #[test]
    fn parse_item_title_fallback_and_missing_mid() {
        let s = song_from_item(&json!({"mid": "x", "title": "", "name": "<em>晴天</em>"})).unwrap();
        assert_eq!(s.title, "晴天");
        assert!(s.cover_url.is_none());
        assert!(song_from_item(&json!({"title": "无 mid"})).is_none());
    }

    #[test]
    fn parse_body() {
        let node =
            json!({"data": {"body": {"item_song": [{"mid": "a"}, {"mid": ""}, {"mid": "b"}]}}});
        let songs: Vec<Song> = parse_items(&node, SearchType::Song)
            .iter()
            .filter_map(song_from_item)
            .collect();
        assert_eq!(
            songs.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
            ["a", "b"]
        );
        assert!(parse_items(&json!({}), SearchType::Song).is_empty());
        let albums = json!({"data": {"body": {"item_album": [{"albummid": "x"}]}}});
        assert_eq!(parse_items(&albums, SearchType::Album).len(), 1);
    }
}

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

    pub 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 search_real() {
        let songs = search(&session(), "十一月的萧邦", 15).await.unwrap();
        assert_eq!(songs.len(), 15);
        let yequ = songs.iter().find(|s| s.id == "001zMQr71F1Qo8").unwrap();
        assert_eq!(yequ.title, "夜曲");
        assert_eq!(yequ.album, "十一月的萧邦");
        assert!(yequ.singers.contains(&"周杰伦".to_string()));
    }
}