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.
//! 与音源无关的数据结构:歌曲、品质档位、解析结果。各音源模块负责把自己的响应转换成这些类型。

use serde::Serialize;

use crate::probe::ProbeResult;

/// 音源。
#[derive(
    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, serde::Deserialize, clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum SourceKind {
    /// QQ 音乐
    #[default]
    Qq,
    /// 网易云音乐(也可写作 163)
    #[value(alias = "163")]
    #[serde(alias = "163")]
    Netease,
}

impl SourceKind {
    pub const ALL: [SourceKind; 2] = [SourceKind::Qq, SourceKind::Netease];

    /// 表格里显示的简称。
    pub fn label(self) -> &'static str {
        match self {
            SourceKind::Qq => "QQ",
            SourceKind::Netease => "网易",
        }
    }
}

impl std::fmt::Display for SourceKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            SourceKind::Qq => "qq",
            SourceKind::Netease => "netease",
        })
    }
}

/// 专辑内的曲序信息(只在专辑下载中填充,写入标签用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Track {
    /// 在专辑曲目列表中的位置(从 1 开始)
    pub number: u32,
    pub total: u32,
    /// (碟号, 总碟数),只有多碟专辑才有
    pub disc: Option<(u32, u32)>,
}

/// 一首歌(仅保留下游需要的字段)。
#[derive(Debug, Clone, Default, Serialize)]
pub struct Song {
    pub source: SourceKind,
    /// 音源内的歌曲标识:QQ 为 songmid,网易为数字 id
    pub id: String,
    pub title: String,
    pub singers: Vec<String>,
    pub album: String,
    /// 音源内的专辑标识:QQ 为 albummid,网易为数字 id
    pub album_id: String,
    /// 时长(秒)
    pub interval: u64,
    /// 搜索接口给出的各品质文件大小(字节,0 表示无;仅作参考)
    pub size_flac: u64,
    pub size_320mp3: u64,
    pub size_hires: u64,
    pub cover_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub track: Option<Track>,
}

impl Song {
    pub fn singer_text(&self) -> String {
        self.singers.join(", ")
    }
}

/// 品质档位(`--max-quality`),按从低到高声明以便比较。
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Serialize,
    serde::Deserialize,
    clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum Level {
    /// 标准:≤192k 的 ogg/mp3/m4a
    Std,
    /// HQ:320k 级别(OGG 320/640、MP3 320)
    Hq,
    /// SQ 无损(16bit FLAC)
    Sq,
    /// Hi-Res(24bit FLAC)
    Hires,
    /// 全景声 / 杜比全景声
    Atmos,
    /// 臻品母带 / 超清母带
    Master,
}

impl std::fmt::Display for Level {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Level::Std => "std",
            Level::Hq => "hq",
            Level::Sq => "sq",
            Level::Hires => "hires",
            Level::Atmos => "atmos",
            Level::Master => "master",
        })
    }
}

/// 解析选项。
#[derive(Debug, Clone, Copy)]
pub struct ResolveOptions {
    /// 第三方接口拿不到无损就放弃
    pub lossless_only: bool,
    /// 不取高于此档位的音质
    pub max_level: Level,
}

/// 一首歌解析出的直链。
#[derive(Debug, Clone, Serialize)]
pub struct Resolved {
    /// 取到链接的接口,如 "vkeys"、"官方"
    pub via: &'static str,
    /// 品质说明,如 "SQ无损"
    pub quality: String,
    pub level: Level,
    #[serde(flatten)]
    pub probe: ProbeResult,
    /// 下载时要带的 Referer
    #[serde(skip)]
    pub referer: Option<&'static str>,
    /// 本音源拿不到、从其他音源补上的链接:链接所属的音源(歌曲信息仍是原音源的)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fallback: Option<SourceKind>,
}

