//! Media download and metadata extraction via yt-dlp. //! //! Provides [`download`] to download media from any yt-dlp-supported URL //! to a file on disk, with extracted metadata. Replaces the old `source/` //! modules and `playback.rs` pipeline. use std::path::Path; use std::process::Stdio; use tokio::process::Command; /// Result of a successful download with resolved metadata. #[derive(Debug, Clone)] pub(crate) struct IngestedTrack { pub title: String, pub duration: f64, pub thumbnail: Option, pub source: String, } #[derive(Debug, thiserror::Error)] pub(crate) enum IngestError { #[error("yt-dlp error: {0}")] Ytdlp(String), #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("parse error: {0}")] Parse(String), } /// Download a media URL to disk and extract metadata. /// /// `dest` should be a file path without extension — the final file will be /// `{dest}.mp4` (yt-dlp merges to mp4 when possible). pub(crate) async fn download(url: &str, dest: &Path) -> Result { let parent = dest.parent().unwrap_or(Path::new(".")); tokio::fs::create_dir_all(parent).await?; // Extract metadata first (best-effort, before the long download). let meta = extract_metadata(url).await; // yt-dlp output template — %(ext)s is replaced with the actual extension. // We use --merge-output-format mp4 so the final file is always .mp4. let output_tpl = format!("{}.%(ext)s", dest.display()); let output = Command::new("yt-dlp") .args([ "-f", "bestvideo[height<=1080]+bestaudio/best[height<=1080]", "-o", &output_tpl, "--merge-output-format", "mp4", "--no-playlist", url, ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(IngestError::Ytdlp(stderr.trim().to_string())); } // Construct the expected path — yt-dlp honours --merge-output-format mp4. let final_path = dest.with_extension("mp4"); // yt-dlp may write to a temp file and rename; retry a few times. let mut retries = 0; loop { if final_path.exists() { break; } retries += 1; if retries > 20 { return Err(IngestError::Parse(format!( "yt-dlp completed but '{}' not found after 20 retries", final_path.display(), ))); } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } Ok(IngestedTrack { title: meta.0, duration: meta.1, thumbnail: meta.2, source: meta.3, }) } /// Extract metadata from a URL using yt-dlp `--print` (fast, no download). /// /// Returns (title, duration_seconds, thumbnail_url, extractor_name). /// Falls back to defaults on any error — this is best-effort metadata. async fn extract_metadata(url: &str) -> (String, f64, Option, String) { let output = Command::new("yt-dlp") .args([ "--no-playlist", "--print", "%(title)s", "--print", "%(duration)s", "--print", "%(thumbnail)s", "--print", "%(extractor)s", url, ]) .stdout(Stdio::piped()) .stderr(Stdio::null()) .output() .await; match output { Ok(out) if out.status.success() => { let out = String::from_utf8_lossy(&out.stdout); let lines: Vec<&str> = out.trim().lines().collect(); let title = lines.first().unwrap_or(&"Unknown").to_string(); let duration = lines .get(1) .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); let thumbnail = lines .get(2) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); let source = lines.get(3).unwrap_or(&"ytdlp").to_string(); (title, duration, thumbnail, source) } _ => ("Unknown".into(), 0.0, None, "ytdlp".into()), } }