//! Download a YouTube playlist as tagged M4A files. //! //! Build and run with: //! cargo run --release -- [--album NAME] [--artist NAME] //! [--year YYYY] [--image cover.jpg] use std::{ env, error::Error, fs, path::{Path, PathBuf}, process::Command, }; use yt_dlp::{client::deps::Libraries, Downloader}; struct Args { url: String, album: Option, artist: Option, year: Option, image: Option, } fn usage() -> &'static str { "Usage: pirate [--album NAME] [--artist NAME] [--year YYYY] [--image FILE]" } fn parse_args() -> Result> { let mut values = env::args().skip(1); let url = values.next().ok_or_else(|| usage().to_owned())?; let mut args = Args { url, album: None, artist: None, year: None, image: None }; while let Some(flag) = values.next() { let value = values .next() .ok_or_else(|| format!("{flag} needs a value\n{}", usage()))?; match flag.as_str() { "--album" => args.album = Some(value), "--artist" => args.artist = Some(value), "--year" => args.year = Some(value.parse()?), "--image" => args.image = Some(PathBuf::from(value)), _ => return Err(format!("unknown option: {flag}\n{}", usage()).into()), } } if let Some(image) = &args.image { if !image.is_file() { return Err(format!("cover image does not exist: {}", image.display()).into()); } } Ok(args) } fn safe_filename(name: &str) -> String { let name: String = name .chars() .map(|c| if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { '_' } else { c }) .collect(); let name = name.trim_matches(|c: char| c == '.' || c.is_whitespace()); if name.is_empty() { "untitled".into() } else { name.into() } } fn transcode( ffmpeg: &Path, source: &Path, destination: &Path, title: &str, args: &Args, ) -> Result<(), Box> { let mut command = Command::new(ffmpeg); command.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]); command.arg(source); if let Some(image) = &args.image { command.args(["-i"]); command.arg(image); command.args([ "-map", "0:a:0", "-map", "1:v:0", "-c:a", "aac", "-c:v", "copy", "-disposition:v", "attached_pic", ]); } else { command.args(["-map", "0:a:0", "-c:a", "aac"]); } command.args(["-metadata", &format!("title={title}")]); if let Some(album) = &args.album { command.args(["-metadata", &format!("album={album}")]); } if let Some(artist) = &args.artist { command.args(["-metadata", &format!("artist={artist}"), "-metadata", &format!("album_artist={artist}")]); } if let Some(year) = args.year { command.args(["-metadata", &format!("date={year}")]); } command.arg(destination); let status = command.status()?; if !status.success() { return Err(format!("ffmpeg failed while creating {}", destination.display()).into()); } Ok(()) } #[tokio::main] async fn main() -> Result<(), Box> { let args = parse_args()?; let libraries_dir = PathBuf::from(".pirate-libs"); let ffmpeg = libraries_dir.join("ffmpeg"); let libraries = Libraries::new(libraries_dir.join("yt-dlp"), ffmpeg.clone()); // The crate downloads yt-dlp and FFmpeg on first use when they are absent. let downloader = Downloader::with_new_binaries(libraries_dir, ".") .await? .build() .await?; let playlist = downloader.fetch_playlist_infos(&args.url).await?; for entry in playlist.available_entries() { let video = downloader .fetch_video_infos(format!("https://www.youtube.com/watch?v={}", entry.id)) .await?; let stem = format!("{} [{}]", safe_filename(&entry.title), entry.id); let temporary = format!(".{stem}.download"); let output = PathBuf::from(format!("{stem}.m4a")); println!("Downloading {}", entry.title); downloader.download_audio_stream(&video, &temporary).await?; let temporary_path = PathBuf::from(&temporary); let result = transcode(&ffmpeg, &temporary_path, &output, &entry.title, &args); let _ = fs::remove_file(&temporary_path); result?; } println!("Done."); Ok(()) }