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.
//! 按音源分发:搜索、取直链、歌词、单曲、歌单、专辑、歌手。各音源的具体协议在各自模块(`qq/`、`netease/`)中。
//!
//! 用枚举分发而不是 trait object:音源数量少且固定,async fn 不需要 `async-trait`。

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

use crate::config::Config;
use crate::model::{
    Album, AlbumHit, AlbumOrder, ArtistAlbum, ResolveOptions, Resolved, SingerHit, Song, SourceKind,
};
use crate::{netease, qq};

/// 一次运行共享的全部音源上下文。
pub struct Sources {
    /// 接口请求用的客户端(连接 5s、总计 15s),封面下载也用它
    pub client: reqwest::Client,
    pub qq: qq::Session,
    pub netease: netease::Session,
}

/// 专辑标识。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AlbumKey {
    Qq(qq::album::AlbumRef),
    Netease(String),
}

impl AlbumKey {
    /// 从搜索结果 / 歌手专辑列表中的 id 构造。
    pub fn from_id(kind: SourceKind, id: &str) -> AlbumKey {
        match kind {
            SourceKind::Qq => AlbumKey::Qq(qq::album::AlbumRef::Mid(id.to_string())),
            SourceKind::Netease => AlbumKey::Netease(id.to_string()),
        }
    }
}

/// 补链用的搜索词与条数:先"歌名 歌手",有专辑名时再加"歌名 歌手 专辑"。
fn fallback_queries(song: &Song) -> Vec<(String, usize)> {
    let base = match song.singers.first() {
        Some(s) => format!("{} {s}", song.title),
        None => song.title.clone(),
    };
    let mut queries = vec![(base.clone(), 5)];
    if !song.album.is_empty() && song.album != song.title {
        queries.push((format!("{base} {}", song.album), 15));
    }
    queries
}

/// 链接属于哪个音源;不是已知音源的链接返回 None。
pub fn detect(input: &str) -> Option<SourceKind> {
    let input = input.trim();
    if !input.contains("://") {
        None
    } else if qq::is_qq_host(input) {
        Some(SourceKind::Qq)
    } else if netease::is_netease_host(input) {
        Some(SourceKind::Netease)
    } else {
        None
    }
}

/// 专辑链接或 id(QQ:albummid / 数字 albumID;网易:数字 id);不是则返回 None。
pub fn parse_album(kind: SourceKind, input: &str) -> Option<AlbumKey> {
    match kind {
        SourceKind::Qq => qq::album::parse_link(input)
            .or_else(|| qq::album::parse_mid_arg(input))
            .map(AlbumKey::Qq),
        SourceKind::Netease => netease::parse_link(input, "album")
            .or_else(|| netease::parse_id_arg(input))
            .map(AlbumKey::Netease),
    }
}

/// 歌手链接或 id(QQ:14 位 singermid;网易:数字 id);不是则返回 None。
pub fn parse_singer(kind: SourceKind, input: &str) -> Option<String> {
    match kind {
        SourceKind::Qq => {
            qq::artist::parse_link(input).or_else(|| qq::artist::parse_mid_arg(input))
        }
        SourceKind::Netease => {
            netease::parse_link(input, "artist").or_else(|| netease::parse_id_arg(input))
        }
    }
}

impl Sources {
    pub fn new(client: reqwest::Client, config: Config) -> Self {
        Self {
            netease: netease::Session::new(client.clone(), config.netease.clone()),
            qq: qq::Session::new(client.clone(), config),
            client,
        }
    }

    /// 搜索歌曲,最多 `limit` 条。
    pub async fn search(&self, kind: SourceKind, keyword: &str, limit: usize) -> Result<Vec<Song>> {
        match kind {
            SourceKind::Qq => qq::search::search(&self.qq, keyword, limit).await,
            SourceKind::Netease => netease::music::search(&self.netease, keyword, limit).await,
        }
    }

