diff options
Diffstat (limited to 'src/download.rs')
| -rw-r--r-- | src/download.rs | 584 |
1 files changed, 584 insertions, 0 deletions
diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..c77aaf5 --- /dev/null +++ b/src/download.rs @@ -0,0 +1,584 @@ +use crate::{ + error::{DownloadError, Result}, + types::*, +}; +use colored::Colorize; +use futures::StreamExt; +use reqwest::{Client, StatusCode}; +use std::{ + fs::File, + io::Write, + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::Mutex; + +const MAX_RETRIES: usize = 5; +const BACKOFF_DELAYS: [Duration; 5] = [ + Duration::from_secs(5), + Duration::from_secs(15), + Duration::from_secs(30), + Duration::from_secs(60), + Duration::from_secs(120), +]; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DirMode { + Playlist, + Album, + Artist, + Flat, +} + +#[derive(Debug, Clone)] +pub struct DownloadProgressUpdate { + pub bytes_downloaded: u64, + pub total_bytes: Option<u64>, + pub speed_bps: Option<f64>, +} + +pub type ProgressCallback = Arc<Mutex<Box<dyn FnMut(DownloadProgressUpdate) + Send>>>; + +#[derive(Clone)] +pub struct DownloadManager { + client: Client, + output_dir: PathBuf, + album: Option<String>, + playlist: Option<String>, + dir_mode: DirMode, +} + +impl DownloadManager { + pub fn new(output_dir: &Path) -> Self { + DownloadManager { + client: Client::builder() + .user_agent("SquidDownloader/0.1.0") + .build() + .unwrap(), + output_dir: output_dir.to_path_buf(), + album: None, + playlist: None, + dir_mode: DirMode::Playlist, + } + } + + pub fn with_album(mut self, album: Option<&str>) -> Self { + self.album = album.map(|s| s.to_string()); + self + } + + pub fn with_playlist(mut self, playlist: Option<&str>) -> Self { + self.playlist = playlist.map(|s| s.to_string()); + self + } + + pub fn with_dir_mode(mut self, mode: DirMode) -> Self { + self.dir_mode = mode; + self + } + + fn sanitize_filename(name: &str) -> String { + sanitize_filename::sanitize(name) + } + + pub fn generate_filename(&self, track: &TrackInfo, quality: &AudioQuality) -> PathBuf { + let artist = Self::sanitize_filename(&track.artist); + let title = Self::sanitize_filename(&track.title); + let extension = quality.file_extension(); + let filename = format!( + "{} - {}.{}", + artist, title, extension + ); + + match self.dir_mode { + DirMode::Playlist => { + if let Some(ref playlist) = self.playlist { + let playlist_dir = Self::sanitize_filename(playlist); + self.output_dir + .join(playlist_dir) + .join(filename) + } else if let Some(ref album) = self.album { + let album_dir = format!( + "{} - {}", + artist, + Self::sanitize_filename(album) + ); + self.output_dir + .join(album_dir) + .join(filename) + } else { + self.output_dir + .join(filename) + } + } + DirMode::Album => { + if let Some(ref album) = self.album { + let album_dir = format!( + "{} - {}", + artist, + Self::sanitize_filename(album) + ); + self.output_dir + .join(album_dir) + .join(filename) + } else { + self.output_dir + .join(filename) + } + } + DirMode::Artist => self + .output_dir + .join(&artist) + .join(filename), + DirMode::Flat => self + .output_dir + .join(filename), + } + } + + fn should_retry(status: StatusCode) -> bool { + status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() + } + + pub async fn download_file(&self, url: &str, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut last_error = None; + + for attempt in 0..MAX_RETRIES { + let response = self + .client + .get(url) + .send() + .await; + + match response { + Ok(resp) => { + let status = resp.status(); + + if status.is_success() { + let mut file = File::create(dest)?; + let bytes = resp + .bytes() + .await?; + file.write_all(&bytes)?; + return Ok(()); + } + + let error = DownloadError::DownloadFailed( + format!( + "HTTP status: {}", + status + ), + ); + + if !Self::should_retry(status) || attempt == MAX_RETRIES - 1 { + return Err(error); + } + + last_error = Some(error); + } + Err(e) => { + if attempt == MAX_RETRIES - 1 { + return Err(DownloadError::NetworkError(e)); + } + last_error = Some(DownloadError::NetworkError(e)); + } + } + + tokio::time::sleep(BACKOFF_DELAYS[attempt]).await; + } + + Err(last_error.unwrap_or(DownloadError::DownloadFailed("Max retries exceeded".to_string()))) + } + + pub async fn download_file_with_progress<F>(&self, url: &str, dest: &Path, mut progress_callback: F) -> Result<u64> + where + F: FnMut(DownloadProgressUpdate) + Send, + { + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut last_error = None; + + for attempt in 0..MAX_RETRIES { + let response = self + .client + .get(url) + .send() + .await; + + match response { + Ok(resp) => { + let status = resp.status(); + + if status.is_success() { + let total_size = resp.content_length(); + let mut file = File::create(dest)?; + let mut downloaded: u64 = 0; + let start_time = Instant::now(); + let mut last_update = Instant::now(); + + let mut stream = resp.bytes_stream(); + + while let Some(chunk_result) = stream + .next() + .await + { + let chunk = chunk_result.map_err(DownloadError::NetworkError)?; + file.write_all(&chunk)?; + downloaded += chunk.len() as u64; + + let now = Instant::now(); + let elapsed = now + .duration_since(last_update) + .as_millis(); + if elapsed >= 100 || downloaded == total_size.unwrap_or(0) { + let total_elapsed = start_time + .elapsed() + .as_secs_f64(); + let speed = if total_elapsed > 0.0 { + Some((downloaded as f64) / total_elapsed) + } else { + None + }; + + progress_callback( + DownloadProgressUpdate { + bytes_downloaded: downloaded, + total_bytes: total_size, + speed_bps: speed, + }, + ); + + last_update = now; + } + } + + return Ok(downloaded); + } + + let error = DownloadError::DownloadFailed( + format!( + "HTTP status: {}", + status + ), + ); + + if !Self::should_retry(status) || attempt == MAX_RETRIES - 1 { + return Err(error); + } + + last_error = Some(error); + } + Err(e) => { + if attempt == MAX_RETRIES - 1 { + return Err(DownloadError::NetworkError(e)); + } + last_error = Some(DownloadError::NetworkError(e)); + } + } + + tokio::time::sleep(BACKOFF_DELAYS[attempt]).await; + } + + Err(last_error.unwrap_or(DownloadError::DownloadFailed("Max retries exceeded".to_string()))) + } + + pub async fn download_segments(&self, segment_urls: &[String], dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut file = File::create(dest)?; + let total = segment_urls.len(); + + for (i, url) in segment_urls + .iter() + .enumerate() + { + let mut segment_error = None; + + for attempt in 0..MAX_RETRIES { + let response = self + .client + .get(url) + .send() + .await; + + match response { + Ok(resp) => { + let status = resp.status(); + + if status.is_success() { + let bytes = resp + .bytes() + .await?; + file.write_all(&bytes)?; + break; + } + + let error = DownloadError::SegmentDownloadFailed( + format!( + "Segment {}/{} failed: {}", + i + 1, + total, + status + ), + ); + + if !Self::should_retry(status) || attempt == MAX_RETRIES - 1 { + let _ = std::fs::remove_file(dest); + return Err(error); + } + + segment_error = Some(error); + } + Err(e) => { + let error = DownloadError::NetworkError(e); + if attempt == MAX_RETRIES - 1 { + let _ = std::fs::remove_file(dest); + return Err(error); + } + segment_error = Some( + DownloadError::SegmentDownloadFailed( + format!( + "Segment {}/{} network error", + i + 1, + total + ), + ), + ); + } + } + + tokio::time::sleep(BACKOFF_DELAYS[attempt]).await; + } + + if let Some(e) = segment_error { + let _ = std::fs::remove_file(dest); + return Err(e); + } + } + + Ok(()) + } + + pub async fn download_segments_with_progress<F>(&self, segment_urls: &[String], dest: &Path, mut progress_callback: F) -> Result<u64> + where + F: FnMut(DownloadProgressUpdate) + Send, + { + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut file = File::create(dest)?; + let total = segment_urls.len(); + let mut downloaded: u64 = 0; + let start_time = Instant::now(); + let mut last_update = Instant::now(); + + for (i, url) in segment_urls + .iter() + .enumerate() + { + let mut segment_error = None; + + for attempt in 0..MAX_RETRIES { + let response = self + .client + .get(url) + .send() + .await; + + match response { + Ok(resp) => { + let status = resp.status(); + + if status.is_success() { + let bytes = resp + .bytes() + .await?; + file.write_all(&bytes)?; + downloaded += bytes.len() as u64; + + let now = Instant::now(); + let elapsed = now + .duration_since(last_update) + .as_millis(); + if elapsed >= 100 || i == total - 1 { + let total_elapsed = start_time + .elapsed() + .as_secs_f64(); + let speed = if total_elapsed > 0.0 { + Some((downloaded as f64) / total_elapsed) + } else { + None + }; + + progress_callback( + DownloadProgressUpdate { + bytes_downloaded: downloaded, + total_bytes: None, + speed_bps: speed, + }, + ); + + last_update = now; + } + break; + } + + let error = DownloadError::SegmentDownloadFailed( + format!( + "Segment {}/{} failed: {}", + i + 1, + total, + status + ), + ); + + if !Self::should_retry(status) || attempt == MAX_RETRIES - 1 { + let _ = std::fs::remove_file(dest); + return Err(error); + } + + segment_error = Some(error); + } + Err(e) => { + let error = DownloadError::NetworkError(e); + if attempt == MAX_RETRIES - 1 { + let _ = std::fs::remove_file(dest); + return Err(error); + } + segment_error = Some( + DownloadError::SegmentDownloadFailed( + format!( + "Segment {}/{} network error", + i + 1, + total + ), + ), + ); + } + } + + tokio::time::sleep(BACKOFF_DELAYS[attempt]).await; + } + + if let Some(e) = segment_error { + let _ = std::fs::remove_file(dest); + return Err(e); + } + } + + Ok(downloaded) + } + + pub async fn download_from_manifest(&self, manifest: &DownloadManifest, track: &TrackInfo) -> Result<PathBuf> { + let dest = self.generate_filename( + track, + &manifest.quality, + ); + + if let Some(ref bit_depth) = manifest.bit_depth { + if let Some(ref sample_rate) = manifest.sample_rate { + let sample_str = format_sample_rate(*sample_rate); + println!( + " {} {}, {}-bit FLAC", + "Format:".cyan(), + sample_str.white(), + bit_depth + .to_string() + .white() + ); + } + } + + println!( + " {} {}", + "Output:".cyan(), + dest.display() + .to_string() + .green() + ); + + let result = if let Some(ref url) = manifest.url { + self.download_file( + url, &dest, + ) + .await + } else if let Some(ref segments) = manifest.segment_urls { + println!( + " {} {} segments", + "Downloading:".cyan(), + segments + .len() + .to_string() + .white() + ); + self.download_segments( + segments, &dest, + ) + .await + } else { + Err(DownloadError::NoDownloadUrl) + }; + + if result.is_err() { + let _ = std::fs::remove_file(&dest); + } + + result?; + Ok(dest) + } + + pub async fn download_from_manifest_with_progress<F>(&self, manifest: &DownloadManifest, track: &TrackInfo, mut progress_callback: F) -> Result<PathBuf> + where + F: FnMut(DownloadProgressUpdate) + Send, + { + let dest = self.generate_filename( + track, + &manifest.quality, + ); + + let result = if let Some(ref url) = manifest.url { + self.download_file_with_progress( + url, + &dest, + &mut progress_callback, + ) + .await?; + } else if let Some(ref segments) = manifest.segment_urls { + self.download_segments_with_progress( + segments, + &dest, + &mut progress_callback, + ) + .await?; + } else { + return Err(DownloadError::NoDownloadUrl); + }; + + Ok(dest) + } +} + +fn format_sample_rate(sample_rate: u32) -> String { + if sample_rate >= 1000 { + let khz = sample_rate as f64 / 1000.0; + format!( + "{:.1}kHz", + khz + ) + } else { + format!( + "{}Hz", + sample_rate + ) + } +} |
