#![allow(clippy::module_inception)]
use {
crate::{
metadata::metadata,
spotify::{fetch_album, fetch_playlist, fetch_track},
youtube::{DownloadResult, download_ytdlp, search_yt},
},
indicatif::{MultiProgress, ProgressBar, ProgressStyle},
indicatif_log_bridge::LogWrapper,
log::{LevelFilter, error, info},
regex::Regex,
spotify_rs::model::track::Track,
std::{
collections::HashMap,
fs::{self, remove_dir_all},
io::Write,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
},
tokio::sync::Semaphore,
};
pub mod metadata;
pub mod spotify;
pub mod youtube;
pub struct DownloadOptions {
pub url: String,
pub client_id: String,
pub client_secret: String,
pub output_dir: String,
pub concurrent_downloads: usize,
pub no_dupes: bool,
pub bitrate: String,
pub format: String,
pub verbosity: String,
pub no_tag: bool,
}
fn sanitize_filename(name: &str) -> String {
let re = Regex::new(r#"[<>:"/\\|?*\x00-\x1F]"#).unwrap();
re.replace_all(name.trim(), "").to_string()
}
pub fn extract_id_from_url(url: &str) -> Option<String> {
let re = Regex::new(r"(track|album|playlist|artist)/([a-zA-Z0-9]+)").unwrap();
if let Some(captures) = re.captures(url) {
return captures.get(2).map(|id| id.as_str().to_string());
}
None
}
const SPOTIFY_PATTERNS: [&str; 3] = [
r"^https://open\.spotify\.com/(track|album|playlist|artist)/.+",
r"^spotify:(track|album|playlist|artist):.+",
r"^https://spotify\.link/.+",
];
enum SpotifyUrlType {
Track,
Album,
Playlist,
Artist,
}
fn is_valid_spotify_url(url: &str) -> Option<(SpotifyUrlType, String)> {
for pattern in SPOTIFY_PATTERNS.iter() {
let re = Regex::new(pattern).unwrap();
if re.is_match(url.trim()) {
let id = extract_id_from_url(url)?;
if url.contains("track") {
return Some((SpotifyUrlType::Track, id));
} else if url.contains("album") {
return Some((SpotifyUrlType::Album, id));
} else if url.contains("playlist") {
return Some((SpotifyUrlType::Playlist, id));
} else if url.contains("artist") {
error!("You wouldn't download an Artist!");
return Some((SpotifyUrlType::Artist, id));
}
}
}
None
}
pub async fn download_spotify(
options: DownloadOptions,
ytdlp_dir: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let multi = MultiProgress::new();
let start_time = Instant::now();
let no_bars = options.verbosity.clone() == "no-bars";
let mut logger = match options.verbosity.clone().as_str() {
"full" => {
let mut builder = env_logger::Builder::new();
builder
.format(|buf, record| writeln!(buf, "{}", record.args()))
.filter_level(LevelFilter::Trace);
builder
}
"info" | "no-bars" => {
let mut builder = env_logger::Builder::new();
builder
.format(|buf, record| writeln!(buf, "{}", record.args()))
.filter_level(LevelFilter::Off)
.filter_module("rustifydl", LevelFilter::Info);
builder
}
"debug" => {
let mut builder = env_logger::Builder::new();
builder.filter_level(LevelFilter::Debug);
builder
}
"none" => {
let mut builder = env_logger::Builder::new();
builder
.format(|buf, record| writeln!(buf, "{}", record.args()))
.filter_level(LevelFilter::Off);
builder
}
_ => {
let mut builder = env_logger::Builder::new();
builder
.format(|buf, record| writeln!(buf, "{}", record.args()))
.filter_level(LevelFilter::Info)
.filter_module("spotify_rs", LevelFilter::Warn)
.filter_module("rustypipe_downloader", LevelFilter::Warn);
builder
}
};
if !no_bars {
let logger = logger.build();
LogWrapper::new(multi.clone(), logger).try_init().unwrap();
} else {
logger.init();
}
let (url_type, id) = is_valid_spotify_url(&options.url).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid Spotify URL")
})?;
let tracks = match url_type {
SpotifyUrlType::Track => fetch_track(&id, &options).await?,
SpotifyUrlType::Album => fetch_album(&id, &options).await?,
SpotifyUrlType::Playlist => fetch_playlist(&id, &options).await?,
SpotifyUrlType::Artist => {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Artist URLs are not supported. Please provide a track, album, or playlist URL.",
)));
}
};
let final_mult = multi.clone();
download_and_tag_tracks(tracks, &options, multi, ytdlp_dir).await?;
if options.verbosity != "no-bars" {
let bar = final_mult.add(ProgressBar::new(100));
bar.set_style(ProgressStyle::with_template("{msg}")?);
bar.finish_with_message(format!("Took {}s", start_time.elapsed().as_secs()));
}
info!("Took {}s", start_time.elapsed().as_secs());
let temp_path = PathBuf::from(format!("{}/temp", options.output_dir));
if temp_path.exists() {
remove_dir_all(temp_path)?;
}
Ok(())
}
async fn download_and_tag_tracks(
tracks: HashMap<String, Track>,
options: &DownloadOptions,
multi: MultiProgress,
ytdlp_dir: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut handles = Vec::new();
let semaphore = Arc::new(Semaphore::new(options.concurrent_downloads));
let lenght = tracks.clone().len();
let options_cloned = Arc::new(DownloadOptions {
url: options.url.clone(),
client_id: options.client_id.to_string().clone(),
client_secret: options.client_secret.to_string().clone(),
output_dir: options.output_dir.to_string(),
concurrent_downloads: options.concurrent_downloads,
no_dupes: options.no_dupes,
bitrate: options.bitrate.clone(),
format: options.format.clone(),
verbosity: options.verbosity.clone(),
no_tag: options.no_tag,
});
let ytdlp_path = download_ytdlp(ytdlp_dir)?;
let multi = Arc::new(multi);
for (i, (name, track)) in tracks.iter().enumerate() {
let semaphore = semaphore.clone();
let name = sanitize_filename(name.as_str());
let track = track.clone();
let options_cloned = Arc::clone(&options_cloned);
let multi = Arc::clone(&multi);
if options.verbosity.clone() != "no-bars" {
let task_ytdlp_path = ytdlp_path.clone();
let handle = tokio::spawn(async move {
let bar = multi.add(ProgressBar::new_spinner());
bar.set_style(ProgressStyle::with_template("{spinner:.cyan} {msg}")?);
bar.enable_steady_tick(Duration::from_millis(100));
let _permit = semaphore.acquire().await.unwrap();
bar.set_message(format!("{}/{} Downloading: {}", i + 1, lenght, name));
if let DownloadResult::Completed =
search_yt(&name, options_cloned.as_ref(), task_ytdlp_path).await?
{
if !options_cloned.no_tag {
bar.set_message(format!("{}/{} Tagging: {}", i + 1, lenght, name));
metadata(&name, &track, options_cloned.as_ref()).await?;
}
} else {
bar.finish_with_message(format!("File already exists, skipping!: {name}"));
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
}
bar.finish_with_message(format!("Finished {name}!"));
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
});
handles.push(handle);
} else {
let task_ytdlp_path = ytdlp_path.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
info!("{}/{} Starting download: {}", i + 1, lenght, name);
if let DownloadResult::Completed =
search_yt(&name, &options_cloned, task_ytdlp_path).await?
{
metadata(&name, &track, &options_cloned).await?;
info!("{}/{} Tagging: {}", i + 1, lenght, name);
} else {
info!("File already exists, skipping: {name}");
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
}
info!("Finished {name}!");
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
});
handles.push(handle);
}
}
for handle in handles {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => error!("Task failed: {e}"),
Err(e) => error!("Join error: {e}"),
}
}
if PathBuf::from(format!("{}/temp", options.output_dir)).exists() {
fs::remove_dir_all(format!("{}/temp", options.output_dir))?;
};
info!("Finished!");
Ok(())
}