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.
//! 歌单:`fcg_ucc_getcdinfo_byids_cp.fcg`(移植自 qq.py `parseplaylist`)。
//!
//! 实测歌曲是旧格式字段(songmid / songname / albummid / albumname / sizeflac / size320),
//! 与搜索的 item_song 不同;整张歌单一次返回,不分页。

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

use super::search::{clean_text, cover_url};
use super::{Session, is_qq_host};
use crate::model::{Song, SourceKind};

const ENDPOINT: &str = "https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg";
#[derive(Debug)]
pub struct Playlist {
    pub name: String,
    pub songs: Vec<Song>,
}

fn is_id(s: &str) -> bool {
    !s.is_empty() && s.len() <= 20 && s.bytes().all(|b| b.is_ascii_digit())
}

/// 从纯数字 ID 或歌单链接中取出歌单 ID;取不到返回 None(可能是需要跳转的短链)。
///
/// 支持:`?id=` / `?disstid=` 查询参数,以及路径最后一段(去掉 `.html`)。
pub fn parse_id(input: &str) -> Option<String> {
    let input = input.trim();
    if is_id(input) {
        return Some(input.to_string());
    }
    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('=')
            && (k == "id" || k == "disstid")
            && is_id(v)
        {
            return Some(v.to_string());
        }
    }
    let last = path.trim_end_matches('/').rsplit('/').next()?;
    let last = last.trim_end_matches(".html").trim_end_matches(".htm");
    is_id(last).then(|| last.to_string())
}

/// 歌单接口里的单首歌(旧格式字段)。
pub fn song_from_playlist_item(item: &Value) -> Option<Song> {
    let s = |k: &str| item.get(k).and_then(Value::as_str).map(clean_text);
    let mid = s("songmid").filter(|m| !m.is_empty())?;
    let size = |k: &str| item.get(k).and_then(Value::as_u64).unwrap_or(0);
    let album_mid = s("albummid").unwrap_or_default();
    Some(Song {
        source: SourceKind::Qq,
        id: mid,
        title: s("songname").unwrap_or_default(),
        singers: item
            .get("singer")
            .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(),
        album: s("albumname").unwrap_or_default(),
        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("sizeflac"),
        size_320mp3: size("size320"),
        size_hires: 0,
        track: None,
    })
}

/// 解析接口响应;`code != 0` 或没有 cdlist 视为歌单不存在/不可见。
pub fn parse_response(id: &str, v: &Value) -> Result<Playlist> {
    let code = v.get("code").and_then(Value::as_i64).unwrap_or(-1);
    let cd = v.pointer("/cdlist/0");
    let Some(cd) = cd.filter(|_| code == 0) else {
        bail!("歌单 {id} 不存在或不可见(code={code})");
    };
    let list = cd
        .get("songlist")
        .or_else(|| cd.get("list"))
        .and_then(Value::as_array)
        .map(Vec::as_slice)
        .unwrap_or_default();
    let mut seen = std::collections::HashSet::new();
    let songs: Vec<Song> = list
        .iter()
        .filter_map(song_from_playlist_item)
        .filter(|s| seen.insert(s.id.clone()))
        .collect();
    let name = cd
        .get("dissname")
        .and_then(Value::as_str)
        .map(clean_text)
        .filter(|n| !n.is_empty())
        .unwrap_or_else(|| format!("playlist-{id}"));
    Ok(Playlist { name, songs })
}

/// 把用户输入解析成歌单 ID;短链等取不到 ID 的 QQ 链接会先跟随跳转。
pub async fn resolve_id(session: &Session, input: &str) -> Result<String> {
    if let Some(id) = parse_id(input) {
        return Ok(id);
    }
    let input = input.trim();
    if !input.contains("://") || !is_qq_host(input) {
        bail!("无法识别的歌单:{input}(需要歌单 ID 或 QQ 音乐歌单链接)");
    }
    let resp = crate::net::send(session.client.get(input))
        .await
        .context("访问歌单链接失败")?;
    let final_url = resp.url().to_string();
    debug!(final_url, "歌单链接跳转");
    parse_id(&final_url).with_context(|| format!("无法从链接中找到歌单 ID:{final_url}"))
}

