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,
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,
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)
}
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,
"上游需要验证(被风控)"
);
}
}