Ctrl K
ComunidadGuías…

src/music.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! 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)
}