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.
//! 网易云音乐协议实现(移植自 musicdl `netease.py`;专辑、歌手为实测补充)。
//!
//! 搜索、详情、歌词、歌单、专辑都是明文接口;只有官方取链(`player/url/v1`)需要 eapi 加密。
//! 不使用登录 Cookie:官方接口免登录只给免费歌曲的 320k,无损靠第三方 tmetu。

pub mod crypto;
pub mod music;
pub mod resolve;

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

use crate::config::NeteaseConfig;
use crate::model::{Song, SourceKind};

/// 网易云音乐的域名(含分享短链 163cn.tv)。
const HOSTS: &[&str] = &["music.163.com", "163cn.tv"];

/// 一次运行共享的网易上下文。
pub struct Session {
    pub client: reqwest::Client,
    pub config: NeteaseConfig,
    /// 上游被风控的第三方接口(本次运行不再请求)
    broken: std::sync::Mutex<Vec<resolve::Provider>>,
}

impl Session {
    pub fn new(client: reqwest::Client, config: NeteaseConfig) -> Self {
        Self {
            client,
            config,
            broken: std::sync::Mutex::new(Vec::new()),
        }
    }

    pub fn is_broken(&self, p: resolve::Provider) -> bool {
        self.broken.lock().expect("熔断锁").contains(&p)
    }

    /// 熔断某个第三方接口;只在第一次时提示。
    pub fn mark_broken(&self, p: resolve::Provider) {
        let mut b = self.broken.lock().expect("熔断锁");
        if !b.contains(&p) {
            b.push(p);
            tracing::warn!(
                "网易云第三方接口 {} 的上游需要验证,本次运行不再使用",
                p.name()
            );
        }
    }

    /// POST 表单,返回 JSON;`code != 200` 视为失败。
    pub async fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<Value> {
        let v: Value = crate::net::send(
            self.client
                .post(url)
                .header("Referer", "https://music.163.com/")
                .form(form),
        )
        .await
        .with_context(|| format!("请求 {url} 失败"))?
        .error_for_status()?
        .json()
        .await
        .context("响应不是 JSON")?;
        check_code(url, v)
    }

    /// GET,返回 JSON;`code != 200` 视为失败。
    pub async fn get_json(&self, url: &str) -> Result<Value> {
        let v: Value = crate::net::send(
            self.client
                .get(url)
                .header("Referer", "https://music.163.com/"),
        )
        .await
        .with_context(|| format!("请求 {url} 失败"))?
        .error_for_status()?
        .json()
        .await
        .context("响应不是 JSON")?;
        check_code(url, v)
    }
}

fn check_code(url: &str, v: Value) -> Result<Value> {
    match v.get("code").and_then(Value::as_i64) {
        Some(200) => Ok(v),
        code => {
            debug!(url, ?code, "网易接口返回错误");
            bail!(
                "网易接口返回 code={}",
                code.map_or("无".into(), |c| c.to_string())
            )
        }
    }
}

/// 链接是否属于网易云音乐。
pub fn is_netease_host(url: &str) -> bool {
    crate::net::host_in(url, HOSTS)
}

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

/// 纯数字 id。
pub fn parse_id_arg(s: &str) -> Option<String> {
    let s = s.trim();
    is_id(s).then(|| s.to_string())
}

/// 从网易链接中取出某类资源的 id(`kind` 为 song / playlist / album / artist)。
///
/// 支持 `/#/song?id=1`、`/song?id=1`、`/m/song?id=1`、`/song/1/`;链接必须明确是该类资源。
pub fn parse_link(input: &str, kind: &str) -> Option<String> {
    let input = input.trim();
    if !input.contains("://") || !is_netease_host(input) {
        return None;
    }
    let rest = input.split_once("://")?.1;
    let rest = &rest[rest.find('/').unwrap_or(rest.len())..];
    // 单页应用的链接把真正的路径放在 # 后面
    let rest = match rest.split_once('#') {
        Some((_, frag)) if frag.starts_with('/') => frag,
        Some((before, _)) => before,
        None => rest,
    };
    let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
    let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
    let pos = segs.iter().position(|s| *s == kind)?;
    if let Some(id) = segs.get(pos + 1).filter(|s| is_id(s)) {
        return Some(id.to_string());
    }
    query
        .split('&')
        .filter_map(|p| p.split_once('='))
        .find(|(k, v)| *k == "id" && is_id(v))
        .map(|(_, v)| v.to_string())
}

