summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/adaptive.rs72
-rw-r--r--src/cli.rs149
-rw-r--r--src/csv.rs391
-rw-r--r--src/download.rs584
-rw-r--r--src/error.rs111
-rw-r--r--src/main.rs2330
-rw-r--r--src/progress.rs262
-rw-r--r--src/services/mod.rs21
-rw-r--r--src/services/tidal.rs524
-rw-r--r--src/types.rs224
-rw-r--r--src/ui.rs384
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"
+ )
+ }
+}