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.
//! `uta doctor`:检查各音源的搜索、歌词和每个取链接口是否可用。
//!
//! 第三方接口都是别人的免费服务,随时可能失效或被风控;这里用固定的测试歌逐个请求,
//! 报告能拿到的音质与耗时,方便判断该换哪个接口。

use std::time::{Duration, Instant};

use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL_CONDENSED};

use crate::model::{ProviderCheck, Song, SourceKind};
use crate::source::Sources;
use crate::{netease, qq};

/// 测试用的歌:第三方用付费歌,官方用免费歌(免登录拿不到付费歌)。
struct Fixture {
    keyword: &'static str,
    paid: Song,
    free: Song,
}

fn song(source: SourceKind, id: &str, title: &str, interval: u64) -> Song {
    Song {
        source,
        id: id.into(),
        title: title.into(),
        interval,
        ..Song::default()
    }
}

fn fixture(kind: SourceKind) -> Fixture {
    match kind {
        SourceKind::Qq => Fixture {
            keyword: "十一月的萧邦",
            paid: song(kind, "001zMQr71F1Qo8", "夜曲", 226),
            free: song(kind, "002MicCm2pZIuc", "小星星", 0),
        },
        SourceKind::Netease => Fixture {
            keyword: "田馥甄",
            paid: song(kind, "1481929839", "无人知晓", 288),
            free: song(kind, "1498983530", "刻在我心底的名字 (Live)", 314),
        },
    }
}

struct Row {
    item: String,
    /// None 表示未配置
    ok: Option<bool>,
    detail: String,
    elapsed: Duration,
}

fn fmt_mb(bytes: u64) -> String {
    format!("{:.1} MB", bytes as f64 / 1048576.0)
}

fn provider_row(c: ProviderCheck, paid: &Song, free: &Song) -> Row {
    let target = if c.via == "官方" { free } else { paid };
    let (ok, detail) = match (&c.result, c.configured) {
        (_, false) => (None, "未配置(见 config.example.toml)".to_string()),
        (Some(r), _) => (
            Some(true),
            format!("{} · {} · {}", r.quality, r.probe.ext, fmt_mb(r.probe.size)),
        ),
        (None, _) => (
            Some(false),
            c.note.clone().unwrap_or_else(|| "拿不到可用链接".into()),
        ),
    };
    Row {
        item: format!("取链 {}({})", c.via, target.title),
        ok,
        detail,
        elapsed: c.elapsed,
    }
}

/// 一个音源的总体状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Health {
    /// 搜索正常,至少一个第三方接口可用
    Ok,
    /// 只有官方接口可用:免费歌能下(通常 320k),付费歌拿不到
    OfficialOnly,
    /// 搜索失败或没有任何接口可用
    Down,
}

fn health(search_ok: bool, checks: &[ProviderCheck]) -> Health {
    let third = checks.iter().any(|c| c.via != "官方" && c.result.is_some());
    let official = checks.iter().any(|c| c.via == "官方" && c.result.is_some());
    match (search_ok, third, official) {
        (true, true, _) => Health::Ok,
        (true, false, true) => Health::OfficialOnly,
        _ => Health::Down,
    }
}

/// 检查一个音源,返回 (表格行, 总体状态)。
async fn check_source(sources: &Sources, kind: SourceKind) -> (Vec<Row>, Health) {
    let f = fixture(kind);
    let search = async {
        let t = Instant::now();
        let r = sources.search(kind, f.keyword, 10).await;
        (r, t.elapsed())
    };
    let lyric = async {
        let t = Instant::now();
        let r = sources.lyric(&f.paid).await;
        (r, t.elapsed())
    };
    let providers = async {
        match kind {
            SourceKind::Qq => qq::resolve::check(&sources.qq, &f.paid, &f.free).await,
            SourceKind::Netease => {
                netease::resolve::check(&sources.netease, &f.paid, &f.free).await
            }
        }
    };
    let ((search, st), (lyric, lt), checks) = tokio::join!(search, lyric, providers);

    let mut rows = Vec::new();
    let search_ok = matches!(&search, Ok(v) if !v.is_empty());
    rows.push(Row {
        item: format!("搜索「{}」", f.keyword),
        ok: Some(search_ok),
        detail: match &search {
            Ok(v) if v.is_empty() => "没有结果".into(),
            Ok(v) => format!("{} 条", v.len()),
            Err(e) => format!("{e:#}"),
        },
        elapsed: st,
    });
    rows.push(Row {
        item: format!("歌词({})", f.paid.title),
        ok: Some(matches!(&lyric, Ok(Some(_)))),
        detail: match &lyric {
            Ok(Some(l)) => format!("{} 行", l.lines().count()),
            Ok(None) => "没有歌词".into(),
            Err(e) => format!("{e:#}"),
        },
        elapsed: lt,
    });
    let h = health(search_ok, &checks);
    rows.extend(
        checks
            .into_iter()
            .map(|c| provider_row(c, &f.paid, &f.free)),
    );
    (rows, h)
}