/// 把输入解析成资源 id:纯数字、网易链接;短链(163cn.tv)先跟随跳转。
pub async fn resolve_id(session: &Session, input: &str, kind: &str) -> Result<String> {
    if let Some(id) = parse_id_arg(input).or_else(|| parse_link(input, kind)) {
        return Ok(id);
    }
    let input = input.trim();
    if !input.contains("://") || !is_netease_host(input) {
        bail!("无法识别的网易云 {kind}:{input}");
    }
    let resp = crate::net::send(session.client.get(input))
        .await
        .context("访问链接失败")?;
    let final_url = resp.url().to_string();
    debug!(final_url, "网易链接跳转");
    parse_link(&final_url, kind).with_context(|| format!("链接不是网易云 {kind}:{final_url}"))
}

/// 毫秒时间戳 → `YYYY-MM-DD`(北京时间);0 或缺失返回空串。
pub fn date_from_ms(ms: i64) -> String {
    if ms <= 0 {
        return String::new();
    }
    let days = (ms / 1000 + 8 * 3600).div_euclid(86_400);
    // Howard Hinnant 的 civil_from_days
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!("{y:04}-{m:02}-{d:02}")
}

/// 封面:统一 https,并限制为 800×800(原图可能有数 MB)。
pub fn cover_url(pic: &str) -> Option<String> {
    let pic = pic.trim();
    if pic.is_empty() {
        return None;
    }
    let pic = pic
        .strip_prefix("http://")
        .map_or(pic.to_string(), |r| format!("https://{r}"));
    Some(if pic.contains('?') {
        pic
    } else {
        format!("{pic}?param=800y800")
    })
}

fn names(v: Option<&Value>) -> Vec<String> {
    v.and_then(Value::as_array)
        .map(|a| {
            a.iter()
                .filter_map(|x| x.get("name").and_then(Value::as_str))
                .map(|n| n.trim().to_string())
                .filter(|n| !n.is_empty())
                .collect()
        })
        .unwrap_or_default()
}

