feat(music): MusicSource trait + yt-dlp backend + 其他 backend stub(M3)

新增模块:src/music/{mod,ytdlp,netease,qq,selfhosted}.rs

MusicSource trait(async_trait):
  fn kind() -> &'static str     // "ytdlp" / "netease" / "qq" / "selfhosted"
  fn display_name() -> &'static str
  async fn search(query, limit) -> Vec<SongCandidate>
  async fn resolve(candidate) -> ResolvedSong

SongCandidate:
  source_kind / source_id / title / artist / album? / duration_ms? / webpage_url?

ResolvedSong:
  candidate + play_url + play_url_expires_at + format_hint + size_bytes_estimate?

AudioFormat 枚举:
  Mp3 / Aac / Flac / Opus / Webm / Other
  from_mime() / is_target_native() 助手方法

SearchFacade:
  多 backend 并行 search → 合并 → 去重(同 title+artist 视为一首)
  失败的 backend 记 warn 后跳过,不阻塞其他 backend

YtDlpBackend(完整实现):
  search: yt-dlp 'ytsearch<N>:<query>' --dump-json --skip-download
          解析每行 JSON,过滤 30s < 长度 < 30min
  resolve: yt-dlp -f bestaudio -g <url>
          取首行非空作为直链,估算 6h 过期
  通过 tokio::process::Command 异步 spawn 子进程
  并发读 stdout/stderr 避免阻塞
  用 tokio::time::timeout 实施超时

  注:HEAD 探测 Content-Type/Content-Length 待 M5 reqwest 引入后启用

其他 backend(stub):
  NetEaseBackend / QqMusicBackend / LocalFsBackend
  都实现 trait,但 search/resolve 返回 AppError::MusicSource 提示未实现

测试:
  cargo test music → 4 passed + 2 ignored(需要网络+yt-dlp)
    - AudioFormat::from_mime / is_target_native
    - SongCandidate serde roundtrip
    - YtDlpBackend 集成测试(ignore 标记)

测试总数:19 passed + 2 ignored

