From 50d9e0f4a943b43fff38de5ebb2e2c080278f38e Mon Sep 17 00:00:00 2001 From: Matrix Music Bot Dev Date: Thu, 2 Jul 2026 21:11:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(music):=20MusicSource=20trait=20+=20yt-dlp?= =?UTF-8?q?=20backend=20+=20=E5=85=B6=E4=BB=96=20backend=20stub=EF=BC=88M3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增模块: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 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:' --dump-json --skip-download 解析每行 JSON,过滤 30s < 长度 < 30min resolve: yt-dlp -f bestaudio -g 取首行非空作为直链,估算 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 端到端实现 --- src/main.rs | 1 + src/music/mod.rs | 205 ++++++++++++++++++++++++++++ src/music/netease.rs | 40 ++++++ src/music/qq.rs | 38 ++++++ src/music/selfhosted.rs | 41 ++++++ src/music/ytdlp.rs | 296 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 621 insertions(+) create mode 100644 src/music/mod.rs create mode 100644 src/music/netease.rs create mode 100644 src/music/qq.rs create mode 100644 src/music/selfhosted.rs create mode 100644 src/music/ytdlp.rs diff --git a/src/main.rs b/src/main.rs index c0d5d7e..336d2a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod commands; mod config; mod error; mod event_loop; +mod music; mod secret_store; use std::path::PathBuf; diff --git a/src/music/mod.rs b/src/music/mod.rs new file mode 100644 index 0000000..9ea2fba --- /dev/null +++ b/src/music/mod.rs @@ -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, + pub duration_ms: Option, + /// 原始 URL(如果有);resolve 阶段可优先复用 + pub webpage_url: Option, +} + +/// 解析后的歌曲(含可下载直链) +#[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, + pub format_hint: AudioFormat, + /// Content-Length 探测(若可探测),用于提前判断是否超过上传限制 + pub size_bytes_estimate: Option, +} + +/// 音乐源 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, AppError>; + + /// 把 SongCandidate 解析为可下载直链 + async fn resolve(&self, candidate: &SongCandidate) -> Result; +} + +/// 多 backend 搜索门面(合并 / 去重) +/// +/// v1 阶段:按 backend 顺序并行调用 search,合并结果,相同 (title, artist) 视为同一首去重。 +pub struct SearchFacade { + backends: Vec>, +} + +impl SearchFacade { + pub fn new(backends: Vec>) -> Self { + Self { backends } + } + + pub fn backends(&self) -> &[Box] { + &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, 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 = 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)); + } +} \ No newline at end of file diff --git a/src/music/netease.rs b/src/music/netease.rs new file mode 100644 index 0000000..d32feb1 --- /dev/null +++ b/src/music/netease.rs @@ -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, AppError> { + Err(AppError::MusicSource( + "NetEase backend 在 v1 阶段为 stub,未实现".to_string(), + )) + } + async fn resolve(&self, _candidate: &SongCandidate) -> Result { + Err(AppError::MusicSource( + "NetEase backend 在 v1 阶段为 stub,未实现".to_string(), + )) + } +} \ No newline at end of file diff --git a/src/music/qq.rs b/src/music/qq.rs new file mode 100644 index 0000000..329ef4e --- /dev/null +++ b/src/music/qq.rs @@ -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, AppError> { + Err(AppError::MusicSource( + "QQ music backend 在 v1 阶段为 stub,未实现".to_string(), + )) + } + async fn resolve(&self, _candidate: &SongCandidate) -> Result { + Err(AppError::MusicSource( + "QQ music backend 在 v1 阶段为 stub,未实现".to_string(), + )) + } +} \ No newline at end of file diff --git a/src/music/selfhosted.rs b/src/music/selfhosted.rs new file mode 100644 index 0000000..bb4ac0a --- /dev/null +++ b/src/music/selfhosted.rs @@ -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, AppError> { + Err(AppError::MusicSource( + "LocalFs backend 在 v1 阶段为 stub,未实现".to_string(), + )) + } + async fn resolve(&self, _candidate: &SongCandidate) -> Result { + Err(AppError::MusicSource( + "LocalFs backend 在 v1 阶段为 stub,未实现".to_string(), + )) + } +} \ No newline at end of file diff --git a/src/music/ytdlp.rs b/src/music/ytdlp.rs new file mode 100644 index 0000000..6e78769 --- /dev/null +++ b/src/music/ytdlp.rs @@ -0,0 +1,296 @@ +//! yt-dlp music backend. +//! +//! yt-dlp 是一个通用的视频/音频下载工具,支持 YouTube / Bilibili / SoundCloud / +//! 网易云音乐(间接)/ QQ 音乐(间接)等数百个站点。本 backend 通过 spawn 子进程 +//! 调 yt-dlp 实现搜索与解析。 +//! +//! 调用流程: +//! - `search(query, limit)`: +//! `yt-dlp "ytsearch:" --dump-json --skip-download --no-warnings` +//! 每行一个 JSON 对象,提取 title / uploader / duration / webpage_url / id。 +//! - `resolve(candidate)`: +//! `yt-dlp -f bestaudio -g ` 拿到直链。 +//! 再用 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, + uploader: Option, + channel: Option, + duration: Option, + webpage_url: Option, + url: Option, +} + +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 { + 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, AppError> { + // ytsearch: 是 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 { + 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()); + } +} \ No newline at end of file