use anyhow::anyhow;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::core::ytdlp;
use crate::models::media::{
DownloadOptions, DownloadResult, MediaInfo, MediaType, VideoQuality as MediaVideoQuality,
};
use crate::platforms::traits::PlatformDownloader;
pub struct YouTubeDownloader;
impl Default for YouTubeDownloader {
fn default() -> Self {
Self::new()
}
}
impl YouTubeDownloader {
pub fn new() -> Self {
Self
}
fn extract_video_id(url: &str) -> Option<String> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str()?.to_lowercase();
if host.contains("youtu.be") {
let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
return segments.first().map(|s| s.to_string());
}
if host.contains("youtube.com") && parsed.path().starts_with("/embed/") {
let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
return segments.last().map(|s| s.to_string());
}
if host.contains("youtube.com") || host.contains("youtube-nocookie.com") {
let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
if segments.first() == Some(&"shorts") {
return segments.get(1).map(|s| s.to_string());
}
return parsed
.query_pairs()
.find(|(k, _)| k == "v")
.map(|(_, v)| v.to_string());
}
None
}
pub fn is_playlist_url(url: &str) -> bool {
if let Ok(parsed) = url::Url::parse(url) {
if parsed.path().starts_with("/playlist") {
return true;
}
if parsed.query_pairs().any(|(k, _)| k == "list") {
return true;
}
}
false
}
pub async fn fetch_with_ytdlp(
url: &str,
ytdlp_path: &std::path::Path,
) -> anyhow::Result<MediaInfo> {
if Self::is_playlist_url(url) {
let (playlist_title, entries) = ytdlp::get_playlist_info(ytdlp_path, url, &[]).await?;
if entries.is_empty() {
return Err(anyhow!("Playlist empty or unavailable"));
}
let qualities: Vec<MediaVideoQuality> = entries
.into_iter()
.enumerate()
.map(|(i, entry)| MediaVideoQuality {
label: format!("{}. {}", i + 1, entry.title),
width: 0,
height: 0,
url: entry.url,
format: "ytdlp_playlist".to_string(),
filesize_bytes: None,
})
.collect();
return Ok(MediaInfo {
title: sanitize_filename::sanitize(&playlist_title),
author: playlist_title,
platform: "youtube".to_string(),
duration_seconds: None,
thumbnail_url: None,
available_qualities: qualities,
media_type: MediaType::Playlist,
file_size_bytes: None,
});
}
let _video_id = Self::extract_video_id(url)
.ok_or_else(|| anyhow!("Could not extract YouTube video ID"))?;
let json = ytdlp::get_video_info(ytdlp_path, url, &[]).await?;
Self::parse_video_info(&json)
}
pub fn parse_video_info(json: &serde_json::Value) -> anyhow::Result<MediaInfo> {
let video_id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let title = json
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let author = json
.get("uploader")
.or_else(|| json.get("channel"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let duration = json.get("duration").and_then(|v| v.as_f64());
let thumbnail = json
.get("thumbnail")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let is_live = json
.get("is_live")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_live {
return Err(anyhow!("Livestreams not supported"));
}
let mut quality_map: std::collections::BTreeMap<u32, MediaVideoQuality> =
std::collections::BTreeMap::new();
if let Some(formats) = json.get("formats").and_then(|v| v.as_array()) {
for f in formats {
let height = f.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let width = f.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let vcodec = f.get("vcodec").and_then(|v| v.as_str()).unwrap_or("none");
let acodec = f.get("acodec").and_then(|v| v.as_str()).unwrap_or("none");
if vcodec == "none" || height == 0 {
continue;
}
let has_audio = acodec != "none";
let filesize_bytes = f
.get("filesize")
.and_then(|v| v.as_u64())
.or_else(|| f.get("filesize_approx").and_then(|v| v.as_u64()));
let label = if has_audio {
format!("{}p", height)
} else {
format!("{}p (HD)", height)
};
let q = MediaVideoQuality {
label,
width,
height,
url: format!("https://www.youtube.com/watch?v={}", video_id),
format: "ytdlp".to_string(),
filesize_bytes,
};
if let Some(existing) = quality_map.get(&height) {
let existing_has_audio = !existing.label.contains("(HD)");
let replace = (!existing_has_audio && has_audio)
|| (filesize_bytes.is_some() && existing.filesize_bytes.is_none());
if replace {
quality_map.insert(height, q);
}
} else {
quality_map.insert(height, q);
}
}
}
let mut qualities: Vec<MediaVideoQuality> = quality_map.into_values().rev().collect();
if qualities.is_empty() {
qualities.push(MediaVideoQuality {
label: "best".to_string(),
width: 0,
height: 0,
url: format!("https://www.youtube.com/watch?v={}", video_id),
format: "ytdlp".to_string(),
filesize_bytes: None,
});
}
Ok(MediaInfo {
title,
author,
platform: "youtube".to_string(),
duration_seconds: duration,
thumbnail_url: thumbnail,
available_qualities: qualities,
media_type: MediaType::Video,
file_size_bytes: None,
})
}
}
#[async_trait]
impl PlatformDownloader for YouTubeDownloader {
fn name(&self) -> &str {
"youtube"
}
fn can_handle(&self, url: &str) -> bool {
if let Ok(parsed) = url::Url::parse(url) {
if let Some(host) = parsed.host_str() {
let host = host.to_lowercase();
return host == "youtube.com"
|| host.ends_with(".youtube.com")
|| host == "youtu.be"
|| host == "youtube-nocookie.com"
|| host.ends_with(".youtube-nocookie.com");
}
}
false
}
async fn get_media_info(&self, url: &str) -> anyhow::Result<MediaInfo> {
let ytdlp_path = ytdlp::ensure_ytdlp(None).await.map_err(|e| {
anyhow!(
"YouTube requer yt-dlp para funcionar. Falha ao obter yt-dlp: {}",
e
)
})?;
if Self::is_playlist_url(url) {
let (playlist_title, entries) = ytdlp::get_playlist_info(&ytdlp_path, url, &[]).await?;
if entries.is_empty() {
return Err(anyhow!("Playlist empty or unavailable"));
}
let qualities: Vec<MediaVideoQuality> = entries
.into_iter()
.enumerate()
.map(|(i, entry)| MediaVideoQuality {
label: format!("{}. {}", i + 1, entry.title),
width: 0,
height: 0,
url: entry.url,
format: "ytdlp_playlist".to_string(),
filesize_bytes: None,
})
.collect();
return Ok(MediaInfo {
title: sanitize_filename::sanitize(&playlist_title),
author: playlist_title,
platform: "youtube".to_string(),
duration_seconds: None,
thumbnail_url: None,
available_qualities: qualities,
media_type: MediaType::Playlist,
file_size_bytes: None,
});
}
let _video_id = Self::extract_video_id(url)
.ok_or_else(|| anyhow!("Could not extract YouTube video ID"))?;
let json = ytdlp::get_video_info(&ytdlp_path, url, &[]).await?;
Self::parse_video_info(&json)
}
async fn download(
&self,
info: &MediaInfo,
opts: &DownloadOptions,
progress: mpsc::Sender<f64>,
) -> anyhow::Result<DownloadResult> {
let _ = progress.send(0.0).await;
let ytdlp_path = if let Some(ref p) = opts.ytdlp_path {
p.clone()
} else {
ytdlp::ensure_ytdlp(None).await?
};
if info.media_type == MediaType::Playlist {
return self
.download_playlist(info, opts, progress, &ytdlp_path)
.await;
}
let first = info
.available_qualities
.first()
.ok_or_else(|| anyhow!("No quality available"))?;
let selected = if let Some(ref wanted) = opts.quality {
info.get_closest_quality(wanted).unwrap_or(first)
} else {
first
};
let quality_height = if selected.height > 0 {
Some(selected.height)
} else {
None
};
let video_url = &selected.url;
ytdlp::download_video(
&ytdlp_path,
video_url,
&opts.output_dir,
quality_height,
progress,
opts.download_mode.as_deref(),
opts.format_id.as_deref(),
opts.filename_template.as_deref(),
opts.referer.as_deref().or(Some("https://www.youtube.com/")),
opts.cancel_token.clone(),
None,
opts.concurrent_fragments,
opts.download_subtitles,
&[],
)
.await
}
}
impl YouTubeDownloader {
async fn download_playlist(
&self,
info: &MediaInfo,
opts: &DownloadOptions,
progress: mpsc::Sender<f64>,
ytdlp_path: &std::path::Path,
) -> anyhow::Result<DownloadResult> {
let playlist_dir = opts
.output_dir
.join(sanitize_filename::sanitize(&info.title));
tokio::fs::create_dir_all(&playlist_dir).await?;
let total = info.available_qualities.len();
let mut total_bytes = 0u64;
let mut last_path = playlist_dir.clone();
for (i, entry) in info.available_qualities.iter().enumerate() {
if opts.cancel_token.is_cancelled() {
anyhow::bail!("Download cancelado");
}
let (video_tx, mut video_rx) = mpsc::channel::<f64>(16);
let progress_tx = progress.clone();
let video_idx = i;
let video_total = total;
let forwarder = tokio::spawn(async move {
let mut max_pct = 0.0_f64;
while let Some(pct) = video_rx.recv().await {
max_pct = max_pct.max(pct);
let overall = (video_idx as f64 / video_total as f64) * 100.0
+ (max_pct / video_total as f64);
let _ = progress_tx.send(overall).await;
}
});
match ytdlp::download_video(
ytdlp_path,
&entry.url,
&playlist_dir,
None,
video_tx,
opts.download_mode.as_deref(),
None,
opts.filename_template.as_deref(),
opts.referer.as_deref().or(Some("https://www.youtube.com/")),
opts.cancel_token.clone(),
None,
opts.concurrent_fragments,
opts.download_subtitles,
&[],
)
.await
{
Ok(result) => {
total_bytes += result.file_size_bytes;
last_path = result.file_path;
}
Err(e) => {
tracing::warn!("Playlist video {} falhou: {}", i + 1, e);
}
}
let _ = forwarder.await;
}
let _ = progress.send(100.0).await;
Ok(DownloadResult {
file_path: last_path,
file_size_bytes: total_bytes,
duration_seconds: 0.0,
torrent_id: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_video_info_basic() {
let js = json!({
"id": "dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"uploader": "Rick Astley",
"duration": 212.5,
"thumbnail": "https://example.com/thumb.jpg",
"is_live": false,
"formats": [
{
"height": 1080,
"width": 1920,
"vcodec": "avc1",
"acodec": "mp4a"
},
{
"height": 720,
"width": 1280,
"vcodec": "avc1",
"acodec": "none"
}
]
});
let info = YouTubeDownloader::parse_video_info(&js).unwrap();
assert_eq!(info.title, "Never Gonna Give You Up");
assert_eq!(info.author, "Rick Astley");
assert_eq!(info.platform, "youtube");
assert_eq!(info.duration_seconds, Some(212.5));
assert_eq!(
info.thumbnail_url,
Some("https://example.com/thumb.jpg".to_string())
);
assert_eq!(info.media_type, MediaType::Video);
assert_eq!(info.available_qualities.len(), 2);
assert_eq!(info.available_qualities[0].height, 1080);
assert_eq!(info.available_qualities[0].label, "1080p"); assert_eq!(info.available_qualities[1].height, 720);
assert_eq!(info.available_qualities[1].label, "720p (HD)"); }
#[test]
fn test_parse_video_info_missing_fields() {
let js = json!({});
let info = YouTubeDownloader::parse_video_info(&js).unwrap();
assert_eq!(info.title, "unknown");
assert_eq!(info.author, "unknown");
assert_eq!(info.duration_seconds, None);
assert_eq!(info.thumbnail_url, None);
assert_eq!(info.available_qualities.len(), 1);
assert_eq!(info.available_qualities[0].label, "best");
assert_eq!(info.available_qualities[0].height, 0);
}
#[test]
fn test_parse_video_info_livestream() {
let js = json!({
"id": "live_id",
"is_live": true
});
let err = YouTubeDownloader::parse_video_info(&js).unwrap_err();
assert_eq!(err.to_string(), "Livestreams not supported");
}
#[test]
fn test_parse_video_info_fallback_author() {
let js = json!({
"id": "vid",
"channel": "Awesome Channel"
});
let info = YouTubeDownloader::parse_video_info(&js).unwrap();
assert_eq!(info.author, "Awesome Channel");
}
#[test]
fn test_parse_video_info_filter_formats() {
let js = json!({
"id": "vid",
"formats": [
{
"height": 1080,
"vcodec": "none", "acodec": "mp4a"
},
{
"height": 0, "vcodec": "avc1",
"acodec": "none"
},
{
"height": 720, "vcodec": "avc1",
"acodec": "none"
},
{
"height": 720, "vcodec": "vp9",
"acodec": "opus"
}
]
});
let info = YouTubeDownloader::parse_video_info(&js).unwrap();
assert_eq!(info.available_qualities.len(), 1);
assert_eq!(info.available_qualities[0].height, 720);
}
}