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:
@@ -17,6 +17,7 @@ mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod event_loop;
|
||||
mod music;
|
||||
mod secret_store;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! NetEase Cloud Music backend (stub)。
|
||||
//!
|
||||
//! v1 阶段仅实现 trait,返回 `Unsupported` 错误。
|
||||
//! 后续 milestone(M5+ 可选)实现与自建 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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 到其他 backend(M3+)。
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user