pub async fn fetch(session: &Session, id: &str) -> Result<Playlist> {
    let referer = format!("https://y.qq.com/n/ryqq/playlist/{id}");
    let v: Value = crate::net::send(
        session
            .client
            .get(ENDPOINT)
            .header("Referer", referer)
            .query(&[
                ("disstid", id),
                ("type", "1"),
                ("json", "1"),
                ("utf8", "1"),
                ("onlysong", "0"),
                ("format", "json"),
            ]),
    )
    .await
    .context("请求歌单失败")?
    .error_for_status()?
    .json()
    .await
    .context("歌单响应不是 JSON")?;
    parse_response(id, &v)
}

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

    #[test]
    fn ids_from_inputs() {
        let id = Some("7039749142".to_string());
        assert_eq!(parse_id("7039749142"), id);
        assert_eq!(parse_id(" 7039749142 "), id);
        assert_eq!(parse_id("https://y.qq.com/n/ryqq/playlist/7039749142"), id);
        assert_eq!(
            parse_id("https://y.qq.com/n/ryqq_v2/playlist/7039749142/"),
            id
        );
        assert_eq!(
            parse_id("https://y.qq.com/n/yqq/playlist/7039749142.html#stat=y_new"),
            id
        );
        assert_eq!(
            parse_id(
                "https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&id=7039749142&hosteuin=x"
            ),
            id
        );
        assert_eq!(parse_id("https://c.y.qq.com/x?disstid=7039749142"), id);
        // 短链:路径里没有数字 ID,需要跳转
        assert_eq!(
            parse_id("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCdEf"),
            None
        );
        // 非 QQ 域名
        assert_eq!(
            parse_id("https://music.163.com/playlist?id=7583298906"),
            None
        );
        assert_eq!(parse_id("https://evil.com/y.qq.com/playlist/1"), None);
        assert_eq!(parse_id("晴天"), None);
        assert_eq!(parse_id(""), None);
    }

    #[test]
    fn hosts() {
        assert!(is_qq_host("https://y.qq.com/n/ryqq/playlist/1"));
        assert!(is_qq_host("http://C6.Y.QQ.COM:443/u"));
        assert!(!is_qq_host("https://notqq.com/y.qq.com"));
        assert!(!is_qq_host("https://y.qq.com.evil.com/"));
    }

    #[test]
    fn parse_real_shape() {
        // 实测字段(2026-10-08,歌单 7039749142 第一首)
        let v = json!({"code": 0, "cdlist": [{
        "dissname": "百听不厌的<em>周杰伦</em>",
        "songlist": [
            {"songmid": "0039MnYb0qxYhV", "songname": "晴天", "interval": 269,
             "singer": [{"mid": "0025NhlN2yWrP4", "name": "周杰伦"}],
             "albummid": "000MkMni19ClKG", "albumname": "叶惠美",
             "sizeflac": 55397039, "size320": 10792943},
            {"songmid": "0039MnYb0qxYhV", "songname": "重复"},
            {"songname": "缺 mid"}
        ]}]});
        let p = parse_response("7039749142", &v).unwrap();
        assert_eq!(p.name, "百听不厌的周杰伦");
        assert_eq!(p.songs.len(), 1);
        let s = &p.songs[0];
        assert_eq!(
            (s.id.as_str(), s.title.as_str(), s.album.as_str()),
            ("0039MnYb0qxYhV", "晴天", "叶惠美")
        );
        assert_eq!(s.singers, ["周杰伦"]);
        assert_eq!(
            (s.interval, s.size_flac, s.size_320mp3),
            (269, 55397039, 10792943)
        );
        assert_eq!(
            s.cover_url.as_deref().unwrap(),
            "https://y.gtimg.cn/music/photo_new/T002R800x800M000000MkMni19ClKG.jpg"
        );
    }

    #[test]
    fn missing_playlist() {
        // 实测不存在的 ID:{"code":-1,...,"cdnum":0,"cdlist":[]}
        let v = json!({"code": -1, "cdnum": 0, "cdlist": []});
        let err = parse_response("1", &v).unwrap_err();
        assert!(err.to_string().contains("不存在或不可见"));
        let p = parse_response("9", &json!({"code": 0, "cdlist": [{"songlist": []}]})).unwrap();
        assert_eq!(p.name, "playlist-9");
        assert!(p.songs.is_empty());
    }
}

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

    #[tokio::test]
    #[ignore = "需要网络"]
    async fn fetch_real_playlist() {
        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 p = fetch(&session, "3230056671").await.unwrap();
        assert_eq!(p.name, "周杰伦作品正序集");
        assert!(p.songs.len() > 100);
        assert!(
            p.songs
                .iter()
                .all(|s| !s.id.is_empty() && !s.title.is_empty())
        );
        assert!(fetch(&session, "1").await.is_err());
    }
}