From c523a3a18c2b2622dfdcae086e43bf13bc9c6b89 Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Sun, 7 Nov 2021 03:22:22 +0100 Subject: [PATCH] Add search function and code quality improvements --- Cargo.lock | 2 +- Cargo.toml | 4 +- README.md | 3 +- src/downloader.rs | 1444 +++++++++++++++++++++++---------------------- src/main.rs | 156 +++-- src/settings.rs | 23 +- src/spotify.rs | 325 +++++----- src/tag/mod.rs | 10 +- 8 files changed, 1026 insertions(+), 941 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5d929c..3719f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "down_on_spot" -version = "0.0.1" +version = "0.1.1" dependencies = [ "aspotify", "async-std", diff --git a/Cargo.toml b/Cargo.toml index f4a9d56..7307ebc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ panic = "abort" [package] name = "down_on_spot" -version = "0.0.1" +version = "0.1.1" edition = "2018" authors = ["exttex", "oSumAtrIX"] build = "build.rs" @@ -44,4 +44,4 @@ tokio = { version = "1.12", features = ["fs"] } OriginalFilename = "DownOnSpot.exe" FileDescription = "Download songs from Spotify with Rust" ProductName = "DownOnSpot" -ProductVersion = "0.0.1" \ No newline at end of file +ProductVersion = "0.1.1" \ No newline at end of file diff --git a/README.md b/README.md index 2297fbf..a19861b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ I am not responsible in any way for the usage of the source code. - Works with free Spotify accounts (if using free-librespot fork) - Download 96, 160kbit/s audio with a free, 256 and 320 kbit/s audio with a premium account from Spotify, directly - Multi-threaded +- Search for tracks - Download tracks, playlists, albums and artists - Convert to mp3 - Metadata tagging @@ -76,7 +77,7 @@ Settings could not be loaded, because of the following error: IO: NotFound No su $ down_on_spot.exe Usage: -down_on_spot.exe (track_url | album_url | playlist_url | artist_url) +down_on_spot.exe (search_term | track_url | album_url | playlist_url | artist_url) ``` ### Template variables diff --git a/src/downloader.rs b/src/downloader.rs index 0baa406..ad744e6 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -23,798 +23,852 @@ use crate::tag::{Field, TagWrap}; /// Wrapper for use with UI #[derive(Debug, Clone)] pub struct Downloader { - rx: Receiver, - tx: Sender, + rx: Receiver, + tx: Sender, - spotify: Spotify, + spotify: Spotify, } impl Downloader { - /// Create new instance - pub fn new(config: DownloaderConfig, spotify: Spotify) -> Downloader { - let (tx_0, rx_0) = bounded(1); - let (tx_1, rx_1) = bounded(1); + /// Create new instance + pub fn new(config: DownloaderConfig, spotify: Spotify) -> Downloader { + let (tx_0, rx_0) = bounded(1); + let (tx_1, rx_1) = bounded(1); - let tx_clone = tx_1.clone(); - let spotify_clone = spotify.clone(); - tokio::spawn(async move { - communication_thread(config, spotify_clone, rx_1, tx_0, tx_clone).await - }); - Downloader { - rx: rx_0, - tx: tx_1, - spotify, - } - } - /// Add item to download queue - pub async fn add_to_queue(&self, download: Download) { - self.tx - .send(Message::AddToQueue(vec![download])) - .await - .unwrap(); - } + let tx_clone = tx_1.clone(); + let spotify_clone = spotify.clone(); + tokio::spawn(async move { + communication_thread(config, spotify_clone, rx_1, tx_0, tx_clone).await + }); + Downloader { + rx: rx_0, + tx: tx_1, + spotify, + } + } + /// Add item to download queue + pub async fn add_to_queue(&self, download: Download) { + self.tx + .send(Message::AddToQueue(vec![download])) + .await + .unwrap(); + } - /// Add multiple items to queue - pub async fn add_to_queue_multiple(&self, downloads: Vec) { - self.tx.send(Message::AddToQueue(downloads)).await.unwrap(); - } + /// Add multiple items to queue + pub async fn add_to_queue_multiple(&self, downloads: Vec) { + self.tx.send(Message::AddToQueue(downloads)).await.unwrap(); + } - /// Add URL or URI to queue - pub async fn add_uri(&self, uri: &str) -> Result<(), SpotifyError> { - let uri = Spotify::parse_uri(uri)?; - let item = self.spotify.resolve_uri(&uri).await?; - match item { - SpotifyItem::Track(t) => self.add_to_queue(t.into()).await, - SpotifyItem::Album(a) => { - let tracks = self.spotify.full_album(&a.id).await?; - let queue: Vec = tracks.into_iter().map(|t| t.into()).collect(); - self.add_to_queue_multiple(queue).await; - } - SpotifyItem::Playlist(p) => { - let tracks = self.spotify.full_playlist(&p.id).await?; - let queue: Vec = tracks.into_iter().map(|t| t.into()).collect(); - self.add_to_queue_multiple(queue).await; - } - SpotifyItem::Artist(a) => { - let tracks = self.spotify.full_artist(&a.id).await?; - let queue: Vec = tracks.into_iter().map(|t| t.into()).collect(); - self.add_to_queue_multiple(queue).await; - } + /// handle input, either link or search + pub async fn handle_input( + &self, + input: &str, + ) -> Result>, SpotifyError> { + if let Ok(uri) = Spotify::parse_uri(input) { + if let Err(e) = self.add_uri(&uri).await { + return Err(e); + } + Ok(None) + } else { + let results: Vec = self + .spotify + .search(input) + .await? + .into_iter() + .map(|item| SearchResult::from(item)) + .collect(); + Ok(Some(results)) + } + } - // Unsupported - SpotifyItem::Other(u) => { - error!("Unsupported URI: {}", u); - return Err(SpotifyError::Unavailable); - } - }; - Ok(()) - } + /// Add URL or URI to queue + pub async fn add_uri(&self, uri: &str) -> Result<(), SpotifyError> { + let uri = Spotify::parse_uri(uri)?; + let item = self.spotify.resolve_uri(&uri).await?; + match item { + SpotifyItem::Track(t) => self.add_to_queue(t.into()).await, + SpotifyItem::Album(a) => { + let tracks = self.spotify.full_album(&a.id).await?; + let queue: Vec = tracks.into_iter().map(|t| t.into()).collect(); + self.add_to_queue_multiple(queue).await; + } + SpotifyItem::Playlist(p) => { + let tracks = self.spotify.full_playlist(&p.id).await?; + let queue: Vec = tracks.into_iter().map(|t| t.into()).collect(); + self.add_to_queue_multiple(queue).await; + } + SpotifyItem::Artist(a) => { + let tracks = self.spotify.full_artist(&a.id).await?; + let queue: Vec = tracks.into_iter().map(|t| t.into()).collect(); + self.add_to_queue_multiple(queue).await; + } - /// Get all downloads - pub async fn get_downloads(&self) -> Vec { - self.tx.send(Message::GetDownloads).await.unwrap(); - let Response::Downloads(d) = self.rx.recv().await.unwrap(); - d - } + // Unsupported + SpotifyItem::Other(u) => { + error!("Unsupported URI: {}", u); + return Err(SpotifyError::Unavailable); + } + }; + Ok(()) + } + + /// Get all downloads + pub async fn get_downloads(&self) -> Vec { + self.tx.send(Message::GetDownloads).await.unwrap(); + let Response::Downloads(d) = self.rx.recv().await.unwrap(); + d + } } async fn communication_thread( - config: DownloaderConfig, - spotify: Spotify, - rx: Receiver, - tx: Sender, - self_tx: Sender, + config: DownloaderConfig, + spotify: Spotify, + rx: Receiver, + tx: Sender, + self_tx: Sender, ) { - // Downloader - let downloader = DownloaderInternal::new(spotify.clone(), self_tx.clone()); - let downloader_tx = downloader.tx.clone(); - tokio::spawn(async move { - downloader.download_loop().await; - }); - let mut waiting_for_job = false; - let mut queue: Vec = vec![]; + // Downloader + let downloader = DownloaderInternal::new(spotify.clone(), self_tx.clone()); + let downloader_tx = downloader.tx.clone(); + tokio::spawn(async move { + downloader.download_loop().await; + }); + let mut waiting_for_job = false; + let mut queue: Vec = vec![]; - // Receive messages - while let Ok(msg) = rx.recv().await { - match msg { - // Send job to worker thread - Message::GetJob => { - if let Some(d) = queue.iter_mut().find(|i| i.state == DownloadState::None) { - d.state = DownloadState::Lock; - downloader_tx - .send(DownloaderMessage::Job(d.clone().into(), config.clone())) - .await - .unwrap(); - waiting_for_job = false; - } else { - waiting_for_job = true; - } - } - // Update state of download - Message::UpdateState(id, state) => { - let i = queue.iter().position(|i| i.id == id).unwrap(); - queue[i].state = state.clone(); - if state == DownloadState::Done { - queue.remove(i); - } - } - Message::AddToQueue(download) => { - // Assign new IDs and reset state - let mut id = queue.iter().map(|i| i.id).max().unwrap_or(0); - let downloads: Vec = download - .into_iter() - .map(|mut d| { - d.id = id; - d.state = DownloadState::None; - id += 1; - d - }) - .collect(); - queue.extend(downloads); - // Update worker threads if locked - if waiting_for_job { - let d = queue - .iter_mut() - .find(|i| i.state == DownloadState::None) - .unwrap(); - d.state = DownloadState::Lock; - downloader_tx - .send(DownloaderMessage::Job(d.clone().into(), config.clone())) - .await - .unwrap(); - waiting_for_job = false; - } - } - Message::GetDownloads => { - tx.send(Response::Downloads(queue.clone())).await.ok(); - } - } - } + // Receive messages + while let Ok(msg) = rx.recv().await { + match msg { + // Send job to worker thread + Message::GetJob => { + if let Some(d) = queue.iter_mut().find(|i| i.state == DownloadState::None) { + d.state = DownloadState::Lock; + downloader_tx + .send(DownloaderMessage::Job(d.clone().into(), config.clone())) + .await + .unwrap(); + waiting_for_job = false; + } else { + waiting_for_job = true; + } + } + // Update state of download + Message::UpdateState(id, state) => { + let i = queue.iter().position(|i| i.id == id).unwrap(); + queue[i].state = state.clone(); + if state == DownloadState::Done { + queue.remove(i); + } + } + Message::AddToQueue(download) => { + // Assign new IDs and reset state + let mut id = queue.iter().map(|i| i.id).max().unwrap_or(0); + let downloads: Vec = download + .into_iter() + .map(|mut d| { + d.id = id; + d.state = DownloadState::None; + id += 1; + d + }) + .collect(); + queue.extend(downloads); + // Update worker threads if locked + if waiting_for_job { + let d = queue + .iter_mut() + .find(|i| i.state == DownloadState::None) + .unwrap(); + d.state = DownloadState::Lock; + downloader_tx + .send(DownloaderMessage::Job(d.clone().into(), config.clone())) + .await + .unwrap(); + waiting_for_job = false; + } + } + Message::GetDownloads => { + tx.send(Response::Downloads(queue.clone())).await.ok(); + } + } + } } /// Spotify downloader pub struct DownloaderInternal { - spotify: Spotify, - pub tx: Sender, - rx: Receiver, - event_tx: Sender, + spotify: Spotify, + pub tx: Sender, + rx: Receiver, + event_tx: Sender, } pub enum DownloaderMessage { - Job(DownloadJob, DownloaderConfig), + Job(DownloadJob, DownloaderConfig), } impl DownloaderInternal { - /// Create new instance - pub fn new(spotify: Spotify, event_tx: Sender) -> DownloaderInternal { - let (tx, rx) = bounded(1); - DownloaderInternal { - spotify, - tx, - rx, - event_tx, - } - } + /// Create new instance + pub fn new(spotify: Spotify, event_tx: Sender) -> DownloaderInternal { + let (tx, rx) = bounded(1); + DownloaderInternal { + spotify, + tx, + rx, + event_tx, + } + } - /// Downloader loop - pub async fn download_loop(&self) { - let mut queue = vec![]; - let mut tasks = FuturesUnordered::new(); - let mut job_future = Box::pin(self.get_job()).fuse(); + /// Downloader loop + pub async fn download_loop(&self) { + let mut queue = vec![]; + let mut tasks = FuturesUnordered::new(); + let mut job_future = Box::pin(self.get_job()).fuse(); - loop { - select! { - job = job_future => { - if let Some((job, config)) = job { - if tasks.len() < config.concurrent_downloads { - tasks.push(self.download_job_wrapper(job.clone(), config).boxed()) - } else { - queue.push((job, config)); - } - } - job_future = Box::pin(self.get_job()).fuse(); - }, - // Task finished - () = tasks.select_next_some() => { - if let Some((job, config)) = queue.first() { - tasks.push(self.download_job_wrapper(job.clone(), config.clone()).boxed()); - queue.remove(0); - } - } - }; - } - } + loop { + select! { + job = job_future => { + if let Some((job, config)) = job { + if tasks.len() < config.concurrent_downloads { + tasks.push(self.download_job_wrapper(job.clone(), config).boxed()) + } else { + queue.push((job, config)); + } + } + job_future = Box::pin(self.get_job()).fuse(); + }, + // Task finished + () = tasks.select_next_some() => { + if let Some((job, config)) = queue.first() { + tasks.push(self.download_job_wrapper(job.clone(), config.clone()).boxed()); + queue.remove(0); + } + } + }; + } + } - // Get job from parent - async fn get_job(&self) -> Option<(DownloadJob, DownloaderConfig)> { - self.event_tx.send(Message::GetJob).await.unwrap(); - match self.rx.recv().await.ok()? { - DownloaderMessage::Job(job, config) => Some((job, config)), - } - } + // Get job from parent + async fn get_job(&self) -> Option<(DownloadJob, DownloaderConfig)> { + self.event_tx.send(Message::GetJob).await.unwrap(); + match self.rx.recv().await.ok()? { + DownloaderMessage::Job(job, config) => Some((job, config)), + } + } - /// Wrapper for download_job for error handling - async fn download_job_wrapper(&self, job: DownloadJob, config: DownloaderConfig) { - let track_id = job.track_id.clone(); - let id = job.id; - match self.download_job(job, config).await { - Ok(_) => {} - Err(e) => { - error!("Download job for track {} failed. {}", track_id, e); - self.event_tx - .send(Message::UpdateState( - id, - DownloadState::Error(e.to_string()), - )) - .await - .unwrap(); - } - } - } + /// Wrapper for download_job for error handling + async fn download_job_wrapper(&self, job: DownloadJob, config: DownloaderConfig) { + let track_id = job.track_id.clone(); + let id = job.id; + match self.download_job(job, config).await { + Ok(_) => {} + Err(e) => { + error!("Download job for track {} failed. {}", track_id, e); + self.event_tx + .send(Message::UpdateState( + id, + DownloadState::Error(e.to_string()), + )) + .await + .unwrap(); + } + } + } - // Wrapper for downloading and tagging - async fn download_job( - &self, - job: DownloadJob, - config: DownloaderConfig, - ) -> Result<(), SpotifyError> { - // Fetch metadata - let track = self - .spotify - .spotify - .tracks() - .get_track(&job.track_id, None) - .await? - .data; - let album = self - .spotify - .spotify - .albums() - .get_album(&track.album.id.ok_or(SpotifyError::Unavailable)?, None) - .await? - .data; + // Wrapper for downloading and tagging + async fn download_job( + &self, + job: DownloadJob, + config: DownloaderConfig, + ) -> Result<(), SpotifyError> { + // Fetch metadata + let track = self + .spotify + .spotify + .tracks() + .get_track(&job.track_id, None) + .await? + .data; + let album = self + .spotify + .spotify + .albums() + .get_album(&track.album.id.ok_or(SpotifyError::Unavailable)?, None) + .await? + .data; - let tags: Vec<(&str, String)> = vec![ - ("%title%", sanitize(&track.name)), - ( - "%artist%", - sanitize( - &track - .artists - .iter() - .map(|a| a.name.as_str()) - .collect::>() - .first() - .unwrap_or(&""), - ), - ), - ( - "%artists%", - sanitize( - &track - .artists - .iter() - .map(|a| a.name.as_str()) - .collect::>() - .join(", "), - ), - ), - ("%track%", track.track_number.to_string()), - ("%0track%", format!("{:02}", track.track_number)), - ("%disc%", track.disc_number.to_string()), - ("%0disc%", format!("{:02}", track.disc_number)), - ("%id%", job.track_id.to_string()), - ("%album%", sanitize(&track.album.name)), - ( - "%albumArtist%", - sanitize( - &track - .album - .artists - .iter() - .map(|a| a.name.as_str()) - .collect::>() - .first() - .unwrap_or(&""), - ), - ), - ( - "%albumArtists%", - sanitize( - &track - .album - .artists - .iter() - .map(|a| a.name.as_str()) - .collect::>() - .join(", "), - ), - ), - ]; + let tags: Vec<(&str, String)> = vec![ + ("%title%", sanitize(&track.name)), + ( + "%artist%", + sanitize( + &track + .artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .first() + .unwrap_or(&""), + ), + ), + ( + "%artists%", + sanitize( + &track + .artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", "), + ), + ), + ("%track%", track.track_number.to_string()), + ("%0track%", format!("{:02}", track.track_number)), + ("%disc%", track.disc_number.to_string()), + ("%0disc%", format!("{:02}", track.disc_number)), + ("%id%", job.track_id.to_string()), + ("%album%", sanitize(&track.album.name)), + ( + "%albumArtist%", + sanitize( + &track + .album + .artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .first() + .unwrap_or(&""), + ), + ), + ( + "%albumArtists%", + sanitize( + &track + .album + .artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", "), + ), + ), + ]; - // Generate path - let mut filename = config.filename_template.to_owned(); - let mut path_template = config.path.to_owned(); - for (t, v) in tags { - path_template = path_template.replace(t, &v); - filename = filename.replace(t, &v); - } - let path = PathBuf::from(path_template).join(filename); - tokio::fs::create_dir_all(path.parent().unwrap()).await?; + let mut filename_template = config.filename_template.clone(); + let mut path_template = config.path.clone(); + for (tag, value) in tags { + filename_template = filename_template.replace(tag, &value); + path_template = path_template.replace(tag, &value); + } + let path = Path::new(&path_template).join(&filename_template); - // Download - let (path, format) = DownloaderInternal::download_track( - &self.spotify.session, - &job.track_id, - path, - config.clone(), - self.event_tx.clone(), - job.id, - ) - .await?; - // Post processing - self.event_tx - .send(Message::UpdateState(job.id, DownloadState::Post)) - .await - .ok(); + tokio::fs::create_dir_all(path.parent().unwrap()).await?; - // Download cover - let mut cover = None; - if let Some(image) = track.album.images.first() { - match DownloaderInternal::download_cover(&image.url).await { - Ok(c) => cover = Some(c), - Err(e) => warn!("Failed downloading cover! {}", e), - } - } + // Download + let (path, format) = DownloaderInternal::download_track( + &self.spotify.session, + &job.track_id, + path, + config.clone(), + self.event_tx.clone(), + job.id, + ) + .await?; + // Post processing + self.event_tx + .send(Message::UpdateState(job.id, DownloadState::Post)) + .await + .ok(); - let tags = vec![ - (Field::Title, vec![track.name.to_string()]), - (Field::Album, vec![track.album.name.to_string()]), - ( - Field::Artist, - track - .artists - .iter() - .map(|a| a.name.to_string()) - .collect::>(), - ), - ( - Field::AlbumArtist, - track - .album - .artists - .iter() - .map(|a| a.name.to_string()) - .collect::>(), - ), - (Field::TrackNumber, vec![track.track_number.to_string()]), - (Field::DiscNumber, vec![track.disc_number.to_string()]), - (Field::Genre, album.genres.clone()), - (Field::Label, vec![album.label.to_string()]), - ]; - let date = album.release_date; - // Write tags - let config = config.clone(); - tokio::task::spawn_blocking(move || { - DownloaderInternal::write_tags(path, format, tags, date, cover, config) - }) - .await??; + // Download cover + let mut cover = None; + if let Some(image) = track.album.images.first() { + match DownloaderInternal::download_cover(&image.url).await { + Ok(c) => cover = Some(c), + Err(e) => warn!("Failed downloading cover! {}", e), + } + } - // Done - self.event_tx - .send(Message::UpdateState(job.id, DownloadState::Done)) - .await - .ok(); - Ok(()) - } + let tags = vec![ + (Field::Title, vec![track.name.to_string()]), + (Field::Album, vec![track.album.name.to_string()]), + ( + Field::Artist, + track + .artists + .iter() + .map(|a| a.name.to_string()) + .collect::>(), + ), + ( + Field::AlbumArtist, + track + .album + .artists + .iter() + .map(|a| a.name.to_string()) + .collect::>(), + ), + (Field::TrackNumber, vec![track.track_number.to_string()]), + (Field::DiscNumber, vec![track.disc_number.to_string()]), + (Field::Genre, album.genres.clone()), + (Field::Label, vec![album.label.to_string()]), + ]; + let date = album.release_date; + // Write tags + let config = config.clone(); + tokio::task::spawn_blocking(move || { + DownloaderInternal::write_tags(path, format, tags, date, cover, config) + }) + .await??; - /// Download cover, returns mime and data - async fn download_cover(url: &str) -> Result<(String, Vec), SpotifyError> { - let res = reqwest::get(url).await?; - let mime = res - .headers() - .get("content-type") - .ok_or_else(|| SpotifyError::Error("Missing cover mime!".into()))? - .to_str() - .unwrap() - .to_string(); - let data = res.bytes().await?.to_vec(); - Ok((mime, data)) - } + // Done + self.event_tx + .send(Message::UpdateState(job.id, DownloadState::Done)) + .await + .ok(); + Ok(()) + } - /// Write tags to file ( BLOCKING ) - fn write_tags( - path: impl AsRef, - format: AudioFormat, - tags: Vec<(Field, Vec)>, - date: NaiveDate, - cover: Option<(String, Vec)>, - config: DownloaderConfig, - ) -> Result<(), SpotifyError> { - let mut tag_wrap = TagWrap::new(path, format)?; - // Format specific - if let TagWrap::Id3(id3) = &mut tag_wrap { - id3.use_id3_v24(config.id3v24) - } + /// Download cover, returns mime and data + async fn download_cover(url: &str) -> Result<(String, Vec), SpotifyError> { + let res = reqwest::get(url).await?; + let mime = res + .headers() + .get("content-type") + .ok_or_else(|| SpotifyError::Error("Missing cover mime!".into()))? + .to_str() + .unwrap() + .to_string(); + let data = res.bytes().await?.to_vec(); + Ok((mime, data)) + } - let tag = tag_wrap.get_tag(); - tag.set_separator(&config.separator); - for (field, value) in tags { - tag.set_field(field, value); - } - tag.set_release_date(date); - // Cover - if let Some((mime, data)) = cover { - tag.add_cover(&mime, data); - } - tag.save()?; - Ok(()) - } + /// Write tags to file ( BLOCKING ) + fn write_tags( + path: impl AsRef, + format: AudioFormat, + tags: Vec<(Field, Vec)>, + date: NaiveDate, + cover: Option<(String, Vec)>, + config: DownloaderConfig, + ) -> Result<(), SpotifyError> { + let mut tag_wrap = TagWrap::new(path, format)?; + // Format specific + if let TagWrap::Id3(id3) = &mut tag_wrap { + id3.use_id3_v24(config.id3v24) + } - /// Download track by id - async fn download_track( - session: &Session, - id: &str, - path: impl AsRef, - config: DownloaderConfig, - tx: Sender, - job_id: i64, - ) -> Result<(PathBuf, AudioFormat), SpotifyError> { - let id = SpotifyId::from_base62(id)?; - let track = Track::get(session, id).await?; - // Fallback if unavailable - if !track.available { - for alt in track.alternatives { - let t = Track::get(session, alt).await?; - if t.available { - break; - } - } - return Err(SpotifyError::Unavailable); - } - // Quality fallback - let mut quality = config.quality; - let (mut file_id, mut file_format) = (None, None); - 'outer: loop { - for format in quality.get_file_formats() { - if let Some(f) = track.files.get(&format) { - info!("{} Using {:?} format.", id.to_base62(), format); - file_id = Some(f); - file_format = Some(format); - break 'outer; - } - } - // Fallback to worser quality - match quality.fallback() { - Some(q) => quality = q, - None => break, - } - warn!("{} Falling back to: {:?}", id.to_base62(), quality); - } - let file_id = file_id.ok_or(SpotifyError::Unavailable)?; - let file_format = file_format.unwrap(); + let tag = tag_wrap.get_tag(); + tag.set_separator(&config.separator); + for (field, value) in tags { + tag.set_field(field, value); + } + tag.set_release_date(date); + // Cover + if let Some((mime, data)) = cover { + tag.add_cover(&mime, data); + } + tag.save()?; + Ok(()) + } - // Path with extension - let mut audio_format: AudioFormat = file_format.into(); - let path = format!( - "{}.{}", - path.as_ref().to_str().unwrap(), - match config.convert_to_mp3 { - true => "mp3".to_string(), - false => audio_format.extension(), - } - ); - let path = Path::new(&path).to_owned(); - let path_clone = path.clone(); + /// Download track by id + async fn download_track( + session: &Session, + id: &str, + path: impl AsRef, + config: DownloaderConfig, + tx: Sender, + job_id: i64, + ) -> Result<(PathBuf, AudioFormat), SpotifyError> { + let id = SpotifyId::from_base62(id)?; + let track = Track::get(session, id).await?; + // Fallback if unavailable + if !track.available { + for alt in track.alternatives { + let t = Track::get(session, alt).await?; + if t.available { + break; + } + } + return Err(SpotifyError::Unavailable); + } + // Quality fallback + let mut quality = config.quality; + let (mut file_id, mut file_format) = (None, None); + 'outer: loop { + for format in quality.get_file_formats() { + if let Some(f) = track.files.get(&format) { + info!("{} Using {:?} format.", id.to_base62(), format); + file_id = Some(f); + file_format = Some(format); + break 'outer; + } + } + // Fallback to worser quality + match quality.fallback() { + Some(q) => quality = q, + None => break, + } + warn!("{} Falling back to: {:?}", id.to_base62(), quality); + } + let file_id = file_id.ok_or(SpotifyError::Unavailable)?; + let file_format = file_format.unwrap(); - let key = session.audio_key().request(track.id, *file_id).await?; - let encrypted = AudioFile::open(session, *file_id, 1024 * 1024, true).await?; - let size = encrypted.get_stream_loader_controller().len(); - // Download - let s = match config.convert_to_mp3 { - true => { - let s = DownloaderInternal::download_track_convert_stream( - path_clone, - encrypted, - key, - audio_format.clone(), - quality, - ) - .boxed(); - audio_format = AudioFormat::Mp3; - s - } - false => DownloaderInternal::download_track_stream(path_clone, encrypted, key).boxed(), - }; - pin_mut!(s); - // Read progress - let mut read = 0; - while let Some(result) = s.next().await { - match result { - Ok(r) => { - read += r; - tx.send(Message::UpdateState( - job_id, - DownloadState::Downloading(read, size), - )) - .await - .ok(); - } - Err(e) => { - tokio::fs::remove_file(path).await.ok(); - return Err(e); - } - } - } + // Path with extension + let mut audio_format: AudioFormat = file_format.into(); + let path = format!( + "{}.{}", + path.as_ref().to_str().unwrap(), + match config.convert_to_mp3 { + true => "mp3".to_string(), + false => audio_format.extension(), + } + ); + let path = Path::new(&path).to_owned(); + let path_clone = path.clone(); - info!("Done downloading: {}", track.id.to_base62()); - Ok((path, audio_format)) - } + let key = session.audio_key().request(track.id, *file_id).await?; + let encrypted = AudioFile::open(session, *file_id, 1024 * 1024, true).await?; + let size = encrypted.get_stream_loader_controller().len(); + // Download + let s = match config.convert_to_mp3 { + true => { + let s = DownloaderInternal::download_track_convert_stream( + path_clone, + encrypted, + key, + audio_format.clone(), + quality, + ) + .boxed(); + audio_format = AudioFormat::Mp3; + s + } + false => DownloaderInternal::download_track_stream(path_clone, encrypted, key).boxed(), + }; + pin_mut!(s); + // Read progress + let mut read = 0; + while let Some(result) = s.next().await { + match result { + Ok(r) => { + read += r; + tx.send(Message::UpdateState( + job_id, + DownloadState::Downloading(read, size), + )) + .await + .ok(); + } + Err(e) => { + tokio::fs::remove_file(path).await.ok(); + return Err(e); + } + } + } - fn download_track_stream( - path: impl AsRef, - encrypted: AudioFile, - key: AudioKey, - ) -> impl Stream> { - try_stream! { - let mut file = File::create(path).await?; - let mut decrypted = AudioDecrypt::new(key, encrypted); - // Skip (i guess encrypted shit) - let mut skip: [u8; 0xa7] = [0; 0xa7]; - let mut decrypted = tokio::task::spawn_blocking(move || { - match decrypted.read_exact(&mut skip) { - Ok(_) => Ok(decrypted), - Err(e) => Err(e) - } - }).await??; - // Custom reader loop for decrypting - loop { - // Blocking reader - let (d, read, buf) = tokio::task::spawn_blocking(move || { - let mut buf = vec![0; 1024 * 64]; - match decrypted.read(&mut buf) { - Ok(r) => Ok((decrypted, r, buf)), - Err(e) => Err(e) - } - }).await??; - decrypted = d; - if read == 0 { - break; - } - file.write_all(&buf[0..read]).await?; - yield read; - } - } - } + info!("Done downloading: {}", track.id.to_base62()); + Ok((path, audio_format)) + } - /// Download and convert to MP3 - fn download_track_convert_stream( - path: impl AsRef, - encrypted: AudioFile, - key: AudioKey, - format: AudioFormat, - quality: Quality, - ) -> impl Stream> { - try_stream! { - let mut file = File::create(path).await?; - let mut decrypted = AudioDecrypt::new(key, encrypted); - // Skip (i guess encrypted shit) - let mut skip: [u8; 0xa7] = [0; 0xa7]; - let decrypted = tokio::task::spawn_blocking(move || { - match decrypted.read_exact(&mut skip) { - Ok(_) => Ok(decrypted), - Err(e) => Err(e) - } - }).await??; - // Convertor - let mut decrypted = tokio::task::spawn_blocking(move || { - AudioConverter::new(Box::new(decrypted), format, quality) - }).await??; + fn download_track_stream( + path: impl AsRef, + encrypted: AudioFile, + key: AudioKey, + ) -> impl Stream> { + try_stream! { + let mut file = File::create(path).await?; + let mut decrypted = AudioDecrypt::new(key, encrypted); + // Skip (i guess encrypted shit) + let mut skip: [u8; 0xa7] = [0; 0xa7]; + let mut decrypted = tokio::task::spawn_blocking(move || { + match decrypted.read_exact(&mut skip) { + Ok(_) => Ok(decrypted), + Err(e) => Err(e) + } + }).await??; + // Custom reader loop for decrypting + loop { + // Blocking reader + let (d, read, buf) = tokio::task::spawn_blocking(move || { + let mut buf = vec![0; 1024 * 64]; + match decrypted.read(&mut buf) { + Ok(r) => Ok((decrypted, r, buf)), + Err(e) => Err(e) + } + }).await??; + decrypted = d; + if read == 0 { + break; + } + file.write_all(&buf[0..read]).await?; + yield read; + } + } + } + /// Download and convert to MP3 + fn download_track_convert_stream( + path: impl AsRef, + encrypted: AudioFile, + key: AudioKey, + format: AudioFormat, + quality: Quality, + ) -> impl Stream> { + try_stream! { + let mut file = File::create(path).await?; + let mut decrypted = AudioDecrypt::new(key, encrypted); + // Skip (i guess encrypted shit) + let mut skip: [u8; 0xa7] = [0; 0xa7]; + let decrypted = tokio::task::spawn_blocking(move || { + match decrypted.read_exact(&mut skip) { + Ok(_) => Ok(decrypted), + Err(e) => Err(e) + } + }).await??; + // Convertor + let mut decrypted = tokio::task::spawn_blocking(move || { + AudioConverter::new(Box::new(decrypted), format, quality) + }).await??; - // Custom reader loop for decrypting - loop { - // Blocking reader - let (d, read, buf) = tokio::task::spawn_blocking(move || { - let mut buf = vec![0; 1024 * 64]; - match decrypted.read(&mut buf) { - Ok(r) => Ok((decrypted, r, buf)), - Err(e) => Err(e) - } - }).await??; - decrypted = d; - if read == 0 { - break; - } - file.write_all(& buf[0..read]).await?; - yield read; - } - } - } + // Custom reader loop for decrypting + loop { + // Blocking reader + let (d, read, buf) = tokio::task::spawn_blocking(move || { + let mut buf = vec![0; 1024 * 64]; + match decrypted.read(&mut buf) { + Ok(r) => Ok((decrypted, r, buf)), + Err(e) => Err(e) + } + }).await??; + decrypted = d; + if read == 0 { + break; + } + file.write_all(&buf[0..read]).await?; + yield read; + } + } + } } #[derive(Debug, Clone)] pub enum AudioFormat { - Ogg, - Aac, - Mp3, - Mp4, - Unknown, + Ogg, + Aac, + Mp3, + Mp4, + Unknown, } impl AudioFormat { - /// Get extension - pub fn extension(&self) -> String { - match self { - AudioFormat::Ogg => "ogg", - AudioFormat::Aac => "m4a", - AudioFormat::Mp3 => "mp3", - AudioFormat::Mp4 => "mp4", - AudioFormat::Unknown => "", - } - .to_string() - } + /// Get extension + pub fn extension(&self) -> String { + match self { + AudioFormat::Ogg => "ogg", + AudioFormat::Aac => "m4a", + AudioFormat::Mp3 => "mp3", + AudioFormat::Mp4 => "mp4", + AudioFormat::Unknown => "", + } + .to_string() + } } impl From for AudioFormat { - fn from(f: FileFormat) -> Self { - match f { - FileFormat::OGG_VORBIS_96 => Self::Ogg, - FileFormat::OGG_VORBIS_160 => Self::Ogg, - FileFormat::OGG_VORBIS_320 => Self::Ogg, - FileFormat::MP3_256 => Self::Mp3, - FileFormat::MP3_320 => Self::Mp3, - FileFormat::MP3_160 => Self::Mp3, - FileFormat::MP3_96 => Self::Mp3, - FileFormat::MP3_160_ENC => Self::Mp3, - FileFormat::MP4_128_DUAL => Self::Mp4, - FileFormat::OTHER3 => Self::Unknown, - FileFormat::AAC_160 => Self::Aac, - FileFormat::AAC_320 => Self::Aac, - FileFormat::MP4_128 => Self::Mp4, - FileFormat::OTHER5 => Self::Unknown, - } - } + fn from(f: FileFormat) -> Self { + match f { + FileFormat::OGG_VORBIS_96 => Self::Ogg, + FileFormat::OGG_VORBIS_160 => Self::Ogg, + FileFormat::OGG_VORBIS_320 => Self::Ogg, + FileFormat::MP3_256 => Self::Mp3, + FileFormat::MP3_320 => Self::Mp3, + FileFormat::MP3_160 => Self::Mp3, + FileFormat::MP3_96 => Self::Mp3, + FileFormat::MP3_160_ENC => Self::Mp3, + FileFormat::MP4_128_DUAL => Self::Mp4, + FileFormat::OTHER3 => Self::Unknown, + FileFormat::AAC_160 => Self::Aac, + FileFormat::AAC_320 => Self::Aac, + FileFormat::MP4_128 => Self::Mp4, + FileFormat::OTHER5 => Self::Unknown, + } + } } impl Quality { - /// Get librespot FileFormat - pub fn get_file_formats(&self) -> Vec { - match self { - Self::Q320 => vec![ - FileFormat::OGG_VORBIS_320, - FileFormat::AAC_320, - FileFormat::MP3_320, - ], - Self::Q256 => vec![FileFormat::MP3_256], - Self::Q160 => vec![ - FileFormat::OGG_VORBIS_160, - FileFormat::AAC_160, - FileFormat::MP3_160, - ], - Self::Q96 => vec![FileFormat::OGG_VORBIS_96, FileFormat::MP3_96], - } - } + /// Get librespot FileFormat + pub fn get_file_formats(&self) -> Vec { + match self { + Self::Q320 => vec![ + FileFormat::OGG_VORBIS_320, + FileFormat::AAC_320, + FileFormat::MP3_320, + ], + Self::Q256 => vec![FileFormat::MP3_256], + Self::Q160 => vec![ + FileFormat::OGG_VORBIS_160, + FileFormat::AAC_160, + FileFormat::MP3_160, + ], + Self::Q96 => vec![FileFormat::OGG_VORBIS_96, FileFormat::MP3_96], + } + } - /// Fallback to lower quality - pub fn fallback(&self) -> Option { - match self { - Self::Q320 => Some(Quality::Q256), - Self::Q256 => Some(Quality::Q160), - Self::Q160 => Some(Quality::Q96), - Self::Q96 => None, - } - } + /// Fallback to lower quality + pub fn fallback(&self) -> Option { + match self { + Self::Q320 => Some(Quality::Q256), + Self::Q256 => Some(Quality::Q160), + Self::Q160 => Some(Quality::Q96), + Self::Q96 => None, + } + } } #[derive(Debug, Clone)] pub struct DownloadJob { - pub id: i64, - pub track_id: String, + pub id: i64, + pub track_id: String, } #[derive(Debug, Clone)] pub enum Message { - // Send job to worker - GetJob, - // Update state of download - UpdateState(i64, DownloadState), - AddToQueue(Vec), - // Get all downloads to UI - GetDownloads, + // Send job to worker + GetJob, + // Update state of download + UpdateState(i64, DownloadState), + //add to download + AddToQueue(Vec), + // Get all downloads to UI + GetDownloads, } #[derive(Debug, Clone)] pub enum Response { - Downloads(Vec), + Downloads(Vec), } #[derive(Debug, Clone)] pub struct Download { - pub id: i64, - pub track_id: String, - pub title: String, - pub subtitle: String, - pub state: DownloadState, + pub id: i64, + pub track_id: String, + pub title: String, + pub subtitle: String, + pub state: DownloadState, +} + +#[derive(Debug, Clone)] +pub struct SearchResult { + pub track_id: String, + pub author: String, + pub title: String, +} + +impl From for SearchResult { + fn from(val: aspotify::Track) -> Self { + SearchResult { + track_id: val.id.unwrap(), + author: val.artists[0].name.to_owned(), + title: val.name, + } + } } impl From for Download { - fn from(val: aspotify::Track) -> Self { - Download { - id: 0, - track_id: val.id.unwrap(), - title: val.name, - subtitle: val - .artists - .first() - .map(|a| a.name.to_owned()) - .unwrap_or_default(), - state: DownloadState::None, - } - } + fn from(val: aspotify::Track) -> Self { + Download { + id: 0, + track_id: val.id.unwrap(), + title: val.name, + subtitle: val + .artists + .first() + .map(|a| a.name.to_owned()) + .unwrap_or_default(), + state: DownloadState::None, + } + } } impl From for Download { - fn from(val: aspotify::TrackSimplified) -> Self { - Download { - id: 0, - track_id: val.id.unwrap(), - title: val.name, - subtitle: val - .artists - .first() - .map(|a| a.name.to_owned()) - .unwrap_or_default(), - state: DownloadState::None, - } - } + fn from(val: aspotify::TrackSimplified) -> Self { + Download { + id: 0, + track_id: val.id.unwrap(), + title: val.name, + subtitle: val + .artists + .first() + .map(|a| a.name.to_owned()) + .unwrap_or_default(), + state: DownloadState::None, + } + } } impl From for DownloadJob { - fn from(val: Download) -> Self { - DownloadJob { - id: val.id, - track_id: val.track_id, - } - } + fn from(val: Download) -> Self { + DownloadJob { + id: val.id, + track_id: val.track_id, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DownloadState { - None, - Lock, - Downloading(usize, usize), - Post, - Done, - Error(String), + None, + Lock, + Downloading(usize, usize), + Post, + Done, + Error(String), } /// Bitrate of music #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy)] pub enum Quality { - Q320, - Q256, - Q160, - Q96, + Q320, + Q256, + Q160, + Q96, } impl ToString for Quality { - fn to_string(&self) -> String { - match self { - Quality::Q320 => "320kbps", - Quality::Q256 => "256kbps", - Quality::Q160 => "160kbps", - Quality::Q96 => "96kbps", - } - .to_string() - } + fn to_string(&self) -> String { + match self { + Quality::Q320 => "320kbps", + Quality::Q256 => "256kbps", + Quality::Q160 => "160kbps", + Quality::Q96 => "96kbps", + } + .to_string() + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DownloaderConfig { - pub concurrent_downloads: usize, - pub quality: Quality, - pub path: String, - pub filename_template: String, - pub id3v24: bool, - pub convert_to_mp3: bool, - pub separator: String, + pub concurrent_downloads: usize, + pub quality: Quality, + pub path: String, + pub filename_template: String, + pub id3v24: bool, + pub convert_to_mp3: bool, + pub separator: String, +} + +impl DownloaderConfig { + // Create new instance + pub fn new() -> DownloaderConfig { + DownloaderConfig { + concurrent_downloads: 4, + quality: Quality::Q320, + path: "downloads".to_string(), + filename_template: "%artist% - %title%".to_string(), + id3v24: true, + convert_to_mp3: false, + separator: ", ".to_string(), + } + } } diff --git a/src/main.rs b/src/main.rs index 8fc1bd9..032ec9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,8 +8,6 @@ use settings::Settings; use spotify::Spotify; use std::{ env, - ffi::OsStr, - path::Path, time::{Duration, Instant}, }; @@ -29,14 +27,14 @@ async fn main() { #[cfg(windows)] #[tokio::main] async fn main() { - use colored::control; + use colored::control; //backwards compatibility. match control::set_virtual_terminal(true) { - Ok(_) => {}, + Ok(_) => {} Err(_) => {} }; - + start().await; } @@ -45,7 +43,7 @@ async fn start() { Ok(settings) => { println!( "{} {}.", - "Settings successfully loaded. Continuing with spotify account:".green(), + "Settings successfully loaded.\nContinuing with spotify account:".green(), settings.username ); settings @@ -56,8 +54,7 @@ async fn start() { "Settings could not be loaded, because of the following error:".red(), e ); - let default_settings = - Settings::new("username", "password", "client_id", "secret").unwrap(); + let default_settings = Settings::new("username", "password", "client_id", "secret"); match default_settings.save().await { Ok(_) => { println!( @@ -103,75 +100,108 @@ async fn start() { let downloader = Downloader::new(settings.downloader, spotify); - match downloader.add_uri(&args[1]).await { - Ok(_) => {} - Err(e) => { - error!("{} {}", "Adding url failed:".red(), e) - } - } + match downloader.handle_input(&args[1]).await { + Ok(search_results) => { + if let Some(search_results) = search_results { + print!("{esc}[2J{esc}[1;1H", esc = 27 as char); - let refresh = Duration::from_secs(settings.refresh_ui_seconds); - let now = Instant::now(); - let mut timeelapsed: u64; + for (i, track) in search_results.iter().enumerate() { + println!("{}: {} - {}", i + 1, track.author, track.title); + } + println!("{}", "Select the track (default: 1): ".green()); - 'outer: loop { - print!("{esc}[2J{esc}[1;1H", esc = 27 as char); - let mut exit_flag: i8 = 1; + let mut selection; + loop { + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .expect("Failed to read line"); - for download in downloader.get_downloads().await { - let state = download.state; + selection = input.trim().parse::().unwrap_or(1) - 1; - let progress: String; + if selection < search_results.len() { + break; + } + println!("{}", "Invalid selection. Try again or quit (CTRL+C):".red()); + } - if state != DownloadState::Done { - exit_flag &= 0; - progress = match state { - DownloadState::Downloading(r, t) => { - let p = r as f32 / t as f32 * 100.0; - if p > 100.0 { - "100%".to_string() - } else { - format!("{}%", p as i8) + let track = &search_results[selection]; + + if let Err(e) = downloader + .add_uri(&format!("spotify:track:{}", track.track_id)) + .await + { + error!( + "{}", + format!( + "{}: {}", + "Track could not be added to download queue.".red(), + e + ) + ); + } else { + let refresh = Duration::from_secs(settings.refresh_ui_seconds); + let now = Instant::now(); + let mut time_elapsed: u64; + + 'outer: loop { + print!("{esc}[2J{esc}[1;1H", esc = 27 as char); + let mut exit_flag: i8 = 1; + + for download in downloader.get_downloads().await { + let state = download.state; + + let progress: String; + + if state != DownloadState::Done { + exit_flag &= 0; + progress = match state { + DownloadState::Downloading(r, t) => { + let p = r as f32 / t as f32 * 100.0; + if p > 100.0 { + "100%".to_string() + } else { + format!("{}%", p as i8) + } + } + DownloadState::Post => "Postprocessing... ".to_string(), + DownloadState::None => "Preparing... ".to_string(), + DownloadState::Lock => "Preparing... ".to_string(), + DownloadState::Error(e) => { + exit_flag |= 1; + format!("{} ", e) + } + DownloadState::Done => { + exit_flag |= 1; + "Impossible state".to_string() + } + }; + } else { + progress = "Done.".to_string(); + } + + println!("{:<19}| {}", progress, download.title); } + time_elapsed = now.elapsed().as_secs(); + if exit_flag == 1 { + break 'outer; + } + + println!("\nElapsed second(s): {}", time_elapsed); + task::sleep(refresh).await } - DownloadState::Post => "Postprocessing... ".to_string(), - DownloadState::None => "Preparing... ".to_string(), - DownloadState::Lock => "Holding... ".to_string(), - DownloadState::Error(e) => { - exit_flag |= 1; - format!("{} ", e) - }, - DownloadState::Done => { - exit_flag |= 1; - "Impossible state".to_string() - } - }; - } else { - progress = "Done.".to_string(); + println!("Finished download(s) in {} second(s).", time_elapsed); + } } - - println!("{:<19}| {}", progress, download.title); } - timeelapsed = now.elapsed().as_secs(); - if exit_flag == 1 { - break 'outer; + Err(e) => { + error!("{} {}", "Handling input failed:".red(), e) } - - println!("\nElapsed second(s): {}", timeelapsed); - task::sleep(refresh).await } - println!("Finished download(s) in {} second(s).", timeelapsed); } else { println!( "Usage:\n{} (track_url | album_url | playlist_url | artist_url )", - env::args() - .next() - .as_ref() - .map(Path::new) - .and_then(Path::file_name) - .and_then(OsStr::to_str) - .map(String::from) - .unwrap() + args[0] ); } } diff --git a/src/settings.rs b/src/settings.rs index 963903f..bd403bc 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,5 +1,4 @@ use crate::downloader::DownloaderConfig; -use crate::downloader::Quality; use crate::error::SpotifyError; use serde::{Deserialize, Serialize}; @@ -18,30 +17,18 @@ pub struct Settings { pub refresh_ui_seconds: u64, pub downloader: DownloaderConfig, } + impl Settings { // Create new instance - pub fn new( - username: &str, - password: &str, - client_id: &str, - client_secret: &str, - ) -> Option { - Some(Settings { + pub fn new(username: &str, password: &str, client_id: &str, client_secret: &str) -> Settings { + Settings { username: username.to_string(), password: password.to_string(), client_id: client_id.to_string(), client_secret: client_secret.to_string(), refresh_ui_seconds: 1, - downloader: DownloaderConfig { - concurrent_downloads: 4, - quality: Quality::Q320, - path: "downloads".to_string(), - filename_template: "%artist% - %title%".to_string(), - id3v24: true, - convert_to_mp3: false, - separator: ", ".to_string(), - }, - }) + downloader: DownloaderConfig::new() + } } // Serialize the settings to a json file diff --git a/src/spotify.rs b/src/spotify.rs index 18e817a..0e7635f 100644 --- a/src/spotify.rs +++ b/src/spotify.rs @@ -1,6 +1,4 @@ -use aspotify::{ - Album, Artist, Client, ClientCredentials, Playlist, PlaylistItemType, Track, TrackSimplified, -}; +use aspotify::{Album, Artist, Client, ClientCredentials, ItemType, Playlist, PlaylistItemType, Track, TrackSimplified}; use librespot::core::authentication::Credentials; use librespot::core::config::SessionConfig; use librespot::core::session::Session; @@ -10,183 +8,196 @@ use url::Url; use crate::error::SpotifyError; pub struct Spotify { - // librespotify sessopm - pub session: Session, - pub spotify: Client, + // librespotify sessopm + pub session: Session, + pub spotify: Client, } impl Spotify { - /// Create new instance - pub async fn new( - username: &str, - password: &str, - client_id: &str, - client_secret: &str, - ) -> Result { - // librespot - let credentials = Credentials::with_password(username, password); - let session = Session::connect(SessionConfig::default(), credentials, None).await?; - //aspotify - let credentials = ClientCredentials { - id: client_id.to_string(), - secret: client_secret.to_string(), - }; - let spotify = Client::new(credentials); + /// Create new instance + pub async fn new( + username: &str, + password: &str, + client_id: &str, + client_secret: &str, + ) -> Result { + // librespot + let credentials = Credentials::with_password(username, password); + let session = Session::connect(SessionConfig::default(), credentials, None).await?; + //aspotify + let credentials = ClientCredentials { + id: client_id.to_string(), + secret: client_secret.to_string(), + }; + let spotify = Client::new(credentials); - Ok(Spotify { session, spotify }) - } + Ok(Spotify { session, spotify }) + } - /// Parse URI or URL into URI - pub fn parse_uri(uri: &str) -> Result { - // Already URI - if uri.starts_with("spotify:") { - if uri.split(':').count() < 3 { - return Err(SpotifyError::InvalidUri); - } - return Ok(uri.to_string()); - } + /// Parse URI or URL into URI + pub fn parse_uri(uri: &str) -> Result { + // Already URI + if uri.starts_with("spotify:") { + if uri.split(':').count() < 3 { + return Err(SpotifyError::InvalidUri); + } + return Ok(uri.to_string()); + } - // Parse URL - let url = Url::parse(uri)?; - // Spotify Web Player URL - if url.host_str() == Some("open.spotify.com") { - let path = url - .path_segments() - .ok_or_else(|| SpotifyError::Error("Missing URL path".into()))? - .collect::>(); - if path.len() < 2 { - return Err(SpotifyError::InvalidUri); - } - return Ok(format!("spotify:{}:{}", path[0], path[1])); - } - Err(SpotifyError::InvalidUri) - } + // Parse URL + let url = Url::parse(uri)?; + // Spotify Web Player URL + if url.host_str() == Some("open.spotify.com") { + let path = url + .path_segments() + .ok_or_else(|| SpotifyError::Error("Missing URL path".into()))? + .collect::>(); + if path.len() < 2 { + return Err(SpotifyError::InvalidUri); + } + return Ok(format!("spotify:{}:{}", path[0], path[1])); + } + Err(SpotifyError::InvalidUri) + } - /// Fetch data for URI - pub async fn resolve_uri(&self, uri: &str) -> Result { - let parts = uri.split(':').skip(1).collect::>(); - let id = parts[1]; - match parts[0] { - "track" => { - let track = self.spotify.tracks().get_track(id, None).await?; - Ok(SpotifyItem::Track(track.data)) - } - "playlist" => { - let playlist = self.spotify.playlists().get_playlist(id, None).await?; - Ok(SpotifyItem::Playlist(playlist.data)) - } - "album" => { - let album = self.spotify.albums().get_album(id, None).await?; - Ok(SpotifyItem::Album(album.data)) - } - "artist" => { - let artist = self.spotify.artists().get_artist(id).await?; - Ok(SpotifyItem::Artist(artist.data)) - } - // Unsupported / Unimplemented - _ => Ok(SpotifyItem::Other(uri.to_string())), - } - } + /// Fetch data for URI + pub async fn resolve_uri(&self, uri: &str) -> Result { + let parts = uri.split(':').skip(1).collect::>(); + let id = parts[1]; + match parts[0] { + "track" => { + let track = self.spotify.tracks().get_track(id, None).await?; + Ok(SpotifyItem::Track(track.data)) + } + "playlist" => { + let playlist = self.spotify.playlists().get_playlist(id, None).await?; + Ok(SpotifyItem::Playlist(playlist.data)) + } + "album" => { + let album = self.spotify.albums().get_album(id, None).await?; + Ok(SpotifyItem::Album(album.data)) + } + "artist" => { + let artist = self.spotify.artists().get_artist(id).await?; + Ok(SpotifyItem::Artist(artist.data)) + } + // Unsupported / Unimplemented + _ => Ok(SpotifyItem::Other(uri.to_string())), + } + } - /// Get all tracks from playlist - pub async fn full_playlist(&self, id: &str) -> Result, SpotifyError> { - let mut items = vec![]; - let mut offset = 0; - loop { - let page = self - .spotify - .playlists() - .get_playlists_items(id, 100, offset, None) - .await?; - items.append( - &mut page - .data - .items - .iter() - .filter_map(|i| -> Option { - if let Some(PlaylistItemType::Track(t)) = &i.item { - Some(t.to_owned()) - } else { - None - } - }) - .collect(), - ); + /// Get search results for query + pub async fn search(&self, query: &str) -> Result, SpotifyError> { + Ok(self + .spotify + .search() + .search(query, [ItemType::Track], true, 50, 0, None) + .await? + .data + .tracks + .unwrap() + .items) + } - // End - offset += page.data.items.len(); - if page.data.total == offset { - return Ok(items); - } - } - } + /// Get all tracks from playlist + pub async fn full_playlist(&self, id: &str) -> Result, SpotifyError> { + let mut items = vec![]; + let mut offset = 0; + loop { + let page = self + .spotify + .playlists() + .get_playlists_items(id, 100, offset, None) + .await?; + items.append( + &mut page + .data + .items + .iter() + .filter_map(|i| -> Option { + if let Some(PlaylistItemType::Track(t)) = &i.item { + Some(t.to_owned()) + } else { + None + } + }) + .collect(), + ); - /// Get all tracks from album - pub async fn full_album(&self, id: &str) -> Result, SpotifyError> { - let mut items = vec![]; - let mut offset = 0; - loop { - let page = self - .spotify - .albums() - .get_album_tracks(id, 50, offset, None) - .await?; - items.append(&mut page.data.items.to_vec()); + // End + offset += page.data.items.len(); + if page.data.total == offset { + return Ok(items); + } + } + } - // End - offset += page.data.items.len(); - if page.data.total == offset { - return Ok(items); - } - } - } + /// Get all tracks from album + pub async fn full_album(&self, id: &str) -> Result, SpotifyError> { + let mut items = vec![]; + let mut offset = 0; + loop { + let page = self + .spotify + .albums() + .get_album_tracks(id, 50, offset, None) + .await?; + items.append(&mut page.data.items.to_vec()); - /// Get all tracks from artist - pub async fn full_artist(&self, id: &str) -> Result, SpotifyError> { - let mut items = vec![]; - let mut offset = 0; - loop { - let page = self - .spotify - .artists() - .get_artist_albums(id, None, 50, offset, None) - .await?; + // End + offset += page.data.items.len(); + if page.data.total == offset { + return Ok(items); + } + } + } - for album in &mut page.data.items.iter() { - items.append(&mut self.full_album(&album.id).await?) - } + /// Get all tracks from artist + pub async fn full_artist(&self, id: &str) -> Result, SpotifyError> { + let mut items = vec![]; + let mut offset = 0; + loop { + let page = self + .spotify + .artists() + .get_artist_albums(id, None, 50, offset, None) + .await?; - // End - offset += page.data.items.len(); - if page.data.total == offset { - return Ok(items); - } - } - } + for album in &mut page.data.items.iter() { + items.append(&mut self.full_album(&album.id).await?) + } + + // End + offset += page.data.items.len(); + if page.data.total == offset { + return Ok(items); + } + } + } } impl Clone for Spotify { - fn clone(&self) -> Self { - Self { - session: self.session.clone(), - spotify: Client::new(self.spotify.credentials.clone()), - } - } + fn clone(&self) -> Self { + Self { + session: self.session.clone(), + spotify: Client::new(self.spotify.credentials.clone()), + } + } } /// Basic debug implementation so can be used in other structs impl fmt::Debug for Spotify { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "") - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "") + } } #[derive(Debug, Clone)] pub enum SpotifyItem { - Track(Track), - Album(Album), - Playlist(Playlist), - Artist(Artist), - /// Unimplemented - Other(String), + Track(Track), + Album(Album), + Playlist(Playlist), + Artist(Artist), + /// Unimplemented + Other(String), } diff --git a/src/tag/mod.rs b/src/tag/mod.rs index 85e78e2..3040d89 100644 --- a/src/tag/mod.rs +++ b/src/tag/mod.rs @@ -1,4 +1,5 @@ use chrono::NaiveDate; + use std::path::Path; use crate::downloader::AudioFormat; @@ -16,6 +17,7 @@ pub enum TagWrap { } impl TagWrap { + /// Load from file pub fn new(path: impl AsRef, format: AudioFormat) -> Result { match format { @@ -27,10 +29,10 @@ impl TagWrap { /// Get Tag trait pub fn get_tag(&mut self) -> Box<&mut dyn Tag> { - match self { - TagWrap::Ogg(tag) => Box::new(tag), - TagWrap::Id3(tag) => Box::new(tag), - } + Box::new(match self { + TagWrap::Ogg(tag) => tag, + TagWrap::Id3(tag) => tag, + }) } }