src/music.rs
Rama main · réplica de lectura del repositorio original.
//! melodl 封装:多源并行搜索(总超时)+ 单曲下载
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use melodl::core::{registry, SearchCtx};
use melodl::{MusicClient, SongInfo};
use serde::Serialize;
use tokio::sync::mpsc;
use crate::config::Settings;
use crate::state::gen_id;
static CLIENT: OnceLock<MusicClient> = OnceLock::new();
static HTTP: OnceLock<reqwest::Client> = OnceLock::new();
/// 前端展示用的歌曲视图(字段名对齐前端契约)
#[derive(Debug, Clone, Serialize)]
pub struct SongView {
pub token: String,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub source: String,
pub ext: String,
pub size: Option<String>,
pub duration: Option<String>,
pub cover_url: Option<String>,
}
pub struct SearchOutcome {
pub items: Vec<(String, SongView, SongInfo)>,
pub sources_ok: Vec<String>,
pub sources_failed: Vec<String>,
pub elapsed: f64,
}
/// 懒加载单例,避免启动时初始化所有源
pub fn client(cfg: &Settings) -> &'static MusicClient {
CLIENT.get_or_init(|| {
let sources = resolve_sources(&cfg.music_sources);
MusicClient::builder()
.sources(sources)
.work_dir(cfg.music_dir.clone())
.search_size_per_source(cfg.search_size)
.build()
.expect("melodl client build failed")
})
}
/// MUSIC_SOURCES 兼容 musicdl 命名(MiguMusicClient -> migu),未知源跳过
fn resolve_sources(names: &[String]) -> Vec<String> {
let available = registry::available_sources();
let mut out = vec![];
for name in names {
let short = name
.strip_suffix("MusicClient")
.unwrap_or(name)
.to_lowercase();
if available.contains(&short.as_str()) {
out.push(short);
} else {
tracing::warn!("未知音源,跳过: {name}");
}
}
if out.is_empty() {
out = vec!["migu".into(), "netease".into(), "qq".into(), "kuwo".into()];
}
out
}
/// 搜索编排自实现(而非 client.search):总超时时保留已完成源的结果
fn http() -> &'static reqwest::Client {
HTTP.get_or_init(|| {
reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
.no_proxy()
.timeout(Duration::from_secs(30))
.build()
.expect("http client build failed")
})
}
pub async fn search(cfg: Arc<Settings>, keyword: String) -> SearchOutcome {
let start = Instant::now();
let names: Vec<String> = client(&cfg)
.source_names()
.into_iter()
.map(str::to_string)
.collect();
let (tx, mut rx) = mpsc::unbounded_channel::<(String, melodl::Result<Vec<SongInfo>>)>();
let mut pending: HashSet<String> = HashSet::new();
for name in names {
let Some(source) = registry::build(&name) else { continue };
let ctx = SearchCtx {
client: http().clone(),
search_size_per_source: cfg.search_size,
search_size_per_page: cfg.search_size.min(10),
max_retries: 3,
events: None,
};
let keyword = keyword.clone();
let tx = tx.clone();
pending.insert(name.clone());
tokio::spawn(async move {
let result = source.search(&keyword, &ctx).await;
let _ = tx.send((name, result));
});
}
drop(tx);
let deadline = start + Duration::from_secs_f64(cfg.search_timeout);
let mut songs: Vec<SongInfo> = vec![];
let mut sources_ok: Vec<String> = vec![];
let mut sources_failed: Vec<String> = vec![];
while !pending.is_empty() {
let remain = deadline.saturating_duration_since(Instant::now());
if remain.is_zero() {
break;
}
match tokio::time::timeout(remain, rx.recv()).await {
Ok(Some((name, Ok(s)))) => {
pending.remove(&name);
sources_ok.push(name);
songs.extend(s);
}
Ok(Some((name, Err(e)))) => {
pending.remove(&name);
tracing::warn!("search source failed {name}: {e}");
sources_failed.push(name);
}
Ok(None) => break,
Err(_) => break, // 总超时:pending 里的源放弃
}
}
if !pending.is_empty() {
let timed_out: Vec<String> = pending.into_iter().collect();
tracing::warn!("search source timeout: {}", timed_out.join(", "));
sources_failed.extend(timed_out);
}
let items = dedup(songs)
.into_iter()
.map(|song| {
let token = gen_id(32);
let view = SongView {
token: token.clone(),
title: song.song_name.clone(),
artist: song.singers.clone(),
album: song.album.clone(),
source: song.source.clone(),
ext: song
.ext
.clone()
.unwrap_or_default()
.trim_start_matches('.')
.to_string(),
size: song
.file_size_bytes
.filter(|b| *b > 0)
.map(|_| song.file_size_display()),
duration: song.duration_s.map(|_| song.duration_display()),
cover_url: song.cover_url.clone(),
};
(token, view, song)
})
.collect();
SearchOutcome {
items,
sources_ok,
sources_failed,
elapsed: (start.elapsed().as_secs_f64() * 10.0).round() / 10.0,
}
}
/// 按 source+root_source+identifier 去重,过滤无下载链接的结果
fn dedup(songs: Vec<SongInfo>) -> Vec<SongInfo> {
let mut seen = HashSet::new();
songs
.into_iter()
.filter(|s| {
s.with_valid_download_url()
&& seen.insert((
s.source.clone(),
s.root_source.clone().unwrap_or_default(),
s.identifier.clone(),
))
})
.collect()
}
/// 下载单曲,melodl 自动写入 ID3/封面/歌词,返回文件路径
pub async fn download(cfg: Arc<Settings>, song: SongInfo) -> Result<PathBuf, String> {
let name = song.song_name.clone();
let mut paths = client(&cfg).download(vec![song]).await;
let path = paths
.drain(..)
.next()
.ok_or_else(|| format!("下载失败: {name}"))?;
tracing::info!("downloaded: {}", path.display());
Ok(path)
}