diff --git a/Cargo.toml b/Cargo.toml index 6ccd17c..f35162a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,10 +10,11 @@ repository = "https://github.com/GuillemCastro/spotify-dl" description = "A command-line utility to download songs and playlists from Spotify" [dependencies] +bytes = "1" structopt = { version = "0.3", default-features = false } rpassword = "7.0" indicatif = "0.17" -librespot = { version = "0.4.2", default-features = false } +librespot = { version = "0.6.0", default-features = false } tokio = { version = "1", features = ["full", "tracing"] } flacenc = { version = "0.4" } audiotags = "0.5" @@ -31,6 +32,7 @@ rayon = "1.10" hex = "0.4" reqwest = { version = "0.11", features = ["blocking", "json"] } id3 = "0.6" +serde_json = "1.0.117" [features] default = ["mp3"] diff --git a/src/download.rs b/src/download.rs index 453e45a..f90df7a 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use std::path::PathBuf; use std::time::Duration; +use bytes::Bytes; use anyhow::Result; use futures::StreamExt; use futures::TryStreamExt; @@ -97,7 +98,7 @@ impl Downloader { let file_size = sink.get_approximate_size(); - let (mut player, _) = Player::new( + let player = Player::new( self.player_config.clone(), self.session.clone(), self.volume_getter(), @@ -134,12 +135,16 @@ impl Downloader { } } + tracing::info!("Fetching album cover image: {:?}", file_name); + let cover_image = self.get_cover_image(&metadata).await?; + tracing::info!("Encoding and writing track: {:?}", file_name); pb.set_message(format!("Encoding and writing {}", &file_name)); let samples = Samples::new(samples, 44100, 2, 16); let encoder = crate::encoder::get_encoder(options.format); let output_path = &path; - encoder.encode(&samples, &metadata, output_path).await?; + + encoder.encode(&samples, &metadata, cover_image, output_path).await?; pb.finish_with_message(format!("Downloaded {}", &file_name)); Ok(()) @@ -188,4 +193,16 @@ impl Downloader { } clean } + + async fn get_cover_image(&self, metadata: &TrackMetadata) -> Result{ + match metadata.album.cover { + Some(ref cover) => { + self.session.spclient() + .get_image(&cover.id) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e)) + } + None => Err(anyhow::anyhow!("No cover art!")) + } + } } diff --git a/src/encoder/flac.rs b/src/encoder/flac.rs index cbd31b1..cc1a0e3 100644 --- a/src/encoder/flac.rs +++ b/src/encoder/flac.rs @@ -1,6 +1,7 @@ use flacenc::component::BitRepr; use flacenc::error::Verify; +use bytes::Bytes; use super::Encoder; use super::Samples; @@ -9,7 +10,12 @@ pub struct FlacEncoder; #[async_trait::async_trait] impl Encoder for FlacEncoder { - async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, output_path: &str) -> anyhow::Result<()> { + async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, cover_image_bytes: Bytes, output_path: &str) -> anyhow::Result<()> { + + if !cover_image_bytes.is_empty() { + tracing::info!("Cover image found but not implemented in flac encoder"); + } + let file_name = &metadata.track_name; tracing::info!("Writing track: {:?} to file: {}", file_name, output_path); let source = flacenc::source::MemSource::from_samples( diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index b692de4..b5248f9 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -2,8 +2,8 @@ mod flac; #[cfg(feature = "mp3")] mod mp3; +use bytes::Bytes; use std::{path::Path, str::FromStr}; - use anyhow::Result; use tokio::sync::oneshot::Sender; @@ -55,7 +55,7 @@ pub fn get_encoder(format: Format) -> &'static dyn Encoder { #[async_trait::async_trait] pub trait Encoder: Sync { - async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, output_path: &str) -> Result<()>; + async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, cover_image_bytes: Bytes, output_path: &str) -> Result<()>; } pub struct Samples { diff --git a/src/encoder/mp3.rs b/src/encoder/mp3.rs index 6ba62e7..53b9fab 100644 --- a/src/encoder/mp3.rs +++ b/src/encoder/mp3.rs @@ -4,6 +4,7 @@ use mp3lame_encoder::Builder; use mp3lame_encoder::FlushNoGap; use mp3lame_encoder::InterleavedPcm; use id3::{Version, frame::{Picture, PictureType, Frame, Content}}; +use bytes::Bytes; use super::execute_with_result; use super::EncodedStream; @@ -40,7 +41,7 @@ impl Mp3Encoder { #[async_trait::async_trait] impl Encoder for Mp3Encoder { - async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, output_path: &str) -> anyhow::Result<()> { + async fn encode(&self, samples: &Samples, metadata: &crate::track::TrackMetadata, cover_image_bytes: Bytes, output_path: &str) -> anyhow::Result<()> { let file_name = &metadata.track_name; tracing::info!("Writing track: {:?} to file: {}", file_name, output_path); let stream = self.encode_raw(samples).await?; @@ -49,21 +50,25 @@ impl Encoder for Mp3Encoder { // Embed tags using id3 crate let mut tag = id3::Tag::read_from_path(output_path).unwrap_or_else(|_| id3::Tag::new()); tag.set_title(file_name); - tag.set_album(&metadata.album.name); + let artists = metadata.artists.iter().map(|a| a.name.as_str()).collect::>().join("\0"); tag.set_artist(&artists); + tag.set_album(&metadata.album.name); + + tag.add_frame(Frame::with_content("TDRC", Content::Text(metadata.album.year.to_string()))); + // Embed cover image - if let Some(image_bytes) = metadata.cover_image.as_deref() { + if !cover_image_bytes.is_empty() { let picture = Picture { mime_type: "image/jpeg".to_string(), picture_type: PictureType::CoverFront, description: "cover".to_string(), - data: image_bytes.to_vec(), + data: cover_image_bytes.to_vec(), }; - let frame = Frame::with_content("APIC", Content::Picture(picture)); - tag.add_frame(frame); + tag.add_frame(Frame::with_content("APIC", Content::Picture(picture))); } + tag.write_to_path(output_path, Version::Id3v24)?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index fc21e97..a059e72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,7 @@ struct Opt { )] tracks: Vec, #[structopt(short = "u", long = "username", help = "Your Spotify username")] - username: String, + username: Option, #[structopt(short = "p", long = "password", help = "Your Spotify password")] password: Option, #[structopt( @@ -45,8 +45,8 @@ struct Opt { #[structopt( short = "f", long = "format", - help = "The format to download the tracks in. Default is flac.", - default_value = "flac" + help = "The format to download the tracks in. Default is mp3.", + default_value = "mp3" )] format: Format } @@ -92,8 +92,14 @@ async fn main() -> anyhow::Result<()> { eprintln!("Compression level is not supported yet. It will be ignored."); } - let session = create_session(opt.username, opt.password).await?; + let user_name = opt.username.or_else(|| { + println!("No username provided via arguments. Attempting to fetch from latest credentials cache."); + std::fs::read_to_string("credentials.json").ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("username")?.as_str().map(|s| s.to_string())) + }); + let session = create_session(user_name.unwrap(), opt.password).await?; let track = get_tracks(opt.tracks, &session).await?; let downloader = Downloader::new(session); diff --git a/src/session.rs b/src/session.rs index b8ca0d3..2f9feba 100644 --- a/src/session.rs +++ b/src/session.rs @@ -13,7 +13,8 @@ pub async fn create_session(username: String, password: Option) -> Resul cache.save_credentials(&credentials); - let (session, _) = Session::connect(session_config, credentials, Some(cache), false).await?; + let session = Session::new(session_config, Some(cache)); + session.connect(credentials, false).await?; Ok(session) } diff --git a/src/track.rs b/src/track.rs index 047a693..29ae142 100644 --- a/src/track.rs +++ b/src/track.rs @@ -15,19 +15,16 @@ pub async fn get_tracks(spotify_ids: Vec, session: &Session) -> Result = Vec::new(); for id in spotify_ids { tracing::debug!("Getting tracks for: {}", id); - let id = parse_uri_or_url(&id).ok_or(anyhow::anyhow!("Invalid track"))?; - let new_tracks = match id.audio_type { - librespot::core::spotify_id::SpotifyAudioType::Track => vec![Track::from_id(id)], - librespot::core::spotify_id::SpotifyAudioType::Podcast => vec![Track::from_id(id)], - librespot::core::spotify_id::SpotifyAudioType::NonPlayable => { - if Album::is_album(id, session).await { - Album::from_id(id).get_tracks(session).await - } else if Playlist::is_playlist(id, session).await { - Playlist::from_id(id).get_tracks(session).await - } else { - vec![] - } - } + let id = parse_uri_or_url(&id).ok_or(anyhow::anyhow!("Invalid track `{id}`"))?; + let new_tracks = match id.item_type { + librespot::core::spotify_id::SpotifyItemType::Track => vec![Track::from_id(id)], + librespot::core::spotify_id::SpotifyItemType::Episode => vec![Track::from_id(id)], + librespot::core::spotify_id::SpotifyItemType::Album => Album::from_id(id).get_tracks(session).await, + librespot::core::spotify_id::SpotifyItemType::Playlist => Playlist::from_id(id).get_tracks(session).await, + librespot::core::spotify_id::SpotifyItemType::Show => vec![], + librespot::core::spotify_id::SpotifyItemType::Artist => vec![], + librespot::core::spotify_id::SpotifyItemType::Local => vec![], + librespot::core::spotify_id::SpotifyItemType::Unknown => vec![], }; tracks.extend(new_tracks); } @@ -76,41 +73,24 @@ impl Track { } pub async fn metadata(&self, session: &Session) -> Result { - let metadata = librespot::metadata::Track::get(session, self.id) + let metadata = librespot::metadata::Track::get(session, &self.id) .await .map_err(|_| anyhow::anyhow!("Failed to get metadata"))?; let mut artists = Vec::new(); - for artist in &metadata.artists { + for artist in metadata.artists.iter() { artists.push( - librespot::metadata::Artist::get(session, *artist) + librespot::metadata::Artist::get(session, &artist.id) .await .map_err(|_| anyhow::anyhow!("Failed to get artist"))?, ); } - let album = librespot::metadata::Album::get(session, metadata.album) + let album = librespot::metadata::Album::get(session, &metadata.album.id) .await .map_err(|_| anyhow::anyhow!("Failed to get album"))?; - // Fetch cover image bytes if available - let cover_image = if let Some(cover) = album.covers.first() { - let file_id_hex = hex::encode(cover.0); - let url = format!("https://i.scdn.co/image/{}", file_id_hex); - match reqwest::get(&url).await { - Ok(response) if response.status().is_success() => { - match response.bytes().await { - Ok(bytes) => Some(bytes.to_vec()), - Err(_) => None, - } - } - _ => None, - } - } else { - None - }; - - Ok(TrackMetadata::from(metadata, artists, album, cover_image)) + Ok(TrackMetadata::from(metadata, artists, album)) } } @@ -136,19 +116,18 @@ impl Album { } pub async fn is_album(id: SpotifyId, session: &Session) -> bool { - librespot::metadata::Album::get(session, id).await.is_ok() + librespot::metadata::Album::get(session, &id).await.is_ok() } } #[async_trait::async_trait] impl TrackCollection for Album { async fn get_tracks(&self, session: &Session) -> Vec { - let album = librespot::metadata::Album::get(session, self.id) + let album = librespot::metadata::Album::get(session, &self.id) .await .expect("Failed to get album"); album - .tracks - .iter() + .tracks() .map(|track| Track::from_id(*track)) .collect() } @@ -169,7 +148,7 @@ impl Playlist { } pub async fn is_playlist(id: SpotifyId, session: &Session) -> bool { - librespot::metadata::Playlist::get(session, id) + librespot::metadata::Playlist::get(session, &id) .await .is_ok() } @@ -178,12 +157,11 @@ impl Playlist { #[async_trait::async_trait] impl TrackCollection for Playlist { async fn get_tracks(&self, session: &Session) -> Vec { - let playlist = librespot::metadata::Playlist::get(session, self.id) + let playlist = librespot::metadata::Playlist::get(session, &self.id) .await .expect("Failed to get playlist"); playlist - .tracks - .iter() + .tracks() .map(|track| Track::from_id(*track)) .collect() } @@ -195,7 +173,6 @@ pub struct TrackMetadata { pub track_name: String, pub album: AlbumMetadata, pub duration: i32, - pub cover_image: Option>, } impl TrackMetadata { @@ -203,7 +180,6 @@ impl TrackMetadata { track: librespot::metadata::Track, artists: Vec, album: librespot::metadata::Album, - cover_image: Option>, ) -> Self { let artists = artists .iter() @@ -217,7 +193,6 @@ impl TrackMetadata { track_name: track.name.clone(), album, duration: track.duration, - cover_image, } } } @@ -238,12 +213,16 @@ impl From for ArtistMetadata { #[derive(Clone, Debug)] pub struct AlbumMetadata { pub name: String, + pub year: i32, + pub cover: Option, } impl From for AlbumMetadata { fn from(album: librespot::metadata::Album) -> Self { AlbumMetadata { name: album.name.clone(), + year: album.date.as_utc().year(), + cover: album.covers.first().cloned() } } }