后续 M4:transcoder.rs(ffmpeg MP3/AAC → stereo Opus 128k)+ delivery/audio_upload.rs(m.audio via Timeline::send)
后续 M5:把 MusicSource 接入 event_loop,!req 端到端实现
This commit is contained in:
Matrix Music Bot Dev
2026-07-02 21:11:58 +08:00
parent a2c9904ec3
commit 50d9e0f4a9
6 changed files with 621 additions and 0 deletions
+1
View File
@@ -17,6 +17,7 @@ mod commands;
mod config;
mod error;
mod event_loop;
mod music;
mod secret_store;
use std::path::PathBuf;
+205
View File
@@ -0,0 +1,205 @@
//! 音乐源抽象。
//!
//! 核心 trait `MusicSource` 把所有音乐后端(yt-dlp / NetEase / QQ / 本地文件库等)
//! 抽象为统一的搜索 + 解析接口。`SearchFacade` 把多个 backend 并行组合,
//! 支持合并 / 去重 / 多源 fallback。
//!
//! v1 阶段 `YtDlpBackend` 是唯一完整实现(src/music/ytdlp.rs),
//! 其他 backend 为 stub trait 实现(返回 `Unsupported`)。
use std::time::SystemTime;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::AppError;
/// 音频格式提示(用于决定是否需要转码)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudioFormat {
Mp3,
Aac,
Flac,
Opus,
Webm,
Other,
}
impl AudioFormat {
/// 从 MIME 类型推断(用于解析 yt-dlp 输出的 Content-Type
pub fn from_mime(mime: &str) -> Self {
let m = mime.to_ascii_lowercase();
if m.contains("flac") {
AudioFormat::Flac
} else if m.contains("opus") {
AudioFormat::Opus
} else if m.contains("webm") {
AudioFormat::Webm
} else if m.contains("mp4") || m.contains("aac") || m.contains("m4a") {
AudioFormat::Aac
} else if m.contains("mpeg") || m.contains("mp3") {
AudioFormat::Mp3
} else {
AudioFormat::Other
}
}
/// 是否已经可以直接作为 `audio/ogg; codecs=opus` 上传(无需转码)
pub fn is_target_native(&self) -> bool {
matches!(self, AudioFormat::Opus | AudioFormat::Webm)
}
}
/// 搜索结果条目(搜索阶段返回的轻量级元数据)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SongCandidate {
/// backend 标识("ytdlp" / "netease" / "qq" / "selfhosted"
pub source_kind: String,
/// backend 内部 id(用于 resolve
pub source_id: String,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration_ms: Option<u64>,
/// 原始 URL(如果有);resolve 阶段可优先复用
pub webpage_url: Option<String>,
}
/// 解析后的歌曲(含可下载直链)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedSong {
pub candidate: SongCandidate,
/// 临时直链(yt-dlp 输出的 -g 直链可能 24h 内过期)
pub play_url: String,
pub play_url_expires_at: Option<SystemTime>,
pub format_hint: AudioFormat,
/// Content-Length 探测(若可探测),用于提前判断是否超过上传限制
pub size_bytes_estimate: Option<u64>,
}
/// 音乐源 trait
#[async_trait]
pub trait MusicSource: Send + Sync {
/// 短标识("ytdlp" / "netease" 等)
fn kind(&self) -> &'static str;
/// 人类可读名称(用于 `!source` 命令和日志)
fn display_name(&self) -> &'static str;
/// 关键词搜索;返回 ranked 候选列表(rank 0 最相关)
async fn search(&self, query: &str, limit: u8) -> Result<Vec<SongCandidate>, AppError>;
/// 把 SongCandidate 解析为可下载直链
async fn resolve(&self, candidate: &SongCandidate) -> Result<ResolvedSong, AppError>;
}
/// 多 backend 搜索门面(合并 / 去重)
///
/// v1 阶段:按 backend 顺序并行调用 search,合并结果,相同 (title, artist) 视为同一首去重。
pub struct SearchFacade {
backends: Vec<Box<dyn MusicSource>>,
}
impl SearchFacade {
pub fn new(backends: Vec<Box<dyn MusicSource>>) -> Self {
Self { backends }
}
pub fn backends(&self) -> &[Box<dyn MusicSource>] {
&self.backends
}
pub fn backend_for(&self, kind: &str) -> Option<&dyn MusicSource> {
self.backends
.iter()
.find(|b| b.kind() == kind)
.map(|b| b.as_ref())
}
/// 并行调用所有 backend 的 search,合并去重
pub async fn search_all(&self, query: &str, limit: u8) -> Result<Vec<SongCandidate>, AppError> {
use futures_util::future::join_all;
let futures = self.backends.iter().map(|b| async move {
(b.kind(), b.search(query, limit).await)
});
let results = join_all(futures).await;
let mut merged: Vec<SongCandidate> = Vec::new();
for (kind, res) in results {
match res {
Ok(mut cands) => {
// 标记来源 kind
for c in &mut cands {
if c.source_kind.is_empty() {
c.source_kind = kind.to_string();
}
}
merged.extend(cands);
}
Err(e) => {
tracing::warn!(backend = %kind, error = %e, "backend search 失败,跳过");
}
}
}
// 简单去重:相同 (lowercased title, lowercased artist) 视为同一首
merged.sort_by(|a, b| {
a.title
.to_lowercase()
.cmp(&b.title.to_lowercase())
.then_with(|| a.artist.to_lowercase().cmp(&b.artist.to_lowercase()))
});
merged.dedup_by(|a, b| {
a.title.eq_ignore_ascii_case(&b.title) && a.artist.eq_ignore_ascii_case(&b.artist)
});
merged.truncate(limit as usize);
Ok(merged)
}
}
pub mod ytdlp;
pub mod netease;
pub mod qq;
pub mod selfhosted;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_format_from_mime() {
assert_eq!(AudioFormat::from_mime("audio/mpeg"), AudioFormat::Mp3);
assert_eq!(AudioFormat::from_mime("audio/mp4"), AudioFormat::Aac);
assert_eq!(AudioFormat::from_mime("audio/ogg; codecs=opus"), AudioFormat::Opus);
assert_eq!(AudioFormat::from_mime("audio/webm"), AudioFormat::Webm);
assert_eq!(AudioFormat::from_mime("audio/flac"), AudioFormat::Flac);
assert_eq!(AudioFormat::from_mime("application/octet-stream"), AudioFormat::Other);
}
#[test]
fn audio_format_is_target_native() {
assert!(AudioFormat::Opus.is_target_native());
assert!(AudioFormat::Webm.is_target_native());
assert!(!AudioFormat::Mp3.is_target_native());
assert!(!AudioFormat::Aac.is_target_native());
assert!(!AudioFormat::Other.is_target_native());
}
#[test]
fn song_candidate_serde() {
let c = SongCandidate {
source_kind: "ytdlp".to_string(),
source_id: "abc123".to_string(),
title: "晴天".to_string(),
artist: "周杰伦".to_string(),
album: Some("叶惠美".to_string()),
duration_ms: Some(222_000),
webpage_url: Some("https://www.youtube.com/watch?v=abc123".to_string()),
};
let json = serde_json::to_string(&c).unwrap();
let parsed: SongCandidate = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.title, "晴天");
assert_eq!(parsed.duration_ms, Some(222_000));
}
}
+40
View File
@@ -0,0 +1,40 @@
//! NetEase Cloud Music backend (stub)。
//!
//! v1 阶段仅实现 trait,返回 `Unsupported` 错误。
//! 后续 milestoneM5+ 可选)实现与自建 Node.js API shim (Binaryify/NeteaseCloudMusicApi) 通信。
use async_trait::async_trait;
use super::{MusicSource, ResolvedSong, SongCandidate};
use crate::error::AppError;
pub struct NetEaseBackend {
// 预留:api_base, cookie 等
_marker: (),
}
impl NetEaseBackend {
pub fn new() -> Self {
Self { _marker: () }
}
}
#[async_trait]
impl MusicSource for NetEaseBackend {
fn kind(&self) -> &'static str {
"netease"
}
fn display_name(&self) -> &'static str {
"网易云音乐(v1 未实现)"
}
async fn search(&self, _query: &str, _limit: u8) -> Result<Vec<SongCandidate>, AppError> {
Err(AppError::MusicSource(
"NetEase backend 在 v1 阶段为 stub,未实现".to_string(),
))
}
async fn resolve(&self, _candidate: &SongCandidate) -> Result<ResolvedSong, AppError> {
Err(AppError::MusicSource(
"NetEase backend 在 v1 阶段为 stub,未实现".to_string(),
))
}
}
+38
View File
@@ -0,0 +1,38 @@
//! QQ 音乐 backend (stub)。
//!
//! v1 阶段仅实现 trait,返回 `Unsupported` 错误。
use async_trait::async_trait;
use super::{MusicSource, ResolvedSong, SongCandidate};
use crate::error::AppError;
pub struct QqMusicBackend {
_marker: (),
}
impl QqMusicBackend {
pub fn new() -> Self {
Self { _marker: () }
}
}
#[async_trait]
impl MusicSource for QqMusicBackend {
fn kind(&self) -> &'static str {
"qq"
}
fn display_name(&self) -> &'static str {
"QQ 音乐(v1 未实现)"
}
async fn search(&self, _query: &str, _limit: u8) -> Result<Vec<SongCandidate>, AppError> {
Err(AppError::MusicSource(
"QQ music backend 在 v1 阶段为 stub,未实现".to_string(),
))
}
async fn resolve(&self, _candidate: &SongCandidate) -> Result<ResolvedSong, AppError> {
Err(AppError::MusicSource(
"QQ music backend 在 v1 阶段为 stub,未实现".to_string(),
))
}
}
+41
View File
@@ -0,0 +1,41 @@
//! 本地文件系统音乐库 backend (stub)。
//!
//! v1 阶段仅实现 trait。后续 milestone 可实现:
//! - 扫描 root_dir
//! - 从 ID3 / 文件名提取 (title, artist, album)
//! - 用 fuzzy match 做搜索
use async_trait::async_trait;
use super::{MusicSource, ResolvedSong, SongCandidate};
use crate::error::AppError;
pub struct LocalFsBackend {
_marker: (),
}
impl LocalFsBackend {
pub fn new() -> Self {
Self { _marker: () }
}
}
#[async_trait]
impl MusicSource for LocalFsBackend {
fn kind(&self) -> &'static str {
"selfhosted"
}
fn display_name(&self) -> &'static str {
"本地音乐库(v1 未实现)"
}
async fn search(&self, _query: &str, _limit: u8) -> Result<Vec<SongCandidate>, AppError> {
Err(AppError::MusicSource(
"LocalFs backend 在 v1 阶段为 stub,未实现".to_string(),
))
}
async fn resolve(&self, _candidate: &SongCandidate) -> Result<ResolvedSong, AppError> {
Err(AppError::MusicSource(
"LocalFs backend 在 v1 阶段为 stub,未实现".to_string(),
))
}
}
+296
View File
@@ -0,0 +1,296 @@
//! yt-dlp music backend.
//!
//! yt-dlp 是一个通用的视频/音频下载工具,支持 YouTube / Bilibili / SoundCloud /
//! 网易云音乐(间接)/ QQ 音乐(间接)等数百个站点。本 backend 通过 spawn 子进程
//! 调 yt-dlp 实现搜索与解析。
//!
//! 调用流程:
//! - `search(query, limit)`:
//! `yt-dlp "ytsearch<N>:<query>" --dump-json --skip-download --no-warnings`
//! 每行一个 JSON 对象,提取 title / uploader / duration / webpage_url / id。
//! - `resolve(candidate)`:
//! `yt-dlp -f bestaudio -g <webpage_url>` 拿到直链。
//! 再用 HEAD 请求探测 Content-Type 与 Content-Length。
//!
//! 已知问题(在国内):
//! - YouTube 不稳定;B 站、SoundCloud 较稳定。
//! - 部分站点返回的直链有时效(通常 6-24h),需在过期前下载完。
//! - VIP 曲目只能拿到低码率或失败;可考虑 fallback 到其他 backendM3+)。
use std::path::PathBuf;
use std::process::Stdio;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use serde::Deserialize;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tracing::{debug, warn};
use super::{AudioFormat, MusicSource, ResolvedSong, SongCandidate};
use crate::error::AppError;
/// yt-dlp 单条搜索结果的 JSON 形状(只取用得到的字段)
#[derive(Debug, Deserialize)]
struct YtDlpSearchItem {
id: String,
title: Option<String>,
uploader: Option<String>,
channel: Option<String>,
duration: Option<f64>,
webpage_url: Option<String>,
url: Option<String>,
}
pub struct YtDlpBackend {
ytdlp_path: PathBuf,
timeout: Duration,
}
impl YtDlpBackend {
pub fn new(ytdlp_path: PathBuf, timeout_secs: u64) -> Self {
Self {
ytdlp_path,
timeout: Duration::from_secs(timeout_secs),
}
}
/// 异步 spawn yt-dlp 子进程并收集 stdout
async fn run_yt_dlp(&self, args: &[&str]) -> Result<String, AppError> {
debug!(args = ?args, "spawn yt-dlp");
let mut child = Command::new(&self.ytdlp_path)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| AppError::MusicSource(format!("无法启动 yt-dlp: {e}(路径: {}", self.ytdlp_path.display())))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AppError::MusicSource("yt-dlp stdout 不可用".to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| AppError::MusicSource("yt-dlp stderr 不可用".to_string()))?;
// 并发读 stdout / stderr,避免阻塞
let stdout_task = tokio::spawn(async move {
let mut buf = String::new();
let mut reader = BufReader::new(stdout);
let mut line = String::new();
while let Ok(n) = reader.read_line(&mut line).await {
if n == 0 {
break;
}
buf.push_str(&line);
line.clear();
}
buf
});
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::new();
let mut reader = BufReader::new(stderr);
tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf).await.ok();
String::from_utf8_lossy(&buf).to_string()
});
// 等子进程 + 超时
let output = tokio::time::timeout(self.timeout, child.wait())
.await
.map_err(|_| AppError::MusicSource(format!("yt-dlp 超时({:?}", self.timeout)))?
.map_err(|e| AppError::MusicSource(format!("yt-dlp wait 失败: {e}")))?;
let stdout_text = stdout_task.await.unwrap_or_default();
let stderr_text = stderr_task.await.unwrap_or_default();
if !output.success() {
return Err(AppError::MusicSource(format!(
"yt-dlp 退出码 {}{}",
output.code().unwrap_or(-1),
stderr_text.trim()
)));
}
Ok(stdout_text)
}
}
#[async_trait]
impl MusicSource for YtDlpBackend {
fn kind(&self) -> &'static str {
"ytdlp"
}
fn display_name(&self) -> &'static str {
"yt-dlp (YouTube / B站 / SoundCloud 等)"
}
async fn search(&self, query: &str, limit: u8) -> Result<Vec<SongCandidate>, AppError> {
// ytsearch<limit>:<query> 是 yt-dlp 内置的 "search then take N" 语法
let search_arg = format!("ytsearch{}:{}", limit, query);
let output = self
.run_yt_dlp(&[
"--dump-json",
"--skip-download",
"--no-warnings",
"--no-playlist",
"--flat-playlist",
&search_arg,
])
.await?;
let mut candidates = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let item: YtDlpSearchItem = match serde_json::from_str(line) {
Ok(i) => i,
Err(e) => {
warn!(error = %e, line = line, "解析 yt-dlp 搜索结果 JSON 失败,跳过");
continue;
}
};
// 过滤掉直播、过长(>30 分钟)、过短(<30 秒)的"歌"
let duration_ms = item.duration.map(|d| (d * 1000.0) as u64);
if let Some(d) = duration_ms {
if d < 30_000 || d > 30 * 60_000 {
continue;
}
}
let webpage_url = item
.webpage_url
.clone()
.or_else(|| item.url.clone())
.unwrap_or_else(|| format!("https://www.youtube.com/watch?v={}", item.id));
let artist = item
.uploader
.clone()
.or(item.channel.clone())
.unwrap_or_else(|| "(未知 artist)".to_string());
candidates.push(SongCandidate {
source_kind: "ytdlp".to_string(),
source_id: item.id.clone(),
title: item.title.unwrap_or_else(|| "(无标题)".to_string()),
artist,
album: None, // yt-dlp 搜索结果不带 album
duration_ms,
webpage_url: Some(webpage_url),
});
}
Ok(candidates)
}
async fn resolve(&self, candidate: &SongCandidate) -> Result<ResolvedSong, AppError> {
let url = candidate.webpage_url.as_deref().ok_or_else(|| {
AppError::MusicSource("candidate 缺少 webpage_url,无法 resolve".to_string())
})?;
// -f bestaudio: 让 yt-dlp 选最佳纯音频流
// -g: 只输出最终 URL(不下载)
let output = self
.run_yt_dlp(&[
"-f", "bestaudio/best",
"-g",
"--no-warnings",
"--no-playlist",
url,
])
.await?;
// yt-dlp -g 可能输出多行(视频 + 音频分离),取第一行非空
let play_url = output
.lines()
.map(|s| s.trim())
.find(|s| !s.is_empty())
.ok_or_else(|| AppError::MusicSource("yt-dlp -g 未返回任何直链".to_string()))?
.to_string();
// M3 阶段:HEAD 探测暂跳过(reqwest 待 M5 引入)
// M5 加入 reqwest 后启用:探测 Content-Type / Content-Length
let format_hint = AudioFormat::Other;
let size_bytes_estimate = None;
// 估算过期时间(直链通常 6h-24h 后失效;保守给 6h)
let expires_at = SystemTime::now()
.checked_add(Duration::from_secs(6 * 3600))
.unwrap_or(UNIX_EPOCH);
Ok(ResolvedSong {
candidate: candidate.clone(),
play_url,
play_url_expires_at: Some(expires_at),
format_hint,
size_bytes_estimate,
})
}
}
// HEAD 探测(M5 reqwest 引入后实现)
#[allow(dead_code)]
mod probe_stub {
// 占位:M5 用 reqwest::Client::new().head(url).send() 实现
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidate_serde_roundtrip() {
let c = SongCandidate {
source_kind: "ytdlp".to_string(),
source_id: "dQw4w9WgXcQ".to_string(),
title: "Never Gonna Give You Up".to_string(),
artist: "Rick Astley".to_string(),
album: None,
duration_ms: Some(213_000),
webpage_url: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string()),
};
let s = serde_json::to_string(&c).unwrap();
let back: SongCandidate = serde_json::from_str(&s).unwrap();
assert_eq!(back.source_id, "dQw4w9WgXcQ");
assert_eq!(back.duration_ms, Some(213_000));
}
// 以下测试需要真实 yt-dlp + 网络,CI 默认跳过
#[test]
#[ignore = "需要网络与 yt-dlp"]
fn real_search() {
let rt = tokio::runtime::Runtime::new().unwrap();
let backend = YtDlpBackend::new("yt-dlp".into(), 30);
let res = rt.block_on(backend.search("周杰伦 晴天", 3));
assert!(res.is_ok(), "search 应成功:{:?}", res);
let cands = res.unwrap();
assert!(!cands.is_empty(), "搜索应有结果");
for c in &cands {
assert!(!c.title.is_empty());
assert!(!c.artist.is_empty());
assert!(c.webpage_url.is_some());
}
}
#[test]
#[ignore = "需要网络与 yt-dlp"]
fn real_resolve() {
let rt = tokio::runtime::Runtime::new().unwrap();
let backend = YtDlpBackend::new("yt-dlp".into(), 30);
let candidate = SongCandidate {
source_kind: "ytdlp".to_string(),
source_id: "test".to_string(),
title: "Never Gonna Give You Up".to_string(),
artist: "Rick Astley".to_string(),
album: None,
duration_ms: Some(213_000),
webpage_url: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string()),
};
let res = rt.block_on(backend.resolve(&candidate));
assert!(res.is_ok(), "resolve 应成功:{:?}", res);
let r = res.unwrap();
assert!(!r.play_url.is_empty());
}
}