/// 运行检查并打印表格;有音源完全不可用时返回 false。
pub async fn run(sources: &Sources, kinds: &[SourceKind]) -> bool {
    eprintln!(
        "正在检查 {} …",
        kinds
            .iter()
            .map(|k| k.label())
            .collect::<Vec<_>>()
            .join("、")
    );
    let results = futures::future::join_all(kinds.iter().map(|&k| check_source(sources, k))).await;
    let mut table = Table::new();
    table
        .load_style(UTF8_FULL_CONDENSED)
        .set_content_arrangement(ContentArrangement::Dynamic)
        .set_header(["音源", "检查项", "结果", "详情", "耗时"]);
    let mut all_ok = true;
    let mut summary = Vec::new();
    for (&kind, (rows, h)) in kinds.iter().zip(results) {
        for r in rows {
            table.add_row([
                kind.label().to_string(),
                r.item,
                match r.ok {
                    Some(true) => "✔",
                    Some(false) => "✘",
                    None => "—",
                }
                .to_string(),
                r.detail,
                format!("{:.1}s", r.elapsed.as_secs_f64()),
            ]);
        }
        summary.push(format!(
            "{}:{}",
            kind.label(),
            match h {
                Health::Ok => "可用",
                Health::OfficialOnly => "仅官方接口可用(免费歌能下,付费歌拿不到)",
                Health::Down => "不可用",
            }
        ));
        all_ok &= h != Health::Down;
    }
    println!("{table}");
    eprintln!("{}", summary.join(","));
    all_ok
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Level, Resolved};
    use crate::probe::ProbeResult;

    fn pc(via: &'static str, ok: bool) -> ProviderCheck {
        ProviderCheck {
            via,
            configured: true,
            result: ok.then(|| Resolved {
                via,
                quality: String::new(),
                level: Level::Sq,
                probe: ProbeResult {
                    url: String::new(),
                    ext: "flac".into(),
                    size: 1,
                },
                referer: None,
                fallback: None,
            }),
            note: None,
            elapsed: Duration::ZERO,
        }
    }

    #[test]
    fn health_levels() {
        assert_eq!(
            health(true, &[pc("vkeys", true), pc("官方", false)]),
            Health::Ok
        );
        assert_eq!(
            health(true, &[pc("vkeys", false), pc("官方", true)]),
            Health::OfficialOnly
        );
        assert_eq!(
            health(true, &[pc("vkeys", false), pc("官方", false)]),
            Health::Down
        );
        assert_eq!(health(false, &[pc("vkeys", true)]), Health::Down);
    }

    #[test]
    fn provider_rows() {
        let f = fixture(SourceKind::Netease);
        let check = |via, configured, result| ProviderCheck {
            via,
            configured,
            result,
            note: None,
            elapsed: Duration::from_millis(1500),
        };
        let r = provider_row(check("chksz", false, None), &f.paid, &f.free);
        assert_eq!((r.ok, r.item.as_str()), (None, "取链 chksz(无人知晓)"));
        let ok = Resolved {
            via: "官方",
            quality: "极高 320k".into(),
            level: Level::Hq,
            probe: ProbeResult {
                url: String::new(),
                ext: "mp3".into(),
                size: 12_593_325,
            },
            referer: None,
            fallback: None,
        };
        let r = provider_row(check("官方", true, Some(ok)), &f.paid, &f.free);
        assert_eq!(r.ok, Some(true));
        assert_eq!(r.item, "取链 官方(刻在我心底的名字 (Live))");
        assert_eq!(r.detail, "极高 320k · mp3 · 12.0 MB");
        let mut failed = check("tmetu", true, None);
        failed.note = Some("上游需要验证(被风控)".into());
        assert_eq!(
            provider_row(failed, &f.paid, &f.free).detail,
            "上游需要验证(被风控)"
        );
    }
}