    /// 解析下载直链;拿不到返回 None。
    pub async fn resolve(&self, song: &Song, opts: ResolveOptions) -> Option<Resolved> {
        match song.source {
            SourceKind::Qq => qq::resolve::resolve(&self.qq, song, opts).await,
            SourceKind::Netease => netease::resolve::resolve(&self.netease, song, opts).await,
        }
    }

    /// 本音源拿不到链接时,到其他音源找同一首歌补链接。
    ///
    /// 先搜"歌名 歌手"(前 5 条);没有匹配且有专辑名时再搜"歌名 歌手 专辑"(前 15 条)——
    /// 实测单字歌名(如《田》)只搜歌名 + 歌手时被淹没,加上专辑名后排第 8。
    /// 用与合并搜索相同的判定(歌名、第一歌手一致,时长相差 ≤5s),避免补成别的版本;
    /// 每次搜索最多试前 2 个匹配。返回的链接带 `fallback` 标记,歌曲信息仍用原来的。
    pub async fn resolve_fallback(&self, song: &Song, opts: ResolveOptions) -> Option<Resolved> {
        let queries = fallback_queries(song);
        for kind in SourceKind::ALL.into_iter().filter(|&k| k != song.source) {
            for (query, limit) in &queries {
                let hits = match self.search(kind, query, *limit).await {
                    Ok(h) => h,
                    Err(e) => {
                        debug!(id = %song.id, %kind, query, "补链搜索失败: {e:#}");
                        break;
                    }
                };
                let matches: Vec<&Song> = hits
                    .iter()
                    .filter(|c| crate::merge::same_song(song, c))
                    .take(2)
                    .collect();
                debug!(id = %song.id, %kind, query, hits = hits.len(), matches = matches.len(), "补链搜索");
                if matches.is_empty() {
                    continue;
                }
                for cand in matches {
                    if let Some(mut r) = self.resolve(cand, opts).await {
                        debug!(id = %song.id, %kind, other = %cand.id, "已从其他音源补上链接");
                        r.fallback = Some(kind);
                        return Some(r);
                    }
                }
                // 找到了同一首但对方也拿不到链接,不必再换搜索词
                break;
            }
        }
        None
    }

    /// 取歌词(已清理的 LRC);没有歌词返回 `Ok(None)`。
    pub async fn lyric(&self, song: &Song) -> Result<Option<String>> {
        match song.source {
            SourceKind::Qq => qq::lyric::fetch(&self.client, &song.id).await,
            SourceKind::Netease => netease::music::lyric(&self.netease, &song.id).await,
        }
    }

    /// 单曲:id 或链接(含短链)。
    pub async fn song(&self, kind: SourceKind, input: &str) -> Result<Song> {
        match kind {
            SourceKind::Qq => {
                let r = qq::song::resolve_ref(&self.qq, input).await?;
                qq::search::song_detail(&self.qq, &r).await
            }
            SourceKind::Netease => {
                let id = netease::resolve_id(&self.netease, input, "song").await?;
                netease::music::song_detail(&self.netease, &id).await
            }
        }
    }

    /// 歌单:返回 (歌单 id, 歌单名, 曲目)。
    pub async fn playlist(
        &self,
        kind: SourceKind,
        input: &str,
    ) -> Result<(String, String, Vec<Song>)> {
        match kind {
            SourceKind::Qq => {
                let id = qq::playlist::resolve_id(&self.qq, input).await?;
                let p = qq::playlist::fetch(&self.qq, &id).await?;
                Ok((id, p.name, p.songs))
            }
            SourceKind::Netease => {
                let id = netease::resolve_id(&self.netease, input, "playlist").await?;
                let p = netease::music::playlist(&self.netease, &id).await?;
                Ok((id, p.name, p.songs))
            }
        }
    }

