diff options
| author | littleblack111 <littleblack11111@gmail.com> | 2026-02-25 01:22:26 +0800 |
|---|---|---|
| committer | littleblack111 <littleblack11111@gmail.com> | 2026-02-25 01:22:26 +0800 |
| commit | a2f9b0b1256c04cf2fb850ef082e7a27e9e06cde (patch) | |
| tree | bed1a473449aa532f6b689d044bf56afc3b69d5b /src | |
initmain
Diffstat (limited to 'src')
| -rw-r--r-- | src/adaptive.rs | 72 | ||||
| -rw-r--r-- | src/cli.rs | 149 | ||||
| -rw-r--r-- | src/csv.rs | 391 | ||||
| -rw-r--r-- | src/download.rs | 584 | ||||
| -rw-r--r-- | src/error.rs | 111 | ||||
| -rw-r--r-- | src/main.rs | 2330 | ||||
| -rw-r--r-- | src/progress.rs | 262 | ||||
| -rw-r--r-- | src/services/mod.rs | 21 | ||||
| -rw-r--r-- | src/services/tidal.rs | 524 | ||||
| -rw-r--r-- | src/types.rs | 224 | ||||
| -rw-r--r-- | src/ui.rs | 384 |
11 files changed, 5052 insertions, 0 deletions
diff --git a/src/adaptive.rs b/src/adaptive.rs new file mode 100644 index 0000000..ca9e0fc --- /dev/null +++ b/src/adaptive.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +pub struct AdaptiveConcurrency { + current_concurrent: usize, + min_concurrent: usize, + max_concurrent: usize, + failure_streak: usize, + success_count: usize, + fail_count: usize, + semaphore: Arc<Semaphore>, +} + +impl AdaptiveConcurrency { + pub fn new(max_concurrent: usize) -> Self { + let initial = num_cpus::get().min(max_concurrent); + AdaptiveConcurrency { + current_concurrent: initial, + min_concurrent: 1, + max_concurrent, + failure_streak: 0, + success_count: 0, + fail_count: 0, + semaphore: Arc::new(Semaphore::new(initial)), + } + } + + pub fn on_success(&mut self) { + self.success_count += 1; + self.failure_streak = 0; + + if self.success_count % 10 == 0 && self.current_concurrent < self.max_concurrent { + self.current_concurrent += 1; + self.semaphore + .add_permits(1); + } + } + + pub fn on_failure(&mut self) { + self.fail_count += 1; + self.failure_streak += 1; + + if self.failure_streak >= 3 && self.current_concurrent > self.min_concurrent { + let new_concurrent = (self.current_concurrent / 2).max(self.min_concurrent); + let reduction = self.current_concurrent - new_concurrent; + self.current_concurrent = new_concurrent; + + for _ in 0..reduction { + let _ = self + .semaphore + .try_acquire(); + } + } + } + + pub fn get_concurrent(&self) -> usize { + self.current_concurrent + } + + pub fn semaphore(&self) -> Arc<Semaphore> { + self.semaphore + .clone() + } +} + +pub async fn acquire_slot(semaphore: &Arc<Semaphore>) -> OwnedSemaphorePermit { + semaphore + .clone() + .acquire_owned() + .await + .unwrap() +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..5063187 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,149 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "squid-downloader")] +#[command(author = "Squid Downloader")] +#[command(version = "0.1.0")] +#[command(about = "Download high-quality music from multiple streaming services", long_about = None)] +#[command(color = clap::ColorChoice::Always)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + #[arg( + short, + long, + global = true, + help = "Output directory for downloads" + )] + pub output: Option<PathBuf>, + + #[arg(short, long, global = true, help = "Audio quality", value_parser = ["hires", "lossless", "high", "low", "mp3"])] + pub quality: Option<String>, + + #[arg(short, long, global = true, help = "Streaming service", value_parser = ["tidal", "amazon", "soundcloud", "khinsider"])] + pub service: Option<String>, + + #[arg( + long, + global = true, + help = "Suppress non-essential output" + )] + pub quiet: bool, + + #[arg( + short = 'j', + long, + global = true, + help = "Max concurrent downloads (default: CPU cores)" + )] + pub concurrent: Option<usize>, + + #[arg(short = 'd', long, global = true, help = "Directory organization mode", value_parser = ["playlist", "album", "artist", "flat"])] + pub dir_mode: Option<String>, +} + +#[derive(Subcommand)] +pub enum Commands { + #[command(about = "Search for tracks")] + Search { + #[arg(help = "Search query (song title)")] + query: String, + + #[arg( + short, + long, + help = "Artist name to improve accuracy" + )] + artist: Option<String>, + + #[arg( + short = 'n', + long, + default_value = "10", + help = "Number of results" + )] + limit: usize, + }, + + #[command(about = "Download a single track")] + Track { + #[arg(help = "Track ID or search query")] + input: String, + + #[arg( + short, + long, + help = "Artist name to improve accuracy" + )] + artist: Option<String>, + + #[arg( + short = 'f', + long, + help = "Download first result without prompting" + )] + first: bool, + }, + + #[command(about = "Download an entire album")] + Album { + #[arg(help = "Album ID or search query")] + input: String, + }, + + #[command(about = "Download tracks from an artist")] + Artist { + #[arg(help = "Artist ID or search query")] + input: String, + }, + + #[command(about = "Download a playlist")] + Playlist { + #[arg(help = "Playlist ID or URL")] + input: String, + }, + + #[command(about = "Import and download tracks from CSV file")] + Csv { + #[arg(help = "Path to CSV file")] + file: PathBuf, + + #[arg( + short = 't', + long, + default_value = "0.6", + help = "Minimum confidence threshold (0.0-1.0)" + )] + threshold: f64, + + #[arg( + short = 'y', + long, + help = "Skip confirmation prompt" + )] + yes: bool, + }, + + #[command(about = "Show information about a track")] + Info { + #[arg(help = "Track ID")] + id: u64, + }, + + #[command(about = "Get recommendations for a track")] + Recommend { + #[arg(help = "Track ID")] + id: u64, + }, + + #[command(about = "List available qualities and services")] + List, +} + +impl Cli { + pub fn parse() -> Self { + Parser::parse() + } +} diff --git a/src/csv.rs b/src/csv.rs new file mode 100644 index 0000000..e35d9fc --- /dev/null +++ b/src/csv.rs @@ -0,0 +1,391 @@ +use crate::{ + error::Result, + services::{tidal::TidalService, MusicService}, + types::TrackInfo, +}; +use colored::Colorize; +use serde::Deserialize; +use std::{fs::File, io::BufReader, path::Path}; + +#[derive(Debug, Clone, Deserialize)] +pub struct CsvTrack { + #[serde(rename = "Track name")] + pub track_name: String, + #[serde(rename = "Artist name")] + pub artist_name: String, + #[serde(rename = "Album")] + pub album: Option<String>, + #[serde(rename = "Playlist name")] + pub playlist_name: Option<String>, + #[serde(rename = "Type")] + pub track_type: Option<String>, + #[serde(rename = "ISRC")] + pub isrc: Option<String>, + #[serde(rename = "Spotify - id")] + pub spotify_id: Option<String>, +} + +impl CsvTrack { + pub fn parse_artists(&self) -> Vec<String> { + let artists: Vec<String> = self + .artist_name + .split( + &[ + ',', '&', '/', ';', + ][..], + ) + .map( + |s| { + s.trim() + .to_string() + }, + ) + .filter(|s| !s.is_empty()) + .collect(); + + if artists.is_empty() { + vec![ + self.artist_name + .clone(), + ] + } else { + artists + } + } +} + +#[derive(Debug, Clone)] +pub struct CsvMatchResult { + pub csv_track: CsvTrack, + pub best_match: Option<TrackInfo>, + pub confidence: f64, + pub search_attempts: Vec<String>, +} + +#[derive(Clone)] +pub struct CsvMatcher { + tidal: TidalService, +} + +impl CsvMatcher { + pub fn new() -> Self { + CsvMatcher { + tidal: TidalService::new(), + } + } + + pub async fn find_best_match(&self, csv_track: &CsvTrack) -> Result<CsvMatchResult> { + let mut search_attempts = Vec::new(); + let mut candidates: Vec<TrackInfo> = Vec::new(); + + let artists = csv_track.parse_artists(); + + for artist in &artists { + search_attempts.push( + format!( + "'{}' by '{}'", + csv_track.track_name, artist + ), + ); + + if let Ok(results) = self + .tidal + .search( + &csv_track.track_name, + Some(artist), + ) + .await + { + candidates.extend(results.tracks); + } + } + + search_attempts.push( + format!( + "'{}' (track only)", + csv_track.track_name + ), + ); + + if let Ok(results) = self + .tidal + .search( + &csv_track.track_name, + None, + ) + .await + { + candidates.extend(results.tracks); + } + + candidates.sort_by_key(|t| t.id); + candidates.dedup_by_key(|t| t.id); + + let best = Self::score_and_select_best( + &candidates, + csv_track, + ); + + Ok( + CsvMatchResult { + csv_track: csv_track.clone(), + best_match: best + .as_ref() + .map(|(track, _)| track.clone()), + confidence: best + .map(|(_, score)| score) + .unwrap_or(0.0), + search_attempts, + }, + ) + } + + pub fn score_and_select_best( + candidates: &[TrackInfo], + csv_track: &CsvTrack, + ) -> Option<( + TrackInfo, + f64, + )> { + if candidates.is_empty() { + return None; + } + + let mut best_score = 0.0_f64; + let mut best_match: Option<TrackInfo> = None; + + let csv_title_lower = csv_track + .track_name + .to_lowercase(); + let csv_artists = csv_track.parse_artists(); + let csv_artists_lower: Vec<String> = csv_artists + .iter() + .map(|a| a.to_lowercase()) + .collect(); + let csv_album_lower = csv_track + .album + .as_ref() + .map(|a| a.to_lowercase()); + + for candidate in candidates { + let mut score = 0.0_f64; + + let title_lower = candidate + .title + .to_lowercase(); + + let title_similarity: f64 = if title_lower == csv_title_lower { + 1.0 + } else if title_lower.contains(&csv_title_lower) || csv_title_lower.contains(&title_lower) { + 0.85 + } else { + strsim::jaro_winkler( + &title_lower, + &csv_title_lower, + ) + }; + score += title_similarity * 0.45; + + if !candidate + .artist + .is_empty() + && !csv_artists_lower.is_empty() + { + let artist_lower = candidate + .artist + .to_lowercase(); + + let artist_similarity: f64 = csv_artists_lower + .iter() + .map( + |csv_artist| { + let sim: f64 = if artist_lower.contains(csv_artist) || csv_artist.contains(&artist_lower) { + 1.0 + } else { + strsim::jaro_winkler( + &artist_lower, + csv_artist, + ) + }; + sim + }, + ) + .fold( + 0.0_f64, + |max, sim| max.max(sim), + ); + + score += artist_similarity * 0.35; + } + + if let Some(ref csv_album) = csv_album_lower { + if let Some(ref candidate_album) = candidate.album { + let album_lower = candidate_album.to_lowercase(); + + let album_similarity: f64 = if album_lower == *csv_album { + 1.0 + } else if album_lower.contains(csv_album) || csv_album.contains(&album_lower) { + 0.9 + } else { + strsim::jaro_winkler( + &album_lower, + csv_album, + ) + }; + + score += album_similarity * 0.20; + } + } else { + score += 0.10; + } + + if score > best_score { + best_score = score; + best_match = Some(candidate.clone()); + } + } + + best_match.map( + |m| { + ( + m, best_score, + ) + }, + ) + } +} + +pub fn parse_csv_file(path: &Path) -> Result<Vec<CsvTrack>> { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let mut csv_r = csv::Reader::from_reader(reader); + let mut tracks = Vec::new(); + + for result in csv_r.deserialize() { + let track: CsvTrack = result?; + tracks.push(track); + } + + Ok(tracks) +} + +pub fn print_csv_summary(results: &[CsvMatchResult]) { + println!(); + println!( + "{}", + "═" + .repeat(70) + .bright_blue() + ); + println!( + "{}", + "CSV Import Summary" + .cyan() + .bold() + ); + println!( + "{}", + "═" + .repeat(70) + .bright_blue() + ); + + let matched = results + .iter() + .filter( + |r| { + r.best_match + .is_some() + }, + ) + .count(); + let unmatched = results.len() - matched; + + println!( + "{} {} / {} {}", + "Matched:".green(), + matched + .to_string() + .green() + .bold(), + "Unmatched:".red(), + unmatched + .to_string() + .red() + ); + println!(); + + for result in results { + match &result.best_match { + Some(track) => { + let confidence_color = if result.confidence > 0.8 { + "✓".green() + } else if result.confidence > 0.6 { + "~".yellow() + } else { + "?".red() + }; + + println!( + " {} {} {} {}", + confidence_color, + format!( + "[{:.0}%]", + result.confidence * 100.0 + ) + .dimmed(), + result + .csv_track + .track_name + .white(), + format!( + "→ {}", + track.title + ) + .cyan() + ); + println!( + " {} {}", + "Artist:".dimmed(), + track + .artist + .white() + ); + if let Some(ref album) = track.album { + println!( + " {} {}", + "Album:".dimmed(), + album.white() + ); + } + } + None => { + println!( + " {} {}", + "✗".red(), + result + .csv_track + .track_name + .white() + ); + println!( + " {} {}", + "Searched:".dimmed(), + result + .search_attempts + .join(", ") + .white() + ); + } + } + } + + println!( + "{}", + "═" + .repeat(70) + .bright_blue() + ); + println!(); +} 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 + ) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2b5954f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,111 @@ +use colored::Colorize; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DownloadError { + #[error("Network request failed: {0}")] + NetworkError(#[from] reqwest::Error), + + #[error("Failed to parse JSON response: {0}")] + JsonParseError(#[from] serde_json::Error), + + #[error("Failed to decode base64 manifest: {0}")] + Base64DecodeError(#[from] base64::DecodeError), + + #[error("Failed to parse XML manifest: {0}")] + XmlParseError(String), + + #[error("Track not found: {0}")] + TrackNotFound(String), + + #[error("Album not found: {0}")] + AlbumNotFound(String), + + #[error("Artist not found: {0}")] + ArtistNotFound(String), + + #[error("Playlist not found: {0}")] + PlaylistNotFound(String), + + #[error("No download URL available for the requested quality")] + NoDownloadUrl, + + #[error("Rate limited. Please wait before making more requests")] + RateLimited, + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Download failed: {0}")] + DownloadFailed(String), + + #[error("Segment download failed: {0}")] + SegmentDownloadFailed(String), + + #[error("CSV parsing error: {0}")] + CsvError(String), + + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), +} + +impl From<csv::Error> for DownloadError { + fn from(err: csv::Error) -> Self { + DownloadError::CsvError(err.to_string()) + } +} + +impl DownloadError { + pub fn pretty_print(&self) { + let error_type = match self { + DownloadError::NetworkError(_) => "Network Error", + DownloadError::JsonParseError(_) => "Parse Error", + DownloadError::Base64DecodeError(_) => "Decode Error", + DownloadError::XmlParseError(_) => "XML Error", + DownloadError::TrackNotFound(_) => "Not Found", + DownloadError::AlbumNotFound(_) => "Not Found", + DownloadError::ArtistNotFound(_) => "Not Found", + DownloadError::PlaylistNotFound(_) => "Not Found", + DownloadError::NoDownloadUrl => "Download Error", + DownloadError::RateLimited => "Rate Limit", + DownloadError::IoError(_) => "IO Error", + DownloadError::DownloadFailed(_) => "Download Error", + DownloadError::SegmentDownloadFailed(_) => "Download Error", + DownloadError::CsvError(_) => "CSV Error", + DownloadError::ServiceUnavailable(_) => "Service Error", + }; + + eprintln!(); + eprintln!( + "{}", + "╭─────────────────────────────────────╮".bright_red() + ); + eprintln!( + "{} {} {:>24} {}", + "│".bright_red(), + error_type + .red() + .bold(), + "", + "│".bright_red() + ); + eprintln!( + "{}", + "├─────────────────────────────────────┤".bright_red() + ); + eprintln!( + "{} {} {}", + "│".bright_red(), + self.to_string() + .white(), + "│".bright_red() + ); + eprintln!( + "{}", + "╰─────────────────────────────────────╯".bright_red() + ); + eprintln!(); + } +} + +pub type Result<T> = std::result::Result<T, DownloadError>; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..35f31ff --- /dev/null +++ b/src/main.rs @@ -0,0 +1,2330 @@ +mod adaptive; +mod cli; +mod csv; +mod download; +mod error; +mod progress; +mod services; +mod types; +mod ui; + +use adaptive::{acquire_slot, AdaptiveConcurrency}; +use cli::{Cli, Commands}; +use colored::Colorize; +use csv::{CsvMatchResult, CsvMatcher}; +use download::{DirMode, DownloadManager, DownloadProgressUpdate}; +use error::{DownloadError, Result}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use progress::DownloadProgress; +use regex::Regex; +use services::{tidal::TidalService, MusicService}; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::sync::Mutex; +use types::{AudioQuality, Service}; +use ui::Ui; + +const NOT_FOUND_DELAY_MS: u64 = 1000; +const RATE_LIMIT_DELAY_MS: u64 = 15000; +const CONSECUTIVE_FAILURE_THRESHOLD: usize = 3; +const RETRY_BACKOFF_SECS: [u64; 5] = [ + 5, 15, 30, 60, 120, +]; + +pub struct ProgressManager { + multi: MultiProgress, + download_bars: Vec<ProgressBar>, + total_bar: ProgressBar, + status_bar: ProgressBar, + matched_count: Arc<AtomicU64>, + downloaded_count: Arc<AtomicU64>, + total_tracks: u64, +} + +impl ProgressManager { + pub fn new(max_concurrent: usize, total_tracks: u64) -> Self { + let multi = MultiProgress::new(); + + let mut download_bars = Vec::new(); + for i in 0..max_concurrent { + let pb = multi.add(ProgressBar::new(0)); + pb.set_style( + ProgressStyle::default_bar() + .template( + &format!( + "Slot {}: [{{bar:25.cyan/blue}}] {{msg}}", + i + 1 + ), + ) + .unwrap() + .progress_chars("█▓▒░ "), + ); + pb.set_message( + "Idle" + .dimmed() + .to_string(), + ); + pb.enable_steady_tick(Duration::from_millis(100)); + download_bars.push(pb); + } + + let separator = multi.add(ProgressBar::new_spinner()); + separator.set_style( + ProgressStyle::default_spinner() + .template("─────────────────────────────────────────────────────────────────────") + .unwrap(), + ); + separator.finish(); + + let total_bar = multi.add(ProgressBar::new(total_tracks * 2)); + total_bar.set_style( + ProgressStyle::default_bar() + .template("Progress: [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {percent}%") + .unwrap() + .progress_chars("█▓▒░ "), + ); + + let status_bar = multi.add(ProgressBar::new_spinner()); + status_bar.set_style( + ProgressStyle::default_spinner() + .template("Stage: {msg}") + .unwrap(), + ); + status_bar.set_message("1/2 - Matching tracks".to_string()); + status_bar.enable_steady_tick(Duration::from_millis(100)); + + Self { + multi, + download_bars, + total_bar, + status_bar, + matched_count: Arc::new(AtomicU64::new(0)), + downloaded_count: Arc::new(AtomicU64::new(0)), + total_tracks, + } + } + + pub fn set_stage(&self, stage: &str) { + self.status_bar + .set_message(stage.to_string()); + self.status_bar + .tick(); + } + + pub fn inc_matched(&self) { + self.matched_count + .fetch_add( + 1, + Ordering::Relaxed, + ); + self.total_bar + .inc(1); + self.update_total_message(); + } + + pub fn inc_downloaded(&self) { + self.downloaded_count + .fetch_add( + 1, + Ordering::Relaxed, + ); + self.total_bar + .inc(1); + self.update_total_message(); + } + + fn update_total_message(&self) { + let matched = self + .matched_count + .load(Ordering::Relaxed); + let downloaded = self + .downloaded_count + .load(Ordering::Relaxed); + self.total_bar + .set_message( + format!( + "{} matched + {} downloaded", + matched, downloaded + ), + ); + } + + pub fn set_downloading(&self, slot: usize, artist: &str, title: &str) { + if slot + < self + .download_bars + .len() + { + self.download_bars[slot].set_message( + format!( + "{} - {}", + artist, title + ), + ); + self.download_bars[slot].set_length(0); + self.download_bars[slot].set_position(0); + } + } + + pub fn update_download_progress(&self, slot: usize, progress: DownloadProgressUpdate) { + if slot + < self + .download_bars + .len() + { + if let Some(total) = progress.total_bytes { + self.download_bars[slot].set_length(total); + } + self.download_bars[slot].set_position(progress.bytes_downloaded); + if let Some(speed) = progress.speed_bps { + let speed_str = format_speed(speed); + self.download_bars[slot].set_message( + format!( + "{} ({})", + self.download_bars[slot] + .message() + .split_whitespace() + .next() + .unwrap_or(""), + speed_str + ), + ); + } + } + } + + pub fn finish_download(&self, slot: usize, success: bool, artist: &str, title: &str) { + if slot + < self + .download_bars + .len() + { + if success { + self.download_bars[slot].set_message( + format!( + "✓ {} - {}", + artist.green(), + title + ), + ); + } else { + self.download_bars[slot].set_message( + format!( + "✗ {} - {}", + artist.red(), + title + ), + ); + } + self.download_bars[slot].finish(); + } + } + + pub fn clear_slot(&self, slot: usize) { + if slot + < self + .download_bars + .len() + { + self.download_bars[slot].set_message( + "Idle" + .dimmed() + .to_string(), + ); + self.download_bars[slot].set_length(0); + self.download_bars[slot].set_position(0); + } + } + + pub fn update_download(&self, slot: usize, msg: String) { + if slot + < self + .download_bars + .len() + { + self.download_bars[slot].set_message(msg); + self.download_bars[slot].tick(); + } + } + + pub fn inc_total(&self) { + self.total_bar + .inc(1); + } + + pub fn finish(&self) { + for bar in &self.download_bars { + bar.finish_and_clear(); + } + self.total_bar + .finish(); + self.status_bar + .finish(); + } + + pub fn finish_with_message(&self, msg: &str) { + for bar in &self.download_bars { + bar.finish_and_clear(); + } + self.total_bar + .finish_with_message(msg.to_string()); + self.status_bar + .finish(); + } + + pub fn clear(&self) { + for bar in &self.download_bars { + bar.finish_and_clear(); + } + self.total_bar.finish_and_clear(); + self.status_bar.finish_and_clear(); + } + + pub fn reset_for_download(&self, download_count: u64) { + for bar in &self.download_bars { + bar.reset(); + bar.set_message("Idle".dimmed().to_string()); + bar.set_length(0); + bar.set_position(0); + } + self.total_bar.set_length(self.total_tracks + download_count); + } +} + +fn format_speed(bytes_per_sec: f64) -> String { + if bytes_per_sec >= 1_000_000.0 { + format!( + "{:.1} MB/s", + bytes_per_sec / 1_000_000.0 + ) + } else if bytes_per_sec >= 1_000.0 { + format!( + "{:.1} KB/s", + bytes_per_sec / 1_000.0 + ) + } else { + format!( + "{:.0} B/s", + bytes_per_sec + ) + } +} + +pub struct SimpleProgressManager { + multi: MultiProgress, + download_bars: Vec<ProgressBar>, + total_bar: ProgressBar, +} + +impl SimpleProgressManager { + pub fn new(max_concurrent: usize, total: u64, prefix: &str) -> Self { + let multi = MultiProgress::new(); + + let mut download_bars = Vec::new(); + for _ in 0..max_concurrent { + let pb = multi.add(ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::default_spinner() + .template("{msg}") + .unwrap(), + ); + pb.set_message( + "Idle" + .dimmed() + .to_string(), + ); + pb.enable_steady_tick(Duration::from_millis(100)); + download_bars.push(pb); + } + + let separator = multi.add(ProgressBar::new_spinner()); + separator.set_style( + ProgressStyle::default_spinner() + .template("─────────────────────────────────────────────────────────────────────") + .unwrap(), + ); + separator.finish(); + + let total_bar = multi.add(ProgressBar::new(total)); + total_bar.set_style( + ProgressStyle::default_bar() + .template("{prefix}: [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {percent}%") + .unwrap() + .progress_chars("█▓▒░ "), + ); + total_bar.set_prefix(prefix.to_string()); + + Self { + multi, + download_bars, + total_bar, + } + } + + pub fn update_download(&self, slot: usize, msg: String) { + if slot + < self + .download_bars + .len() + { + self.download_bars[slot].set_message(msg); + self.download_bars[slot].tick(); + } + } + + pub fn clear_slot(&self, slot: usize) { + if slot + < self + .download_bars + .len() + { + self.download_bars[slot].set_message( + "Idle" + .dimmed() + .to_string(), + ); + } + } + + pub fn inc_total(&self) { + self.total_bar + .inc(1); + } + + pub fn finish_with_message(&self, msg: &str) { + for bar in &self.download_bars { + bar.finish_and_clear(); + } + self.total_bar + .finish_with_message(msg.to_string()); + } +} + +#[tokio::main] +async fn main() { + colored::control::set_override(true); + let args = Cli::parse(); + + if !args.quiet { + Ui::print_banner(); + } + + let output_dir = args + .output + .clone() + .unwrap_or_else(|| PathBuf::from("./downloads")); + let quality = parse_quality( + args.quality + .as_deref(), + ); + let service = parse_service( + args.service + .as_deref(), + ); + let max_concurrent = args + .concurrent + .unwrap_or_else(num_cpus::get); + let dir_mode = parse_dir_mode( + args.dir_mode + .as_deref(), + ); + + if let Err(e) = run_command( + args, + &output_dir, + quality, + service, + max_concurrent, + dir_mode, + ) + .await + { + e.pretty_print(); + std::process::exit(1); + } +} + +fn parse_quality(quality: Option<&str>) -> AudioQuality { + match quality { + Some("hires") => AudioQuality::HiResLossless, + Some("lossless") => AudioQuality::Lossless, + Some("high") => AudioQuality::High, + Some("low") => AudioQuality::Low, + Some("mp3") => AudioQuality::Mp3_320, + None => AudioQuality::HiResLossless, + _ => AudioQuality::HiResLossless, + } +} + +fn parse_service(service: Option<&str>) -> Service { + match service { + Some("tidal") => Service::Tidal, + Some("amazon") => Service::AmazonMusic, + Some("soundcloud") => Service::SoundCloud, + Some("khinsider") => Service::KHInsider, + None => Service::Tidal, + _ => Service::Tidal, + } +} + +fn parse_dir_mode(dir_mode: Option<&str>) -> DirMode { + match dir_mode { + Some("playlist") => DirMode::Playlist, + Some("album") => DirMode::Album, + Some("artist") => DirMode::Artist, + Some("flat") => DirMode::Flat, + None => DirMode::Playlist, + _ => DirMode::Playlist, + } +} + +async fn ensure_output_dir(output_dir: &PathBuf) -> Result<()> { + if !output_dir.exists() { + tokio::fs::create_dir_all(output_dir).await?; + } + Ok(()) +} + +async fn run_command(args: Cli, output_dir: &PathBuf, quality: AudioQuality, service: Service, max_concurrent: usize, dir_mode: DirMode) -> Result<()> { + match args.command { + Commands::Search { + query, + artist, + limit, + } => { + Ui::print_info( + &format!( + "Searching for: {}", + query.cyan() + ), + ); + + let tidal = TidalService::new(); + let results = tidal + .search( + &query, + artist.as_deref(), + ) + .await?; + + if results + .tracks + .is_empty() + { + Ui::print_warning("No results found"); + return Ok(()); + } + + Ui::print_info( + &format!( + "Found {} results", + results + .tracks + .len() + .to_string() + .green() + ), + ); + Ui::print_search_results( + &results.tracks, + limit, + ); + } + + Commands::Track { + input, + artist, + first, + } => { + ensure_output_dir(output_dir).await?; + download_track( + &input, + artist.as_deref(), + first, + output_dir, + quality, + service, + ) + .await?; + } + + Commands::Album { + input, + } => { + ensure_output_dir(output_dir).await?; + download_album( + &input, + output_dir, + quality, + service, + max_concurrent, + ) + .await?; + } + + Commands::Artist { + input, + } => { + download_artist( + &input, output_dir, quality, service, + ) + .await?; + } + + Commands::Playlist { + input, + } => { + ensure_output_dir(output_dir).await?; + download_playlist( + &input, + output_dir, + quality, + service, + max_concurrent, + ) + .await?; + } + + Commands::Csv { + file, + threshold, + yes, + } => { + ensure_output_dir(output_dir).await?; + download_from_csv( + &file, + threshold, + yes, + output_dir, + quality, + max_concurrent, + dir_mode, + ) + .await?; + } + + Commands::Info { + id, + } => { + let tidal = TidalService::new(); + let track = tidal + .get_track_info(id) + .await?; + Ui::print_track_detail( + &track, None, + ); + } + + Commands::Recommend { + id, + } => { + Ui::print_info("Fetching recommendations..."); + let tidal = TidalService::new(); + let results = tidal + .search( + "recommendations", + None, + ) + .await?; + Ui::print_search_results( + &results.tracks, + 10, + ); + let _ = id; + } + + Commands::List => { + Ui::print_services(); + Ui::print_qualities(); + } + } + + Ok(()) +} + +async fn download_track(input: &str, artist: Option<&str>, first: bool, output_dir: &PathBuf, quality: AudioQuality, _service: Service) -> Result<()> { + let tidal = TidalService::new(); + + let track_id = if input + .parse::<u64>() + .is_ok() + { + input + .parse::<u64>() + .unwrap() + } else { + Ui::print_info( + &format!( + "Searching for: {}", + input.cyan() + ), + ); + + let search_results = tidal + .search( + input, artist, + ) + .await?; + + if search_results + .tracks + .is_empty() + { + return Err(DownloadError::TrackNotFound(input.to_string())); + } + + let selected_index = if first { + 0 + } else { + Ui::select_track(&search_results.tracks).ok_or_else(|| DownloadError::TrackNotFound("No track selected".to_string()))? + }; + + let track = &search_results.tracks[selected_index]; + Ui::print_info( + &format!( + "Selected: {} by {}", + track + .title + .cyan(), + track + .artist + .white() + ), + ); + + track.id + }; + + let track_info = tidal + .get_track_info(track_id) + .await?; + let manifest = tidal + .get_manifest( + track_id, quality, + ) + .await?; + + let downloader = DownloadManager::new(output_dir); + downloader + .download_from_manifest( + &manifest, + &track_info, + ) + .await?; + + Ui::print_success("Download complete!"); + + Ok(()) +} + +async fn download_album(input: &str, output_dir: &PathBuf, quality: AudioQuality, _service: Service, max_concurrent: usize) -> Result<()> { + let tidal = TidalService::new(); + + let album_id = if input + .parse::<u64>() + .is_ok() + { + input + .parse::<u64>() + .unwrap() + } else { + Ui::print_info( + &format!( + "Searching for album: {}", + input.cyan() + ), + ); + + let search_results = tidal + .search( + input, None, + ) + .await?; + + if search_results + .tracks + .is_empty() + { + return Err(DownloadError::AlbumNotFound(input.to_string())); + } + + let track = &search_results.tracks[0]; + let album_name = track + .album + .as_ref() + .ok_or_else(|| DownloadError::AlbumNotFound("No album info found".to_string()))?; + + Ui::print_info( + &format!( + "Found album: {} by {}", + album_name.cyan(), + track + .artist + .white() + ), + ); + + if !Ui::confirm("Download this album?") { + Ui::print_info("Cancelled"); + return Ok(()); + } + + return Ok(()); + }; + + let album_info = tidal + .get_album_info(album_id) + .await?; + Ui::print_album_detail(&album_info); + + let tracks = tidal + .get_album_tracks(album_id) + .await?; + + let total_tracks = tracks.len(); + let progress_mgr = Arc::new( + SimpleProgressManager::new( + max_concurrent, + total_tracks as u64, + "Album", + ), + ); + + let album_name = Some( + album_info + .title + .clone(), + ); + let output_dir = output_dir.clone(); + let adaptive = Arc::new(Mutex::new(AdaptiveConcurrency::new(max_concurrent))); + let semaphore = adaptive + .lock() + .await + .semaphore(); + let success_count = Arc::new(Mutex::new(0usize)); + let fail_count = Arc::new(Mutex::new(0usize)); + let slot_counter = Arc::new(AtomicUsize::new(0)); + + let mut handles = vec![]; + + for track in tracks { + let permit = acquire_slot(&semaphore).await; + let tidal = TidalService::new(); + let output_dir = output_dir.clone(); + let quality = quality; + let album_name = album_name.clone(); + let success_count = success_count.clone(); + let fail_count = fail_count.clone(); + let adaptive_inner = adaptive.clone(); + let progress_inner = progress_mgr.clone(); + let slot_counter_inner = slot_counter.clone(); + + let handle = tokio::spawn( + async move { + let _permit = permit; + let slot = slot_counter_inner.fetch_add( + 1, + Ordering::Relaxed, + ) % max_concurrent; + let downloader = DownloadManager::new(&output_dir).with_album(album_name.as_deref()); + + progress_inner.update_download( + slot, + format!( + "Matching: {} - {}", + track + .artist + .white(), + track + .title + .cyan() + ), + ); + + let result = retry_download( + || async { + let manifest = tidal + .get_manifest( + track.id, quality, + ) + .await?; + downloader + .download_from_manifest( + &manifest, &track, + ) + .await + }, + adaptive_inner.clone(), + ) + .await; + + match result { + Ok(_) => { + adaptive_inner + .lock() + .await + .on_success(); + *success_count + .lock() + .await += 1; + progress_inner.update_download( + slot, + format!( + "✓ {} - {}", + track + .artist + .white(), + track + .title + .green() + ), + ); + } + Err(e) => { + adaptive_inner + .lock() + .await + .on_failure(); + *fail_count + .lock() + .await += 1; + progress_inner.update_download( + slot, + format!( + "✗ {}: {}", + track + .title + .white(), + e.to_string() + .dimmed() + ), + ); + } + } + progress_inner.inc_total(); + }, + ); + + handles.push(handle); + } + + for handle in handles { + handle + .await + .unwrap(); + } + + let success = *success_count + .lock() + .await; + let fail = *fail_count + .lock() + .await; + + let final_concurrent = adaptive + .lock() + .await + .get_concurrent(); + + progress_mgr.finish_with_message( + &format!( + "{} successful, {} failed", + success + .to_string() + .green(), + fail.to_string() + .red(), + ), + ); + + println!(); + println!( + "Album download complete. Final concurrency: {}", + final_concurrent + .to_string() + .yellow() + ); + + Ok(()) +} + +async fn download_artist(input: &str, _output_dir: &PathBuf, _quality: AudioQuality, _service: Service) -> Result<()> { + let tidal = TidalService::new(); + + Ui::print_info( + &format!( + "Searching for artist: {}", + input.cyan() + ), + ); + + let search_results = tidal + .search( + input, None, + ) + .await?; + + if search_results + .tracks + .is_empty() + { + return Err(DownloadError::ArtistNotFound(input.to_string())); + } + + Ui::print_search_results( + &search_results.tracks, + 5, + ); + Ui::print_warning("Artist downloads are not fully implemented yet."); + Ui::print_info("Use 'track' command to download individual tracks."); + + Ok(()) +} + +async fn download_playlist(input: &str, output_dir: &PathBuf, quality: AudioQuality, _service: Service, max_concurrent: usize) -> Result<()> { + let tidal = TidalService::new(); + + let playlist_id = if input + .parse::<u64>() + .is_ok() + { + input + .parse::<u64>() + .unwrap() + } else { + return Err(DownloadError::PlaylistNotFound("Please provide a playlist ID".to_string())); + }; + + let playlist_info = tidal + .get_playlist_info(playlist_id) + .await?; + Ui::print_playlist_detail(&playlist_info); + + if !Ui::confirm("Download this playlist?") { + Ui::print_info("Cancelled"); + return Ok(()); + } + + let tracks = tidal + .get_playlist_tracks(playlist_id) + .await?; + + let total_tracks = tracks.len(); + let progress_mgr = Arc::new( + SimpleProgressManager::new( + max_concurrent, + total_tracks as u64, + "Playlist", + ), + ); + + let output_dir = output_dir.clone(); + let adaptive = Arc::new(Mutex::new(AdaptiveConcurrency::new(max_concurrent))); + let semaphore = adaptive + .lock() + .await + .semaphore(); + let success_count = Arc::new(Mutex::new(0usize)); + let fail_count = Arc::new(Mutex::new(0usize)); + let slot_counter = Arc::new(AtomicUsize::new(0)); + + let mut handles = vec![]; + + for track in tracks { + let permit = acquire_slot(&semaphore).await; + let tidal = TidalService::new(); + let output_dir = output_dir.clone(); + let quality = quality; + let success_count = success_count.clone(); + let fail_count = fail_count.clone(); + let adaptive_inner = adaptive.clone(); + let progress_inner = progress_mgr.clone(); + let slot_counter_inner = slot_counter.clone(); + + let handle = tokio::spawn( + async move { + let _permit = permit; + let slot = slot_counter_inner.fetch_add( + 1, + Ordering::Relaxed, + ) % max_concurrent; + let downloader = DownloadManager::new(&output_dir); + + progress_inner.update_download( + slot, + format!( + "Matching: {} - {}", + track + .artist + .white(), + track + .title + .cyan() + ), + ); + + let result = retry_download( + || async { + let manifest = tidal + .get_manifest( + track.id, quality, + ) + .await?; + downloader + .download_from_manifest( + &manifest, &track, + ) + .await + }, + adaptive_inner.clone(), + ) + .await; + + match result { + Ok(_) => { + adaptive_inner + .lock() + .await + .on_success(); + *success_count + .lock() + .await += 1; + progress_inner.update_download( + slot, + format!( + "✓ {} - {}", + track + .artist + .white(), + track + .title + .green() + ), + ); + } + Err(e) => { + adaptive_inner + .lock() + .await + .on_failure(); + *fail_count + .lock() + .await += 1; + progress_inner.update_download( + slot, + format!( + "✗ {}: {}", + track + .title + .white(), + e.to_string() + .dimmed() + ), + ); + } + } + progress_inner.inc_total(); + }, + ); + + handles.push(handle); + } + + for handle in handles { + handle + .await + .unwrap(); + } + + let success = *success_count + .lock() + .await; + let fail = *fail_count + .lock() + .await; + + let final_concurrent = adaptive + .lock() + .await + .get_concurrent(); + + progress_mgr.finish_with_message( + &format!( + "{} successful, {} failed", + success + .to_string() + .green(), + fail.to_string() + .red(), + ), + ); + + println!(); + println!( + "Playlist download complete. Final concurrency: {}", + final_concurrent + .to_string() + .yellow() + ); + + Ok(()) +} + +#[derive(Debug, Clone)] +struct DownloadTask { + track: crate::types::TrackInfo, + csv_track: csv::CsvTrack, + confidence: f64, + album: Option<String>, +} + +#[derive(Debug, Clone)] +struct DownloadJob { + track: crate::types::TrackInfo, + album: Option<String>, + playlist: Option<String>, + dir_mode: DirMode, + retry_count: usize, + max_retries: usize, + last_error: Option<String>, +} + +async fn download_from_csv(file: &PathBuf, threshold: f64, skip_confirm: bool, output_dir: &PathBuf, quality: AudioQuality, max_concurrent: usize, dir_mode: DirMode) -> Result<()> { + Ui::print_info( + &format!( + "Parsing CSV file: {}", + file.display() + .to_string() + .cyan() + ), + ); + + let csv_tracks = csv::parse_csv_file(file)?; + let total = csv_tracks.len(); + let progress_file = output_dir.join(".download_progress.json"); + let source_str = file + .display() + .to_string(); + + let (progress, resume) = if progress_file.exists() { + if let Ok(existing) = DownloadProgress::load_from_file(&progress_file) { + if existing.source == source_str { + let completed = existing.get_completed_count(); + let failed_matches = existing + .failed_matches + .len(); + println!(); + println!( + "{} Previous progress found: {}/{} tracks completed, {} failed matches", + "→".cyan(), + completed + .to_string() + .green(), + total + .to_string() + .white(), + failed_matches + .to_string() + .red() + ); + let resume_prompt = "Resume from where you left off?"; + if Ui::confirm(resume_prompt) { + ( + existing, true, + ) + } else { + let _ = std::fs::remove_file(&progress_file); + ( + DownloadProgress::new( + &source_str, + total, + ), + false, + ) + } + } else { + ( + DownloadProgress::new( + &source_str, + total, + ), + false, + ) + } + } else { + ( + DownloadProgress::new( + &source_str, + total, + ), + false, + ) + } + } else { + ( + DownloadProgress::new( + &source_str, + total, + ), + false, + ) + }; + + Ui::print_info( + &format!( + "Found {} tracks in CSV", + total + .to_string() + .green() + ), + ); + + let matched_indices: std::collections::HashSet<usize> = if resume { + progress + .matched_csv_indices + .iter() + .copied() + .collect() + } else { + std::collections::HashSet::new() + }; + + let completed_track_ids: std::collections::HashSet<u64> = if resume { + progress + .completed_track_ids + .iter() + .copied() + .collect() + } else { + std::collections::HashSet::new() + }; + + Ui::print_info( + &format!( + "Matching with adaptive concurrency (max {})...", + max_concurrent + .to_string() + .cyan() + ), + ); + + let progress_mgr = Arc::new( + ProgressManager::new( + max_concurrent, + total as u64, + ), + ); + let matcher = Arc::new(CsvMatcher::new()); + let adaptive = Arc::new(Mutex::new(AdaptiveConcurrency::new(max_concurrent))); + let semaphore = adaptive + .lock() + .await + .semaphore(); + let match_results = Arc::new(Mutex::new(Vec::new())); + let progress_counter = Arc::new(Mutex::new(0usize)); + let consecutive_failures = Arc::new(Mutex::new(0usize)); + let failed_match_indices = Arc::new(Mutex::new(Vec::<usize>::new())); + let matched_indices_shared = Arc::new(Mutex::new(matched_indices.clone())); + let progress_shared = Arc::new(Mutex::new(progress.clone())); + let csv_tracks_shared = Arc::new(csv_tracks.clone()); + let slot_counter = Arc::new(AtomicUsize::new(0)); + + let mut handles = vec![]; + + for (index, track) in csv_tracks + .clone() + .into_iter() + .enumerate() + { + if resume && matched_indices.contains(&index) { + let mut prog = progress_counter + .lock() + .await; + *prog += 1; + progress_mgr.inc_matched(); + continue; + } + + let permit = acquire_slot(&semaphore).await; + let matcher = matcher.clone(); + let match_results = match_results.clone(); + let progress_counter = progress_counter.clone(); + let consecutive_failures = consecutive_failures.clone(); + let failed_match_indices = failed_match_indices.clone(); + let matched_indices_inner = matched_indices_shared.clone(); + let progress_inner = progress_shared.clone(); + let csv_tracks_inner = csv_tracks_shared.clone(); + let track_name = track + .track_name + .clone(); + let progress_mgr_inner = progress_mgr.clone(); + let slot_counter_inner = slot_counter.clone(); + + let handle = tokio::spawn( + async move { + let _permit = permit; + let slot = slot_counter_inner.fetch_add( + 1, + Ordering::Relaxed, + ) % max_concurrent; + progress_mgr_inner.update_download( + slot, + format!( + "Matching: {}", + track_name.white() + ), + ); + + let result = matcher + .find_best_match(&track) + .await + .unwrap_or_else( + |e| CsvMatchResult { + csv_track: track.clone(), + best_match: None, + confidence: 0.0, + search_attempts: vec![ + format!( + "Error: {}", + e + ), + ], + }, + ); + + let is_match = result + .best_match + .is_some(); + + if !is_match { + let mut failures = consecutive_failures + .lock() + .await; + *failures += 1; + + if *failures >= CONSECUTIVE_FAILURE_THRESHOLD { + drop(failures); + progress_mgr_inner.update_download( + slot, + format!( + "Rate limited, waiting {}s...", + RATE_LIMIT_DELAY_MS / 1000 + ), + ); + tokio::time::sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await; + } else { + drop(failures); + tokio::time::sleep(Duration::from_millis(NOT_FOUND_DELAY_MS)).await; + } + + failed_match_indices + .lock() + .await + .push(index); + } else { + *consecutive_failures + .lock() + .await = 0; + matched_indices_inner + .lock() + .await + .insert(index); + + if let Some(ref matched_track) = result.best_match { + let csv_track = &csv_tracks_inner[index]; + progress_inner + .lock() + .await + .add_matched_track( + index, + matched_track, + result.confidence, + &csv_track.track_name, + &csv_track.artist_name, + csv_track + .album + .as_deref(), + csv_track + .playlist_name + .as_deref(), + ); + } + } + + let status = if is_match { + if result.confidence >= threshold { + format!( + "✓ [{:.0}%] {}", + result.confidence * 100.0, + track_name.green() + ) + } else { + format!( + "? [{:.0}%] {}", + result.confidence * 100.0, + track_name.yellow() + ) + } + } else { + format!( + "✗ Not found: {}", + track_name.red() + ) + }; + + let mut prog = progress_counter + .lock() + .await; + *prog += 1; + progress_mgr_inner.update_download( + slot, status, + ); + progress_mgr_inner.inc_matched(); + + match_results + .lock() + .await + .push( + ( + index, result, + ), + ); + }, + ); + + handles.push(handle); + } + + for handle in handles { + handle + .await + .unwrap(); + } + + let match_results_inner = Arc::try_unwrap(match_results) + .unwrap() + .into_inner(); + let match_results_sorted: Vec<( + usize, + CsvMatchResult, + )> = match_results_inner; + let mut match_results: Vec<Option<CsvMatchResult>> = vec![None; total]; + + for (index, result) in match_results_sorted { + match_results[index] = Some(result); + } + + if resume { + for m in &progress.matched_tracks { + let csv_index = m.csv_index; + if match_results[csv_index].is_none() { + match_results[csv_index] = Some( + CsvMatchResult { + csv_track: csv::CsvTrack { + track_name: m + .csv_track_name + .clone(), + artist_name: m + .csv_artist_name + .clone(), + album: m + .csv_album + .clone(), + playlist_name: m + .playlist_name + .clone(), + track_type: None, + isrc: None, + spotify_id: None, + }, + best_match: Some( + crate::types::TrackInfo { + id: m.track_id, + title: m + .track_title + .clone(), + artist: m + .track_artist + .clone(), + album: m + .album + .clone(), + duration: None, + quality: None, + cover_url: None, + }, + ), + confidence: m.confidence, + search_attempts: vec![], + }, + ); + } + } + } + + let mut match_results: Vec<CsvMatchResult> = match_results + .into_iter() + .enumerate() + .map( + |(index, opt)| { + opt.unwrap_or_else( + || CsvMatchResult { + csv_track: csv_tracks[index].clone(), + best_match: None, + confidence: 0.0, + search_attempts: vec![], + }, + ) + }, + ) + .collect(); + + let failed_indices = Arc::try_unwrap(failed_match_indices) + .unwrap() + .into_inner(); + + for idx in &failed_indices { + let track = &csv_tracks[*idx]; + progress_shared + .lock() + .await + .add_failed_match( + &track.track_name, + Some(&track.artist_name), + track + .album + .as_deref(), + ); + } + + { + let mut prog = progress_shared + .lock() + .await; + prog.total_matched = prog + .matched_csv_indices + .len(); + let _ = prog.save_to_file(&progress_file); + } + + if !failed_indices.is_empty() { + let retry_spinner = ProgressBar::new_spinner(); + retry_spinner.set_style(ProgressStyle::default_spinner()); + retry_spinner.set_message( + format!( + "Retrying {} failed matches...", + failed_indices.len() + ), + ); + + let retry_match_results = Arc::new(Mutex::new(Vec::new())); + let retry_progress = Arc::new(Mutex::new(0usize)); + + for idx in &failed_indices { + let csv_track = csv_tracks[*idx].clone(); + let tidal = TidalService::new(); + let retry_match_results = retry_match_results.clone(); + let retry_progress = retry_progress.clone(); + + let cleaned_name = clean_track_name(&csv_track.track_name); + retry_spinner.set_message( + format!( + "Retrying: '{}' by '{}'", + cleaned_name, csv_track.artist_name + ), + ); + retry_spinner.tick(); + + let mut candidates: Vec<crate::types::TrackInfo> = Vec::new(); + + let query1 = format!( + "{} {}", + cleaned_name, csv_track.artist_name + ); + if let Ok(results) = tidal + .search( + &query1, None, + ) + .await + { + candidates.extend(results.tracks); + tokio::time::sleep(Duration::from_millis(300)).await; + } + + if let Ok(results) = tidal + .search( + &cleaned_name, + Some(&csv_track.artist_name), + ) + .await + { + candidates.extend(results.tracks); + tokio::time::sleep(Duration::from_millis(300)).await; + } + + if let Ok(results) = tidal + .search( + &cleaned_name, + None, + ) + .await + { + candidates.extend(results.tracks); + } + + candidates.sort_by_key(|t| t.id); + candidates.dedup_by_key(|t| t.id); + + let best = csv::CsvMatcher::score_and_select_best( + &candidates, + &csv_track, + ); + + let result = CsvMatchResult { + csv_track: csv_track.clone(), + best_match: best + .as_ref() + .map(|(t, _)| t.clone()), + confidence: best + .map(|(_, s)| s) + .unwrap_or(0.0), + search_attempts: vec![ + query1, + cleaned_name.clone(), + ], + }; + + let mut prog = retry_progress + .lock() + .await; + *prog += 1; + + if result + .best_match + .is_some() + { + let mut prog = progress_shared + .lock() + .await; + prog.total_matched += 1; + prog.mark_csv_matched(*idx); + if let Some(ref track) = result.best_match { + let csv_track = &csv_tracks[*idx]; + prog.add_matched_track( + *idx, + track, + result.confidence, + &csv_track.track_name, + &csv_track.artist_name, + csv_track + .album + .as_deref(), + csv_track + .playlist_name + .as_deref(), + ); + } + } + + retry_match_results + .lock() + .await + .push( + ( + *idx, result, + ), + ); + } + + retry_spinner.finish_with_message("Retry complete"); + + let retry_results = Arc::try_unwrap(retry_match_results) + .unwrap() + .into_inner(); + + for (idx, result) in retry_results { + match_results[idx] = result; + } + } + + progress_mgr.clear(); + + csv::print_csv_summary(&match_results); + + let valid_matches: Vec<( + usize, + &CsvMatchResult, + )> = match_results + .iter() + .enumerate() + .filter( + |(_, r)| { + r.best_match + .is_some() + && r.confidence >= threshold + }, + ) + .collect(); + + if valid_matches.is_empty() { + Ui::print_warning("No tracks matched with sufficient confidence."); + progress_shared + .lock() + .await + .save_to_file(&progress_file)?; + return Ok(()); + } + + let skipped = match_results.len() - valid_matches.len(); + if skipped > 0 { + Ui::print_warning( + &format!( + "{} tracks skipped (below {:.0}% confidence threshold)", + skipped, + threshold * 100.0 + ), + ); + } + + let tracks_to_download: Vec<_> = valid_matches + .iter() + .filter( + |(_, r)| { + if let Some(ref track) = r.best_match { + !completed_track_ids.contains(&track.id) + } else { + false + } + }, + ) + .collect(); + + let already_completed = valid_matches.len() - tracks_to_download.len(); + if already_completed > 0 && resume { + println!( + "{} {} tracks already downloaded, skipping...", + "→".cyan(), + already_completed + .to_string() + .green() + ); + } + + if tracks_to_download.is_empty() { + Ui::print_info("All tracks already downloaded."); + progress_shared + .lock() + .await + .save_to_file(&progress_file)?; + return Ok(()); + } + + println!(); + if !skip_confirm { + if !Ui::confirm( + &format!( + "Download {} tracks with adaptive concurrency?", + tracks_to_download.len() + ), + ) { + Ui::print_info("Cancelled"); + progress_shared + .lock() + .await + .save_to_file(&progress_file)?; + return Ok(()); + } + } + + Ui::print_info( + &format!( + "Downloading {} tracks with parallel retry queue (max {})...", + tracks_to_download + .len() + .to_string() + .green(), + max_concurrent + .to_string() + .cyan() + ), + ); + + progress_mgr.reset_for_download(tracks_to_download.len() as u64); + progress_mgr.set_stage("2/2 - Downloading"); + + let output_dir = output_dir.clone(); + let download_adaptive = Arc::new(Mutex::new(AdaptiveConcurrency::new(max_concurrent))); + let success_count = Arc::new(Mutex::new(0usize)); + let final_fail_count = Arc::new(Mutex::new(0usize)); + + let queue: Arc<Mutex<std::collections::VecDeque<DownloadJob>>> = Arc::new(Mutex::new(std::collections::VecDeque::new())); + let in_flight = Arc::new(Mutex::new(0usize)); + let completed = Arc::new(Mutex::new(0usize)); + let failed_jobs = Arc::new( + Mutex::new( + Vec::<( + DownloadJob, + String, + )>::new(), + ), + ); + let progress_file_shared = progress_file.clone(); + let download_slot_counter = Arc::new(AtomicUsize::new(0)); + + for (_, result) in &tracks_to_download { + if let Some(ref track) = result.best_match { + let job = DownloadJob { + track: track.clone(), + album: result + .csv_track + .album + .clone(), + playlist: result + .csv_track + .playlist_name + .clone(), + dir_mode, + retry_count: 0, + max_retries: 5, + last_error: None, + }; + queue + .lock() + .await + .push_back(job); + } + } + + let mut worker_handles = vec![]; + + for _worker_id in 0..max_concurrent { + let queue = queue.clone(); + let in_flight = in_flight.clone(); + let completed = completed.clone(); + let success_count = success_count.clone(); + let final_fail_count = final_fail_count.clone(); + let failed_jobs = failed_jobs.clone(); + let download_adaptive = download_adaptive.clone(); + let output_dir = output_dir.clone(); + let quality = quality; + let progress_shared = progress_shared.clone(); + let progress_file_inner = progress_file_shared.clone(); + let progress_mgr_inner = progress_mgr.clone(); + let download_slot_counter_inner = download_slot_counter.clone(); + + let handle = tokio::spawn( + async move { + loop { + let job_opt = { + let mut q = queue + .lock() + .await; + q.pop_front() + }; + + let job = match job_opt { + Some(j) => j, + None => { + let in_flight_val = *in_flight + .lock() + .await; + if in_flight_val == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + }; + + *in_flight + .lock() + .await += 1; + + let slot = download_slot_counter_inner.fetch_add( + 1, + Ordering::Relaxed, + ) % max_concurrent; + let tidal = TidalService::new(); + let downloader = DownloadManager::new(&output_dir) + .with_album( + job.album + .as_deref(), + ) + .with_playlist( + job.playlist + .as_deref(), + ) + .with_dir_mode(job.dir_mode); + + progress_mgr_inner.set_downloading( + slot, + &job.track + .artist, + &job.track + .title, + ); + + let progress_mgr_clone = progress_mgr_inner.clone(); + let slot_for_callback = slot; + let result = async { + let manifest = tidal + .get_manifest( + job.track + .id, + quality, + ) + .await?; + downloader + .download_from_manifest_with_progress( + &manifest, + &job.track, + |progress| { + progress_mgr_clone.update_download_progress( + slot_for_callback, + progress, + ); + }, + ) + .await + } + .await; + + match result { + Ok(_) => { + download_adaptive + .lock() + .await + .on_success(); + *success_count + .lock() + .await += 1; + *completed + .lock() + .await += 1; + *in_flight + .lock() + .await -= 1; + + { + let mut prog = progress_shared + .lock() + .await; + prog.mark_completed( + job.track + .id, + ); + let _ = prog.save_to_file(&progress_file_inner); + } + + progress_mgr_inner.finish_download( + slot, + true, + &job.track + .artist, + &job.track + .title, + ); + progress_mgr_inner.inc_downloaded(); + } + Err(e) => { + let error_str = e.to_string(); + let is_retryable = matches!( + e, + DownloadError::RateLimited | DownloadError::NetworkError(_) | DownloadError::DownloadFailed(_) | DownloadError::SegmentDownloadFailed(_) | DownloadError::ServiceUnavailable(_) + ); + + download_adaptive + .lock() + .await + .on_failure(); + + if is_retryable && job.retry_count + 1 < job.max_retries { + let delay = Duration::from_secs( + RETRY_BACKOFF_SECS[job + .retry_count + .min(4) as usize], + ); + progress_mgr_inner.update_download( + slot, + format!( + "Retrying: {} (attempt {}/{})", + job.track + .title + .yellow(), + job.retry_count + 1, + job.max_retries + ), + ); + tokio::time::sleep(delay).await; + + let retry_job = DownloadJob { + track: job.track, + album: job.album, + playlist: job.playlist, + dir_mode: job.dir_mode, + retry_count: job.retry_count + 1, + max_retries: job.max_retries, + last_error: Some(error_str), + }; + queue + .lock() + .await + .push_back(retry_job); + *in_flight + .lock() + .await -= 1; + } else { + *completed + .lock() + .await += 1; + *in_flight + .lock() + .await -= 1; + *final_fail_count + .lock() + .await += 1; + failed_jobs + .lock() + .await + .push( + ( + job.clone(), + error_str.clone(), + ), + ); + progress_shared + .lock() + .await + .add_failure( + &job.track, + job.album + .as_deref(), + error_str.clone(), + ); + progress_mgr_inner.finish_download( + slot, + false, + &job.track + .title, + &error_str, + ); + progress_mgr_inner.inc_downloaded(); + } + } + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + }, + ); + + worker_handles.push(handle); + } + + for handle in worker_handles { + let _ = handle.await; + } + + let _success = *success_count + .lock() + .await; + let fail = *final_fail_count + .lock() + .await; + let failed_list = Arc::try_unwrap(failed_jobs) + .unwrap() + .into_inner(); + + let mut progress = Arc::try_unwrap(progress_shared) + .unwrap() + .into_inner(); + progress.total_downloaded = progress.get_completed_count(); + progress.successful = progress.get_completed_count(); + + progress.save_to_file(&progress_file)?; + + let final_concurrent = download_adaptive + .lock() + .await + .get_concurrent(); + + progress_mgr.finish(); + + println!(); + println!( + "{}", + "═" + .repeat(60) + .bright_blue() + ); + println!( + "{}", + "Download Summary" + .cyan() + .bold() + ); + println!( + "{}", + "═" + .repeat(60) + .bright_blue() + ); + println!( + " {} {}", + "Total tracks:".dimmed(), + total + .to_string() + .white() + ); + println!( + " {} {}", + "Successfully matched:".green(), + progress + .total_matched + .to_string() + .green() + ); + + let failed_matches_count = progress + .failed_matches + .len(); + if failed_matches_count > 0 { + println!( + " {} {}", + "Failed matches:".red(), + failed_matches_count + .to_string() + .red() + ); + for fm in &progress.failed_matches { + println!( + " {} {}", + "•".red(), + fm.track_name + .white() + ); + } + } + + println!( + " {} {}", + "Successfully downloaded:".green(), + progress + .get_completed_count() + .to_string() + .green() + ); + + if fail > 0 { + println!( + " {} {}", + "Failed downloads:".red(), + fail.to_string() + .red() + ); + for (job, error) in &failed_list { + println!( + " {} {} - {}", + "•".red(), + job.track + .title + .white(), + error.dimmed() + ); + } + } + + println!( + " {} {}", + "Final concurrency:".dimmed(), + final_concurrent + .to_string() + .yellow() + ); + println!( + "{}", + "═" + .repeat(60) + .bright_blue() + ); + println!( + "{} {}", + "Progress saved to:".dimmed(), + progress_file + .display() + .to_string() + .white() + ); + + Ok(()) +} + +fn clean_track_name(name: &str) -> String { + let cleaned: String = name + .chars() + .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-') + .collect(); + let result = cleaned + .split_whitespace() + .collect::<Vec<_>>() + .join(" "); + + let removed_brackets = Regex::new(r"\s*[\(\[][^)\]]*[\)\]]\s*").unwrap(); + removed_brackets + .replace_all( + &result, " ", + ) + .trim() + .to_string() +} + +async fn retry_download<F, Fut, T>(f: F, adaptive: Arc<Mutex<AdaptiveConcurrency>>) -> Result<T> +where + F: Fn() -> Fut, + Fut: std::future::Future<Output = Result<T>>, +{ + 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), + ]; + + let mut last_error = None; + + for attempt in 0..MAX_RETRIES { + match f().await { + Ok(result) => return Ok(result), + Err(e) => { + let should_retry = matches!( + e, + DownloadError::RateLimited | DownloadError::NetworkError(_) | DownloadError::DownloadFailed(_) | DownloadError::SegmentDownloadFailed(_) | DownloadError::ServiceUnavailable(_) + ); + + if !should_retry || attempt == MAX_RETRIES - 1 { + return Err(e); + } + + if should_retry { + adaptive + .lock() + .await + .on_failure(); + } + + last_error = Some(e); + tokio::time::sleep(BACKOFF_DELAYS[attempt]).await; + } + } + } + + Err(last_error.unwrap_or(DownloadError::DownloadFailed("Max retries exceeded".to_string()))) +} diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..8822bdb --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,262 @@ +use crate::{error::Result, types::TrackInfo}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchedTrack { + #[serde(rename = "trackId")] + pub track_id: u64, + #[serde(rename = "trackTitle")] + pub track_title: String, + #[serde(rename = "trackArtist")] + pub track_artist: String, + pub album: Option<String>, + pub confidence: f64, + #[serde(rename = "csvIndex")] + pub csv_index: usize, + #[serde(rename = "csvTrackName")] + pub csv_track_name: String, + #[serde(rename = "csvArtistName")] + pub csv_artist_name: String, + #[serde(rename = "csvAlbum")] + pub csv_album: Option<String>, + #[serde(rename = "playlistName")] + pub playlist_name: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FailedMatch { + #[serde(rename = "trackName")] + pub track_name: String, + pub artist: Option<String>, + pub album: Option<String>, + #[serde(rename = "searchAttempts")] + pub search_attempts: Vec<String>, + pub attempts: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CsvTrackInfo { + #[serde(rename = "trackName")] + pub track_name: String, + pub artist: Option<String>, + pub album: Option<String>, + pub isrc: Option<String>, + #[serde(rename = "spotifyId")] + pub spotify_id: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FailedDownload { + #[serde(rename = "trackId")] + pub track_id: u64, + #[serde(rename = "trackTitle")] + pub track_title: String, + #[serde(rename = "trackArtist")] + pub track_artist: String, + pub album: Option<String>, + pub error: String, + pub attempts: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadProgress { + pub source: String, + pub total: usize, + pub successful: usize, + pub failed: Vec<FailedDownload>, + #[serde(rename = "startedAt")] + pub started_at: String, + #[serde(rename = "completedAt")] + pub completed_at: Option<String>, + #[serde(rename = "failedMatches")] + pub failed_matches: Vec<FailedMatch>, + #[serde(rename = "totalMatched")] + pub total_matched: usize, + #[serde(rename = "totalDownloaded")] + pub total_downloaded: usize, + #[serde(rename = "completedTrackIds")] + pub completed_track_ids: Vec<u64>, + #[serde(rename = "matchedCsvIndices")] + pub matched_csv_indices: Vec<usize>, + #[serde(rename = "matchedTracks")] + pub matched_tracks: Vec<MatchedTrack>, +} + +impl DownloadProgress { + pub fn new(source: &str, total: usize) -> Self { + Self { + source: source.to_string(), + total, + successful: 0, + failed: Vec::new(), + started_at: Utc::now().to_rfc3339(), + completed_at: None, + failed_matches: Vec::new(), + total_matched: 0, + total_downloaded: 0, + completed_track_ids: Vec::new(), + matched_csv_indices: Vec::new(), + matched_tracks: Vec::new(), + } + } + + pub fn mark_completed(&mut self, track_id: u64) { + if !self + .completed_track_ids + .contains(&track_id) + { + self.completed_track_ids + .push(track_id); + } + } + + pub fn is_completed(&self, track_id: u64) -> bool { + self.completed_track_ids + .contains(&track_id) + } + + pub fn get_completed_count(&self) -> usize { + self.completed_track_ids + .len() + } + + pub fn mark_csv_matched(&mut self, index: usize) { + if !self + .matched_csv_indices + .contains(&index) + { + self.matched_csv_indices + .push(index); + } + } + + pub fn is_csv_matched(&self, index: usize) -> bool { + self.matched_csv_indices + .contains(&index) + } + + pub fn add_matched_track(&mut self, csv_index: usize, track: &TrackInfo, confidence: f64, csv_track_name: &str, csv_artist: &str, csv_album: Option<&str>, playlist: Option<&str>) { + self.matched_tracks + .push( + MatchedTrack { + track_id: track.id, + track_title: track + .title + .clone(), + track_artist: track + .artist + .clone(), + album: track + .album + .clone(), + confidence, + csv_index, + csv_track_name: csv_track_name.to_string(), + csv_artist_name: csv_artist.to_string(), + csv_album: csv_album.map(|s| s.to_string()), + playlist_name: playlist.map(|s| s.to_string()), + }, + ); + self.mark_csv_matched(csv_index); + } + + pub fn get_matched_track(&self, csv_index: usize) -> Option<&MatchedTrack> { + self.matched_tracks + .iter() + .find(|m| m.csv_index == csv_index) + } + + pub fn add_success(&mut self) { + self.successful += 1; + } + + pub fn add_failure(&mut self, track: &TrackInfo, album: Option<&str>, error: String) { + self.failed + .push( + FailedDownload { + track_id: track.id, + track_title: track + .title + .clone(), + track_artist: track + .artist + .clone(), + album: album.map(|s| s.to_string()), + error, + attempts: 1, + }, + ); + } + + pub fn add_failed_match(&mut self, track_name: &str, artist: Option<&str>, album: Option<&str>) { + if let Some(existing) = self + .failed_matches + .iter_mut() + .find( + |m| { + m.track_name == track_name + && m.artist + .as_deref() + == artist + && m.album + .as_deref() + == album + }, + ) + { + existing.attempts += 1; + } else { + self.failed_matches + .push( + FailedMatch { + track_name: track_name.to_string(), + artist: artist.map(|s| s.to_string()), + album: album.map(|s| s.to_string()), + search_attempts: Vec::new(), + attempts: 1, + }, + ); + } + } + + pub fn get_failed_matches(&self) -> &[FailedMatch] { + &self.failed_matches + } + + pub fn total_failed(&self) -> usize { + self.failed_matches + .len() + + self + .failed + .len() + } + + pub fn save_to_file(&self, path: &Path) -> Result<()> { + let json = serde_json::to_string_pretty(self)?; + std::fs::write( + path, json, + )?; + Ok(()) + } + + pub fn load_from_file(path: &Path) -> Result<Self> { + let content = std::fs::read_to_string(path)?; + let progress: Self = serde_json::from_str(&content)?; + Ok(progress) + } + + pub fn has_failures(&self) -> bool { + !self + .failed + .is_empty() + } + + pub fn retry_count(&self) -> usize { + self.failed + .iter() + .map(|f| f.attempts) + .sum() + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..4691028 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,21 @@ +pub mod tidal; + +use crate::{error::Result, types::*}; +use async_trait::async_trait; + +#[async_trait] +pub trait MusicService: Send + Sync { + async fn search(&self, query: &str, artist: Option<&str>) -> Result<SearchResults>; + + async fn get_track_info(&self, track_id: u64) -> Result<TrackInfo>; + + async fn get_album_info(&self, album_id: u64) -> Result<AlbumInfo>; + + async fn get_playlist_info(&self, playlist_id: u64) -> Result<PlaylistInfo>; + + async fn get_album_tracks(&self, album_id: u64) -> Result<Vec<TrackInfo>>; + + async fn get_playlist_tracks(&self, playlist_id: u64) -> Result<Vec<TrackInfo>>; + + async fn get_manifest(&self, track_id: u64, quality: AudioQuality) -> Result<DownloadManifest>; +} diff --git a/src/services/tidal.rs b/src/services/tidal.rs new file mode 100644 index 0000000..a3c6781 --- /dev/null +++ b/src/services/tidal.rs @@ -0,0 +1,524 @@ +use crate::{ + error::{DownloadError, Result}, + services::MusicService, + types::*, +}; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use regex::Regex; +use reqwest::Client; +use serde::Deserialize; + +const API_BASE: &str = "https://triton.squid.wtf"; +const FALLBACK_APIS: &[&str] = &[ + "https://aether.squid.wtf", + "https://zeus.squid.wtf", +]; + +#[derive(Debug, Deserialize)] +struct ApiResponse<T> { + data: T, +} + +#[derive(Debug, Deserialize)] +struct SearchResponse { + items: Vec<TidalTrack>, +} + +#[derive(Debug, Deserialize)] +struct TidalTrack { + id: u64, + title: String, + #[serde(default)] + artist: Option<TidalArtist>, + #[serde(default)] + album: Option<TidalAlbum>, + #[serde(default)] + duration: Option<u32>, + #[serde( + rename = "audioQuality", + default + )] + audio_quality: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct TidalArtist { + name: String, +} + +#[derive(Debug, Deserialize)] +struct TidalAlbum { + title: String, +} + +#[derive(Debug, Deserialize)] +struct TrackInfoData { + id: u64, + title: String, + #[serde(default)] + artist: Option<TidalArtist>, + #[serde(default)] + album: Option<TidalAlbum>, + #[serde(default)] + duration: Option<u32>, + #[serde( + rename = "audioQuality", + default + )] + audio_quality: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct TrackManifestData { + #[serde(default)] + manifest: Option<String>, + #[serde( + rename = "bitDepth", + default + )] + bit_depth: Option<u8>, + #[serde( + rename = "sampleRate", + default + )] + sample_rate: Option<u32>, +} + +#[derive(Debug, Deserialize)] +struct AlbumData { + id: u64, + title: String, + #[serde(default)] + artist: Option<TidalArtist>, + #[serde( + rename = "numberOfTracks", + default + )] + number_of_tracks: Option<u32>, +} + +#[derive(Debug, Deserialize)] +struct PlaylistData { + id: u64, + title: String, + #[serde(default)] + creator: Option<TidalCreator>, + #[serde( + rename = "numberOfTracks", + default + )] + number_of_tracks: Option<u32>, +} + +#[derive(Debug, Deserialize)] +struct TidalCreator { + name: String, +} + +#[derive(Debug, Deserialize)] +struct ManifestJson { + #[serde(default)] + urls: Vec<String>, +} + +#[derive(Clone)] +pub struct TidalService { + client: Client, + api_index: usize, +} + +impl TidalService { + pub fn new() -> Self { + TidalService { + client: Client::builder() + .user_agent("SquidDownloader/0.1.0") + .build() + .unwrap(), + api_index: 0, + } + } + + fn get_api_base(&self) -> &str { + if self.api_index == 0 { + API_BASE + } else { + FALLBACK_APIS[self.api_index - 1] + } + } + + fn parse_quality(quality_str: &str) -> AudioQuality { + match quality_str + .to_uppercase() + .as_str() + { + "HI_RES_LOSSLESS" | "HI_RES" => AudioQuality::HiResLossless, + "LOSSLESS" => AudioQuality::Lossless, + "HIGH" => AudioQuality::High, + "LOW" => AudioQuality::Low, + _ => AudioQuality::Auto, + } + } + + fn map_track_to_info(track: TidalTrack) -> TrackInfo { + TrackInfo { + id: track.id, + title: track.title, + artist: track + .artist + .map(|a| a.name) + .unwrap_or_default(), + album: track + .album + .map(|a| a.title), + duration: track.duration, + quality: track + .audio_quality + .map(|q| Self::parse_quality(&q)), + cover_url: None, + } + } + + fn parse_mpd_manifest(mpd_xml: &str) -> Result<Vec<String>> { + let mut segment_urls = Vec::new(); + let mut base_url: Option<String> = None; + + let re = Regex::new(r"<BaseURL>([^<]+)</BaseURL>").unwrap(); + if let Some(caps) = re.captures(mpd_xml) { + base_url = Some(caps[1].to_string()); + } + + let segment_re = Regex::new(r#"media="([^"]+)""#).unwrap(); + for caps in segment_re.captures_iter(mpd_xml) { + let segment = caps[1].to_string(); + if let Some(ref base) = base_url { + let full_url = if base.ends_with('/') { + format!( + "{}{}", + base, segment + ) + } else { + format!( + "{}/{}", + base, segment + ) + }; + segment_urls.push(full_url); + } else { + segment_urls.push(segment); + } + } + + if segment_urls.is_empty() { + let seg_pattern = r#"<SegmentURL[^>]*media="([^"]+)""#; + let seg_re = Regex::new(seg_pattern).unwrap(); + for caps in seg_re.captures_iter(mpd_xml) { + segment_urls.push(caps[1].to_string()); + } + } + + if segment_urls.is_empty() { + return Err(DownloadError::XmlParseError("Could not parse segment URLs from MPD manifest".to_string())); + } + + Ok(segment_urls) + } + + async fn make_request<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T> { + let url = format!( + "{}{}", + self.get_api_base(), + endpoint + ); + + let response = self + .client + .get(&url) + .send() + .await?; + + if response.status() == 429 { + return Err(DownloadError::RateLimited); + } + + let status = response.status(); + if !status.is_success() { + return Err( + DownloadError::ServiceUnavailable( + format!( + "API returned status {}", + status + ), + ), + ); + } + + let text = response + .text() + .await?; + + serde_json::from_str(&text).map_err(|e| DownloadError::JsonParseError(e)) + } +} + +impl Default for TidalService { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl MusicService for TidalService { + async fn search(&self, query: &str, artist: Option<&str>) -> Result<SearchResults> { + let search_query = match artist { + Some(a) => format!( + "{} {}", + query, a + ), + None => query.to_string(), + }; + + let encoded = urlencoding::encode(&search_query); + let endpoint = format!( + "/search/?s={}", + encoded + ); + + let response: ApiResponse<SearchResponse> = self + .make_request(&endpoint) + .await?; + + let tracks: Vec<TrackInfo> = response + .data + .items + .into_iter() + .map(Self::map_track_to_info) + .collect(); + + let filtered_tracks = if let Some(artist_name) = artist { + tracks + .into_iter() + .filter( + |t| { + strsim::jaro_winkler( + t.artist + .to_lowercase() + .as_str(), + artist_name + .to_lowercase() + .as_str(), + ) > 0.7 + }, + ) + .collect() + } else { + tracks + }; + + Ok( + SearchResults { + tracks: filtered_tracks, + }, + ) + } + + async fn get_track_info(&self, track_id: u64) -> Result<TrackInfo> { + let endpoint = format!( + "/info/?id={}", + track_id + ); + let response: ApiResponse<TrackInfoData> = self + .make_request(&endpoint) + .await?; + + Ok( + TrackInfo { + id: response + .data + .id, + title: response + .data + .title, + artist: response + .data + .artist + .map(|a| a.name) + .unwrap_or_default(), + album: response + .data + .album + .map(|a| a.title), + duration: response + .data + .duration, + quality: response + .data + .audio_quality + .map(|q| Self::parse_quality(&q)), + cover_url: None, + }, + ) + } + + async fn get_album_info(&self, album_id: u64) -> Result<AlbumInfo> { + let endpoint = format!( + "/album/?id={}", + album_id + ); + let response: ApiResponse<AlbumData> = self + .make_request(&endpoint) + .await?; + + Ok( + AlbumInfo { + id: response + .data + .id, + title: response + .data + .title, + artist: response + .data + .artist + .map(|a| a.name) + .unwrap_or_default(), + track_count: response + .data + .number_of_tracks + .unwrap_or(0), + year: None, + cover_url: None, + }, + ) + } + + async fn get_playlist_info(&self, playlist_id: u64) -> Result<PlaylistInfo> { + let endpoint = format!( + "/playlist/?id={}", + playlist_id + ); + let response: ApiResponse<PlaylistData> = self + .make_request(&endpoint) + .await?; + + Ok( + PlaylistInfo { + id: response + .data + .id, + title: response + .data + .title, + creator: response + .data + .creator + .map(|c| c.name) + .unwrap_or_default(), + track_count: response + .data + .number_of_tracks + .unwrap_or(0), + cover_url: None, + }, + ) + } + + async fn get_album_tracks(&self, album_id: u64) -> Result<Vec<TrackInfo>> { + let endpoint = format!( + "/album/tracks/?id={}", + album_id + ); + let response: ApiResponse<SearchResponse> = self + .make_request(&endpoint) + .await?; + + Ok( + response + .data + .items + .into_iter() + .map(Self::map_track_to_info) + .collect(), + ) + } + + async fn get_playlist_tracks(&self, playlist_id: u64) -> Result<Vec<TrackInfo>> { + let endpoint = format!( + "/playlist/tracks/?id={}", + playlist_id + ); + let response: ApiResponse<SearchResponse> = self + .make_request(&endpoint) + .await?; + + Ok( + response + .data + .items + .into_iter() + .map(Self::map_track_to_info) + .collect(), + ) + } + + async fn get_manifest(&self, track_id: u64, quality: AudioQuality) -> Result<DownloadManifest> { + let endpoint = format!( + "/track/?id={}&quality={}", + track_id, + quality.as_str() + ); + let response: ApiResponse<TrackManifestData> = self + .make_request(&endpoint) + .await?; + + let manifest_b64 = response + .data + .manifest + .ok_or(DownloadError::NoDownloadUrl)?; + let manifest_decoded = BASE64.decode(&manifest_b64)?; + let manifest_str = String::from_utf8_lossy(&manifest_decoded); + + let bit_depth = response + .data + .bit_depth; + let sample_rate = response + .data + .sample_rate; + + if manifest_str.contains("application/dash+xml") || manifest_str.contains("<MPD") { + let segment_urls = Self::parse_mpd_manifest(&manifest_str)?; + Ok( + DownloadManifest { + url: None, + segment_urls: Some(segment_urls), + quality, + bit_depth, + sample_rate, + }, + ) + } else { + let manifest_data: ManifestJson = serde_json::from_str(&manifest_str)?; + let url = manifest_data + .urls + .into_iter() + .next() + .ok_or(DownloadError::NoDownloadUrl)?; + + Ok( + DownloadManifest { + url: Some(url), + segment_urls: None, + quality, + bit_depth, + sample_rate, + }, + ) + } + } +} + +mod urlencoding { + pub fn encode(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..5b26c7d --- /dev/null +++ b/src/types.rs @@ -0,0 +1,224 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum AudioQuality { + #[serde(rename = "HI_RES_LOSSLESS")] + HiResLossless, + #[serde(rename = "LOSSLESS")] + Lossless, + #[serde(rename = "HIGH")] + High, + #[serde(rename = "LOW")] + Low, + #[serde(rename = "MP3_320")] + Mp3_320, + #[default] + #[serde(rename = "AUTO")] + Auto, +} + +impl AudioQuality { + pub fn as_str(&self) -> &'static str { + match self { + AudioQuality::HiResLossless => "HI_RES_LOSSLESS", + AudioQuality::Lossless => "LOSSLESS", + AudioQuality::High => "HIGH", + AudioQuality::Low => "LOW", + AudioQuality::Mp3_320 => "MP3_320", + AudioQuality::Auto => "AUTO", + } + } + + pub fn description(&self) -> &'static str { + match self { + AudioQuality::HiResLossless => "24-bit/192kHz FLAC (Hi-Res)", + AudioQuality::Lossless => "16-bit/44.1kHz FLAC (CD Quality)", + AudioQuality::High => "320kbps AAC", + AudioQuality::Low => "96kbps AAC", + AudioQuality::Mp3_320 => "320kbps MP3", + AudioQuality::Auto => "Best available", + } + } + + pub fn file_extension(&self) -> &'static str { + match self { + AudioQuality::HiResLossless | AudioQuality::Lossless => "flac", + AudioQuality::High | AudioQuality::Low => "m4a", + AudioQuality::Mp3_320 => "mp3", + AudioQuality::Auto => "flac", + } + } + + pub fn all() -> Vec<Self> { + vec![ + AudioQuality::HiResLossless, + AudioQuality::Lossless, + AudioQuality::High, + AudioQuality::Low, + ] + } +} + +impl std::fmt::Display for AudioQuality { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.description() + ) + } +} + +impl std::str::FromStr for AudioQuality { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s + .to_uppercase() + .as_str() + { + "HI_RES_LOSSLESS" | "HIRES" | "HI-RES" | "MASTER" => Ok(AudioQuality::HiResLossless), + "LOSSLESS" | "FLAC" | "CD" => Ok(AudioQuality::Lossless), + "HIGH" | "AAC320" | "320" => Ok(AudioQuality::High), + "LOW" | "AAC96" | "96" => Ok(AudioQuality::Low), + "MP3_320" | "MP3" => Ok(AudioQuality::Mp3_320), + "AUTO" => Ok(AudioQuality::Auto), + _ => Err( + format!( + "Unknown quality: {}", + s + ), + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Service { + Tidal, + AmazonMusic, + SoundCloud, + KHInsider, +} + +impl Service { + pub fn as_str(&self) -> &'static str { + match self { + Service::Tidal => "tidal", + Service::AmazonMusic => "amazon", + Service::SoundCloud => "soundcloud", + Service::KHInsider => "khinsider", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Service::Tidal => "Tidal", + Service::AmazonMusic => "Amazon Music", + Service::SoundCloud => "SoundCloud", + Service::KHInsider => "KHInsider", + } + } + + pub fn all() -> Vec<Self> { + vec![ + Service::Tidal, + Service::AmazonMusic, + Service::SoundCloud, + Service::KHInsider, + ] + } +} + +impl std::fmt::Display for Service { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.display_name() + ) + } +} + +impl std::str::FromStr for Service { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s + .to_lowercase() + .as_str() + { + "tidal" | "tdl" => Ok(Service::Tidal), + "amazon" | "amazonmusic" | "amz" => Ok(Service::AmazonMusic), + "soundcloud" | "sc" => Ok(Service::SoundCloud), + "khinsider" | "khi" | "kh" => Ok(Service::KHInsider), + _ => Err( + format!( + "Unknown service: {}", + s + ), + ), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackInfo { + pub id: u64, + pub title: String, + pub artist: String, + pub album: Option<String>, + pub duration: Option<u32>, + pub quality: Option<AudioQuality>, + pub cover_url: Option<String>, +} + +impl TrackInfo { + pub fn format_duration(&self) -> String { + self.duration + .map( + |d| { + let mins = d / 60; + let secs = d % 60; + format!( + "{:02}:{:02}", + mins, secs + ) + }, + ) + .unwrap_or_else(|| "--:--".to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlbumInfo { + pub id: u64, + pub title: String, + pub artist: String, + pub track_count: u32, + pub year: Option<u32>, + pub cover_url: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaylistInfo { + pub id: u64, + pub title: String, + pub creator: String, + pub track_count: u32, + pub cover_url: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResults { + pub tracks: Vec<TrackInfo>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadManifest { + pub url: Option<String>, + pub segment_urls: Option<Vec<String>>, + pub quality: AudioQuality, + pub bit_depth: Option<u8>, + pub sample_rate: Option<u32>, +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..8fc2c90 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,384 @@ +use crate::types::*; +use colored::Colorize; +use std::io::{self, Write}; +use tabled::{ + settings::{object::Rows, Alignment, Modify, Style}, + Table, Tabled, +}; + +#[derive(Tabled)] +struct TrackRow { + #[tabled(rename = "#")] + index: usize, + #[tabled(rename = "Title")] + title: String, + #[tabled(rename = "Artist")] + artist: String, + #[tabled(rename = "Album")] + album: String, + #[tabled(rename = "Duration")] + duration: String, +} + +pub struct Ui; + +impl Ui { + pub fn print_banner() { + println!(); + println!( + "{}", + r#" + ██████ ██▓███ ▄▄▄ █ █░ ▄▄▄ ███▄ █ +▒██ ▒ ▓██░ ██▒▒████▄ ▓█░ █ ░█░▒████▄ ██ ▀█ █ +░ ▓██▄ ▓██░ ██▓▒▒██ ▀█▄ ▒█░ █ ░█ ▒██ ▀█▄ ▓██ ▀█ ██▒ + ▒ ██▒▒██▄█▓▒ ▒░██▄▄▄▄██ ░█░ █ ░█ ░██▄▄▄▄██ ▓██▒ ▐▌██▒ +▒██████▒▒▒██▒ ░ ░ ▓█ ▓██▒░░██▒██▓ ▓█ ▓██▒▒██░ ▓██░ +▒ ▒▓▒ ▒ ░▒▓▒░ ░ ░ ▒▒ ▓▒█░░ ▓░▒ ▒ ▒▒ ▓▒█░░ ▒░ ▒ ▒ +░ ░▒ ░ ░░▒ ░ ▒ ▒▒ ░ ▒ ░ ░ ▒ ▒▒ ░░ ░░ ░ ▒░ +░ ░ ░ ░░ ░ ▒ ░ ░ ░ ▒ ░ ░ ░ + ░ ░ ░ ░ ░ ░ ░ +"# + .bright_cyan() + ); + println!( + " {} {} • {} {}", + "High-quality music downloader".white(), + "v0.1.0".yellow(), + "Supports:".white(), + "Tidal".green() + ); + println!(); + } + + pub fn print_info(message: &str) { + println!( + "{} {}", + "ℹ" + .blue() + .bold(), + message.white() + ); + } + + pub fn print_success(message: &str) { + println!( + "{} {}", + "✓" + .green() + .bold(), + message.white() + ); + } + + pub fn print_warning(message: &str) { + println!( + "{} {}", + "!".yellow() + .bold(), + message.white() + ); + } + + pub fn print_error(message: &str) { + eprintln!( + "{} {}", + "✗" + .red() + .bold(), + message.white() + ); + } + + pub fn print_search_results(tracks: &[TrackInfo], limit: usize) { + if tracks.is_empty() { + Self::print_warning("No results found"); + return; + } + + let rows: Vec<TrackRow> = tracks + .iter() + .take(limit) + .enumerate() + .map( + |(i, t)| TrackRow { + index: i + 1, + title: t + .title + .clone(), + artist: t + .artist + .clone(), + album: t + .album + .clone() + .unwrap_or_else(|| "-".to_string()), + duration: t.format_duration(), + }, + ) + .collect(); + + let mut table = Table::new(rows); + table + .with(Style::rounded()) + .with(Modify::new(Rows::new(1..)).with(Alignment::left())); + + println!(); + println!( + "{}", + table + ); + println!(); + } + + pub fn print_track_detail(track: &TrackInfo, index: Option<usize>) { + let prefix = index + .map( + |i| { + format!( + "{}.", + i + ) + }, + ) + .unwrap_or_default(); + + println!( + "{} {}", + prefix.cyan(), + track + .title + .white() + .bold() + ); + println!( + " {} {}", + "Artist:".dimmed(), + track + .artist + .white() + ); + if let Some(ref album) = track.album { + println!( + " {} {}", + "Album:".dimmed(), + album.white() + ); + } + println!( + " {} {}", + "Duration:".dimmed(), + track + .format_duration() + .white() + ); + if let Some(ref quality) = track.quality { + println!( + " {} {}", + "Quality:".dimmed(), + quality + .to_string() + .green() + ); + } + println!( + " {} {}", + "ID:".dimmed(), + track + .id + .to_string() + .yellow() + ); + } + + pub fn print_album_detail(album: &AlbumInfo) { + println!(); + println!( + "{}", + album + .title + .white() + .bold() + ); + println!( + " {} {}", + "Artist:".cyan(), + album + .artist + .white() + ); + println!( + " {} {} tracks", + "Tracks:".cyan(), + album + .track_count + .to_string() + .white() + ); + if let Some(ref year) = album.year { + println!( + " {} {}", + "Year:".cyan(), + year.to_string() + .white() + ); + } + println!(); + } + + pub fn print_playlist_detail(playlist: &PlaylistInfo) { + println!(); + println!( + "{}", + playlist + .title + .white() + .bold() + ); + println!( + " {} {}", + "Creator:".cyan(), + playlist + .creator + .white() + ); + println!( + " {} {} tracks", + "Tracks:".cyan(), + playlist + .track_count + .to_string() + .white() + ); + println!(); + } + + pub fn print_qualities() { + println!(); + println!( + "{}", + "Available Audio Qualities:" + .cyan() + .bold() + ); + println!(); + + for quality in AudioQuality::all() { + println!( + " {} {}", + format!( + "{:15}", + quality.as_str() + ) + .yellow(), + quality + .description() + .white() + ); + } + println!(); + } + + pub fn print_services() { + println!(); + println!( + "{}", + "Available Services:" + .cyan() + .bold() + ); + println!(); + + println!( + " {} {}", + format!( + "{:12}", + "tidal" + ) + .yellow(), + "Tidal (Hi-Res FLAC)".white() + ); + println!(); + } + + pub fn select_track(tracks: &[TrackInfo]) -> Option<usize> { + if tracks.is_empty() { + Self::print_warning("No tracks to select from"); + return None; + } + + if tracks.len() == 1 { + return Some(0); + } + + Self::print_search_results( + tracks, + tracks.len(), + ); + print!( + "{} {}", + "?".green() + .bold(), + "Select track (1-".white(), + ); + print!( + "{}): ", + tracks + .len() + .to_string() + .white() + ); + + io::stdout() + .flush() + .ok()?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .ok()?; + + let selection: usize = input + .trim() + .parse() + .ok()?; + + if selection == 0 || selection > tracks.len() { + Self::print_error( + &format!( + "Invalid selection: {}", + selection + ), + ); + return None; + } + + Some(selection - 1) + } + + pub fn confirm(message: &str) -> bool { + print!( + "{} {} [y/N]: ", + "?".green() + .bold(), + message + ); + io::stdout() + .flush() + .ok() + .unwrap_or(()); + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .ok() + .unwrap_or_default(); + + matches!( + input + .trim() + .to_lowercase() + .as_str(), + "y" | "yes" + ) + } +} |
