Something went wrong. Try again.
experiments of a tiny cytube-like player with yt-dlp
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136//! 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<String>, 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<IngestedTrack, IngestError> { 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>, 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::<f64>().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()), }}