    pub async fn search_albums(
        &self,
        kind: SourceKind,
        keyword: &str,
        limit: usize,
    ) -> Result<Vec<AlbumHit>> {
        match kind {
            SourceKind::Qq => qq::album::search(&self.qq, keyword, limit).await,
            SourceKind::Netease => {
                netease::music::search_albums(&self.netease, keyword, limit).await
            }
        }
    }

    pub async fn album(&self, key: &AlbumKey) -> Result<Album> {
        match key {
            AlbumKey::Qq(r) => qq::album::fetch(&self.qq, r).await,
            AlbumKey::Netease(id) => netease::music::album(&self.netease, id).await,
        }
    }

    pub async fn search_singers(
        &self,
        kind: SourceKind,
        keyword: &str,
        limit: usize,
    ) -> Result<Vec<SingerHit>> {
        match kind {
            SourceKind::Qq => qq::artist::search(&self.qq, keyword, limit).await,
            SourceKind::Netease => {
                netease::music::search_singers(&self.netease, keyword, limit).await
            }
        }
    }

    pub async fn artist_albums(
        &self,
        kind: SourceKind,
        id: &str,
        order: AlbumOrder,
    ) -> Result<Vec<ArtistAlbum>> {
        match kind {
            SourceKind::Qq => qq::artist::albums(&self.qq, id, order).await,
            SourceKind::Netease => netease::music::artist_albums(&self.netease, id, order)
                .await
                .context("获取网易云歌手专辑失败"),
        }
    }
}

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

    #[test]
    fn fallback_query_list() {
        let song = |title: &str, singers: &[&str], album: &str| Song {
            title: title.into(),
            singers: singers.iter().map(|s| s.to_string()).collect(),
            album: album.into(),
            ..Song::default()
        };
        assert_eq!(
            fallback_queries(&song("田", &["田馥甄"], "无人知晓")),
            [
                ("田 田馥甄".to_string(), 5),
                ("田 田馥甄 无人知晓".to_string(), 15)
            ]
        );
        // 单曲专辑与歌名相同、没有专辑、没有歌手
        assert_eq!(
            fallback_queries(&song("一二三", &["田馥甄"], "一二三")).len(),
            1
        );
        assert_eq!(fallback_queries(&song("x", &["y"], "")).len(), 1);
        assert_eq!(fallback_queries(&song("x", &[], ""))[0].0, "x");
    }

    #[test]
    fn detect_and_parse() {
        assert_eq!(
            detect("https://y.qq.com/n/ryqq/songDetail/x"),
            Some(SourceKind::Qq)
        );
        assert_eq!(
            detect("https://music.163.com/#/song?id=1"),
            Some(SourceKind::Netease)
        );
        assert_eq!(detect("https://163cn.tv/x"), Some(SourceKind::Netease));
        assert_eq!(detect("1481929839"), None);
        assert_eq!(detect("https://example.com/"), None);
        assert_eq!(
            parse_album(
                SourceKind::Netease,
                "https://music.163.com/#/album?id=95902047"
            ),
            Some(AlbumKey::Netease("95902047".into()))
        );
        assert_eq!(
            parse_album(SourceKind::Netease, "95902047"),
            Some(AlbumKey::Netease("95902047".into()))
        );
        assert_eq!(parse_album(SourceKind::Netease, "无人知晓"), None);
        assert!(matches!(
            parse_album(SourceKind::Qq, "0024bjiL2aocxT"),
            Some(AlbumKey::Qq(_))
        ));
        assert_eq!(
            parse_singer(SourceKind::Netease, "9548").as_deref(),
            Some("9548")
        );
        assert_eq!(
            parse_singer(SourceKind::Qq, "0025NhlN2yWrP4").as_deref(),
            Some("0025NhlN2yWrP4")
        );
        assert_eq!(parse_singer(SourceKind::Qq, "9548"), None);
        assert_eq!(
            AlbumKey::from_id(SourceKind::Netease, "1"),
            AlbumKey::Netease("1".into())
        );
    }
}