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.
//! `get` 的输入:songmid、数字 songid 或 QQ 音乐歌曲链接。
//!
//! 实测 `get_song_detail_yqq` 既接受 `song_mid` 也接受数字 `song_id`。

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

use super::{Session, is_qq_host};

/// 歌曲标识:songmid,或数字 songid。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SongRef {
    Mid(String),
    Id(u64),
}

impl std::fmt::Display for SongRef {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SongRef::Mid(m) => f.write_str(m),
            SongRef::Id(id) => write!(f, "{id}"),
        }
    }
}

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

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

fn ref_from_token(s: &str) -> Option<SongRef> {
    if is_id(s) {
        s.parse().ok().map(SongRef::Id)
    } else if is_mid(s) {
        Some(SongRef::Mid(s.to_string()))
    } else {
        None
    }
}

/// 从 QQ 音乐歌曲链接中取出歌曲标识;取不到返回 None(可能是需要跳转的短链)。
///
/// 支持:`/songDetail/{mid}`、`/song/{mid}.html`,以及 `?songmid=` / `?songid=` 等查询参数。
pub fn parse_link(input: &str) -> Option<SongRef> {
    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, ""));
    let pairs: Vec<(&str, &str)> = query.split('&').filter_map(|p| p.split_once('=')).collect();
    // mid 优先于数字 id(分享链接常同时带两者)
    for keys in [
        &["songmid", "songMid", "song_mid"][..],
        &["songid", "songId", "song_id"],
    ] {
        if let Some(r) = pairs
            .iter()
            .filter(|(k, _)| keys.contains(k))
            .find_map(|(_, v)| 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("songDetail") || *s == "song")?;
    let token = segs.get(pos + 1)?;
    ref_from_token(token.trim_end_matches(".html").trim_end_matches(".htm"))
}

/// 命令行参数:链接、数字 songid 或 songmid。
pub fn parse_arg(input: &str) -> Option<SongRef> {
    let input = input.trim();
    if input.contains("://") {
        parse_link(input)
    } else {
        ref_from_token(input)
    }
}

/// 把用户输入解析成歌曲标识;短链等取不到标识的 QQ 链接会先跟随跳转。
pub async fn resolve_ref(session: &Session, input: &str) -> Result<SongRef> {
    if let Some(r) = parse_arg(input) {
        return Ok(r);
    }
    let input = input.trim();
    if !input.contains("://") || !is_qq_host(input) {
        bail!("无法识别的歌曲:{input}(需要 songmid、songid 或 QQ 音乐歌曲链接)");
    }
    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).with_context(|| format!("无法从链接中找到歌曲:{final_url}"))
}

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

    fn mid(s: &str) -> Option<SongRef> {
        Some(SongRef::Mid(s.to_string()))
    }

    #[test]
    fn args() {
        assert_eq!(parse_arg("001zMQr71F1Qo8"), mid("001zMQr71F1Qo8"));
        assert_eq!(parse_arg(" 001zMQr71F1Qo8 "), mid("001zMQr71F1Qo8"));
        assert_eq!(parse_arg("718477"), Some(SongRef::Id(718477)));
        assert_eq!(parse_arg("夜曲"), None);
        assert_eq!(parse_arg("abc"), None);
        assert_eq!(parse_arg(""), None);
    }

    #[test]
    fn links() {
        let m = mid("001zMQr71F1Qo8");
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/songDetail/001zMQr71F1Qo8"),
            m
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq_v2/songDetail/001zMQr71F1Qo8/"),
            m
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/yqq/song/001zMQr71F1Qo8.html#stat=x"),
            m
        );
        assert_eq!(
            parse_link(
                "https://i.y.qq.com/v8/playsong.html?ADTAG=share&songid=718477&songmid=001zMQr71F1Qo8&type=0"
            ),
            m
        );
        assert_eq!(
            parse_link("https://i.y.qq.com/v8/playsong.html?songid=718477"),
            Some(SongRef::Id(718477))
        );
        assert_eq!(
            parse_arg("https://y.qq.com/n/ryqq/songDetail/001zMQr71F1Qo8"),
            m
        );
        // 短链:需要跳转
        assert_eq!(
            parse_link("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCdEf"),
            None
        );
        // 其他类型的 QQ 链接
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/playlist/7039749142"),
            None
        );
        assert_eq!(
            parse_link("https://y.qq.com/n/ryqq/albumDetail/0024bjiL2aocxT"),
            None
        );
        // 非 QQ 域名
        assert_eq!(
            parse_link("https://evil.com/n/ryqq/songDetail/001zMQr71F1Qo8"),
            None
        );
    }

    #[test]
    fn display() {
        assert_eq!(SongRef::Id(1).to_string(), "1");
        assert_eq!(SongRef::Mid("x".into()).to_string(), "x");
    }
}