/// 解析一首歌(搜索 `result.songs[]`、`song/detail` 的 `songs[]`、专辑 `songs[]` 结构相同)。
pub fn song_from_json(v: &Value) -> Option<Song> {
    let id = v.get("id").and_then(Value::as_u64)?;
    let al = v.get("al");
    let s = |x: Option<&Value>, k: &str| {
        x.and_then(|x| x.get(k))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .trim()
            .to_string()
    };
    let size = |k: &str| {
        v.pointer(&format!("/{k}/size"))
            .and_then(Value::as_u64)
            .unwrap_or(0)
    };
    Some(Song {
        source: SourceKind::Netease,
        id: id.to_string(),
        title: s(Some(v), "name"),
        singers: names(v.get("ar")),
        album: s(al, "name"),
        album_id: al
            .and_then(|a| a.get("id"))
            .and_then(Value::as_u64)
            .filter(|&i| i > 0)
            .map(|i| i.to_string())
            .unwrap_or_default(),
        interval: v.get("dt").and_then(Value::as_u64).unwrap_or(0) / 1000,
        size_flac: size("sq"),
        size_320mp3: size("h"),
        size_hires: size("hr"),
        cover_url: cover_url(&s(al, "picUrl")),
        track: None,
    })
}

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

    #[test]
    fn links() {
        let id = Some("1481929839".to_string());
        assert_eq!(
            parse_link("https://music.163.com/#/song?id=1481929839", "song"),
            id
        );
        assert_eq!(
            parse_link("https://music.163.com/song?id=1481929839&userid=9", "song"),
            id
        );
        assert_eq!(
            parse_link("https://y.music.163.com/m/song?id=1481929839", "song"),
            id
        );
        assert_eq!(
            parse_link("https://music.163.com/song/1481929839/?userid=9", "song"),
            id
        );
        assert_eq!(
            parse_link("https://music.163.com/#/playlist?id=3778678", "playlist"),
            Some("3778678".into())
        );
        assert_eq!(
            parse_link("https://music.163.com/#/album?id=95902047", "album"),
            Some("95902047".into())
        );
        assert_eq!(
            parse_link("https://music.163.com/#/artist?id=9548", "artist"),
            Some("9548".into())
        );
        // 类型不符
        assert_eq!(
            parse_link("https://music.163.com/#/playlist?id=3778678", "song"),
            None
        );
        // 短链需要跳转
        assert_eq!(parse_link("https://163cn.tv/AbCdEf", "song"), None);
        // 非网易域名
        assert_eq!(parse_link("https://evil.com/#/song?id=1", "song"), None);
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/songDetail/001zMQr71F1Qo8", "song"),
            None
        );
        assert!(is_netease_host("https://163cn.tv/x"));
        assert!(!is_netease_host("https://music.163.com.evil.com/"));
        assert_eq!(parse_id_arg(" 9548 "), Some("9548".into()));
        assert_eq!(parse_id_arg("001zMQr71F1Qo8"), None);
    }

    #[test]
    fn dates() {
        // 实测:无人知晓 publishTime 1600963200000 → 2020-09-25(北京时间)
        assert_eq!(date_from_ms(1_600_963_200_000), "2020-09-25");
        assert_eq!(date_from_ms(1_790_179_200_000), "2026-09-24");
        assert_eq!(date_from_ms(0), "");
        assert_eq!(date_from_ms(951_782_400_000), "2000-02-29");
    }

    #[test]
    fn covers() {
        assert_eq!(
            cover_url("http://p2.music.126.net/a==/1.jpg").as_deref(),
            Some("https://p2.music.126.net/a==/1.jpg?param=800y800")
        );
        assert_eq!(
            cover_url("https://x/1.jpg?param=1y1").as_deref(),
            Some("https://x/1.jpg?param=1y1")
        );
        assert_eq!(cover_url(" "), None);
    }

    #[test]
    fn parse_song() {
        // 实测字段(2026-10-09,cloudsearch "田馥甄" 中的《无人知晓》,节选)
        let v = json!({"id": 1481929839u64, "name": "无人知晓", "dt": 288733,
            "ar": [{"id": 9548, "name": "田馥甄"}],
            "al": {"id": 95902047, "name": "无人知晓", "picUrl": "http://p2.music.126.net/x==/109951165350166137.jpg"},
            "sq": {"br": 829000, "size": 29908374}, "h": {"br": 320000, "size": 11551725}, "hr": null});
        let s = song_from_json(&v).unwrap();
        assert_eq!(
            (s.source, s.id.as_str(), s.title.as_str()),
            (SourceKind::Netease, "1481929839", "无人知晓")
        );
        assert_eq!(s.singers, ["田馥甄"]);
        assert_eq!(
            (s.album.as_str(), s.album_id.as_str(), s.interval),
            ("无人知晓", "95902047", 288)
        );
        assert_eq!(
            (s.size_flac, s.size_320mp3, s.size_hires),
            (29908374, 11551725, 0)
        );
        assert!(
            s.cover_url
                .unwrap()
                .starts_with("https://p2.music.126.net/")
        );
        assert!(song_from_json(&json!({"name": "无 id"})).is_none());
    }
}