/// 单个取链接口的检查结果(`uta doctor`)。
#[derive(Debug)]
pub struct ProviderCheck {
    pub via: &'static str,
    /// 配置文件里是否填了地址(官方接口恒为 true)
    pub configured: bool,
    pub result: Option<Resolved>,
    /// 失败原因的补充说明
    pub note: Option<String>,
    pub elapsed: std::time::Duration,
}

/// 计时执行一个取链接口;未配置时不执行。
pub async fn check_provider<F>(via: &'static str, configured: bool, fut: F) -> ProviderCheck
where
    F: std::future::Future<Output = Option<Resolved>>,
{
    let start = std::time::Instant::now();
    let result = if configured { fut.await } else { None };
    ProviderCheck {
        via,
        configured,
        result,
        note: None,
        elapsed: start.elapsed(),
    }
}

/// Python 版的低质过滤:`size*8 < 320000*duration` 视为低质(仅用于第三方接口)。
pub fn below_320k(size: u64, duration_s: u64) -> bool {
    (size as u128) * 8 < 320_000u128 * duration_s as u128
}

/// 专辑搜索结果。
#[derive(Debug, Clone, Serialize)]
pub struct AlbumHit {
    /// 音源内的标识:QQ 为 mid,网易为数字 id
    pub id: String,
    pub name: String,
    pub singer: String,
    pub song_num: u64,
    pub publish_date: String,
}

#[derive(Debug)]
pub struct Album {
    /// 音源内的标识:QQ 为 mid,网易为数字 id
    pub id: String,
    pub name: String,
    pub singers: Vec<String>,
    pub publish_date: String,
    pub songs: Vec<Song>,
}

impl Album {
    /// 下载子目录名:`歌手 - 专辑名`。
    pub fn dir_name(&self) -> String {
        if self.singers.is_empty() {
            self.name.clone()
        } else {
            format!("{} - {}", self.singers.join(", "), self.name)
        }
    }
}

/// 按曲目列表位置生成曲序;碟号只在多碟时给出(`index_cd` 从 0 开始)。
pub fn tracks_for(index_cds: &[u32]) -> Vec<Track> {
    let total = index_cds.len() as u32;
    let multi = index_cds.iter().any(|&c| c != index_cds[0]);
    let discs = index_cds.iter().max().map_or(1, |m| m + 1);
    index_cds
        .iter()
        .enumerate()
        .map(|(i, &cd)| Track {
            number: i as u32 + 1,
            total,
            disc: multi.then_some((cd + 1, discs)),
        })
        .collect()
}

/// 歌手搜索结果。
#[derive(Debug, Clone, Serialize)]
pub struct SingerHit {
    /// 音源内的标识:QQ 为 mid,网易为数字 id
    pub id: String,
    pub name: String,
    pub album_num: u64,
    pub song_num: u64,
}

/// 专辑类型。QQ `albumType` 实测:录音室专辑、EP、Single、演唱会、人声音频;
/// 网易 `type`/`subType` 实测:专辑/Single + 录音室版/现场版/伴奏版。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum AlbumKind {
    /// 录音室专辑
    Studio,
    Ep,
    Single,
    /// 演唱会 / 现场版
    Live,
    /// 其他(人声音频、伴奏版等)
    Other,
}

/// 歌手专辑列表中的一张专辑。
#[derive(Debug, Clone, Serialize)]
pub struct ArtistAlbum {
    /// 音源内的标识:QQ 为 mid,网易为数字 id
    pub id: String,
    pub name: String,
    pub singer: String,
    /// 接口原始类型文字
    pub album_type: String,
    pub kind: AlbumKind,
    pub publish_date: String,
    /// 曲目数
    pub song_num: u64,
}

/// 歌手专辑排序。
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum AlbumOrder {
    /// 按发行时间从新到旧
    New,
    /// 按热度
    Hot,
}

impl ArtistAlbum {
    pub fn year(&self) -> &str {
        self.publish_date.get(..4).unwrap_or(&self.publish_date)
    }
}

/// 从歌手搜索结果中自动选出歌手:名字与关键词完全一致(忽略大小写和首尾空白)的第一个。
pub fn exact_match<'a>(hits: &'a [SingerHit], keyword: &str) -> Option<&'a SingerHit> {
    let k = keyword.trim().to_lowercase();
    hits.iter().find(|h| h.name.trim().to_lowercase() == k)
}

/// 用 --mid / 链接指定歌手时没有歌手名:取专辑列表中出现最多的歌手署名(合作专辑署名不同)。
pub fn main_singer(albums: &[ArtistAlbum]) -> Option<String> {
    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
    for a in albums.iter().filter(|a| !a.singer.is_empty()) {
        *counts.entry(&a.singer).or_default() += 1;
    }
    // 次数相同按名字排序,保证结果稳定
    counts
        .into_iter()
        .max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(a.0)))
        .map(|(s, _)| s.to_string())
}

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

    #[test]
    fn exact_match_picks_same_name() {
        let hit = |id: &str, name: &str| SingerHit {
            id: id.into(),
            name: name.into(),
            album_num: 0,
            song_num: 0,
        };
        let hits = [hit("a", "Jay Chou Band"), hit("b", "jay chou")];
        assert_eq!(exact_match(&hits, " Jay Chou ").unwrap().id, "b");
        assert!(exact_match(&hits, "Jay").is_none());
    }

    #[test]
    fn main_singer_most_common() {
        let a = |singer: &str| ArtistAlbum {
            id: String::new(),
            name: String::new(),
            singer: singer.into(),
            album_type: String::new(),
            kind: AlbumKind::Studio,
            publish_date: String::new(),
            song_num: 0,
        };
        let collab = "周杰伦, 言承旭, 吴建豪, 周渝民, 五月天 阿信";
        // 两种署名各一次:按名字排序取第一个,结果稳定
        assert_eq!(
            main_singer(&[a("周杰伦"), a(collab)]).as_deref(),
            Some("周杰伦")
        );
        assert_eq!(
            main_singer(&[a("周杰伦"), a(collab), a(collab)]).as_deref(),
            Some(collab)
        );
        assert_eq!(main_singer(&[a("")]), None);
        assert_eq!(main_singer(&[]), None);
    }

    #[test]
    fn filter_320k() {
        // 夜曲 226s:SQ 无损 26.7MB 通过;128k mp3 3.6MB 不通过
        assert!(!below_320k(26_691_277, 226));
        assert!(below_320k(3_630_591, 226));
        // 320k mp3 正好在阈值附近:9075745*8 = 72.6M >= 72.32M
        assert!(!below_320k(9_075_745, 226));
        // 时长未知时不过滤
        assert!(!below_320k(1, 0));
    }

    #[test]
    fn level_order() {
        assert!(Level::Master > Level::Atmos && Level::Atmos > Level::Hires);
        assert!(Level::Hires > Level::Sq && Level::Sq > Level::Hq && Level::Hq > Level::Std);
    }

    #[test]
    fn source_kind_alias_163() {
        use clap::ValueEnum;
        assert_eq!(SourceKind::from_str("163", false), Ok(SourceKind::Netease));
        assert_eq!(
            SourceKind::from_str("netease", false),
            Ok(SourceKind::Netease)
        );
        assert_eq!(SourceKind::from_str("qq", false), Ok(SourceKind::Qq));
        let v: SourceKind = serde_json::from_value(serde_json::json!("163")).unwrap();
        assert_eq!(v, SourceKind::Netease);
    }

    #[test]
    fn source_kind_names() {
        assert_eq!(SourceKind::default(), SourceKind::Qq);
        assert_eq!(SourceKind::Netease.to_string(), "netease");
        assert_eq!(SourceKind::Qq.label(), "QQ");
        assert_eq!(
            serde_json::to_value(SourceKind::Netease).unwrap(),
            "netease"
        );
    }
}