diff --git a/Cargo.toml b/Cargo.toml index 394a95a..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" @@ -28,6 +29,10 @@ dirs = "5.0" mp3lame-encoder = { version = "0.1.5", optional = true } futures = "0.3" 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/channel_sink.rs b/src/channel_sink.rs index ec767df..4d1e11f 100644 --- a/src/channel_sink.rs +++ b/src/channel_sink.rs @@ -19,14 +19,14 @@ pub struct ChannelSink { impl ChannelSink { - pub fn new(track: TrackMetadata) -> (Self, SinkEventChannel) { + pub fn new(track: &TrackMetadata) -> (Self, SinkEventChannel) { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); ( ChannelSink { sender: tx, bytes_sent: 0, - bytes_total: Self::convert_track_duration_to_size(&track), + bytes_total: Self::convert_track_duration_to_size(track), }, rx, ) diff --git a/src/download.rs b/src/download.rs index eea27f5..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; @@ -77,7 +78,7 @@ impl Downloader { #[tracing::instrument(name = "download_track", skip(self))] async fn download_track(&self, track: Track, options: &DownloadOptions) -> Result<()> { let metadata = track.metadata(&self.session).await?; - tracing::info!("Downloading track: {:?}", metadata); + tracing::info!("Downloading track: {:?}", metadata.track_name); let file_name = self.get_file_name(&metadata); let path = options @@ -88,11 +89,16 @@ impl Downloader { .ok_or(anyhow::anyhow!("Could not set the output path"))? .to_string(); - let (sink, mut sink_channel) = ChannelSink::new(metadata); + if std::path::Path::new(&path).exists() { + println!("File already exists, skipping: {}", path); + return Ok(()); + } + + let (sink, mut sink_channel) = ChannelSink::new(&metadata); 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(), @@ -129,15 +135,16 @@ impl Downloader { } } - tracing::info!("Encoding track: {:?}", file_name); - pb.set_message(format!("Encoding {}", &file_name)); + 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 stream = encoder.encode(samples).await?; + let output_path = &path; - pb.set_message(format!("Writing {}", &file_name)); - tracing::info!("Writing track: {:?} to file: {}", file_name, &path); - stream.write_to_file(&path).await?; + encoder.encode(&samples, &metadata, cover_image, output_path).await?; pb.finish_with_message(format!("Downloaded {}", &file_name)); Ok(()) @@ -186,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 50de691..cc1a0e3 100644 --- a/src/encoder/flac.rs +++ b/src/encoder/flac.rs @@ -1,9 +1,7 @@ -use flacenc::bitsink::ByteSink; use flacenc::component::BitRepr; use flacenc::error::Verify; -use super::execute_with_result; -use super::EncodedStream; +use bytes::Bytes; use super::Encoder; use super::Samples; @@ -12,7 +10,14 @@ pub struct FlacEncoder; #[async_trait::async_trait] impl Encoder for FlacEncoder { - async fn encode(&self, samples: Samples) -> 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( &samples.samples, samples.channels as usize, @@ -26,7 +31,7 @@ impl Encoder for FlacEncoder { let (tx, rx) = tokio::sync::oneshot::channel(); - rayon::spawn(execute_with_result( + rayon::spawn(super::execute_with_result( move || { let flac_stream = flacenc::encode_with_fixed_block_size( &config, @@ -35,7 +40,7 @@ impl Encoder for FlacEncoder { ) .map_err(|e| anyhow::anyhow!("Failed to encode flac: {:?}", e))?; - let mut byte_sink = ByteSink::new(); + let mut byte_sink = flacenc::bitsink::ByteSink::new(); flac_stream .write(&mut byte_sink) .map_err(|e| anyhow::anyhow!("Failed to write flac stream: {:?}", e))?; @@ -47,6 +52,7 @@ impl Encoder for FlacEncoder { let byte_sink: Vec = rx.await??; - Ok(EncodedStream::new(byte_sink)) + let stream = super::EncodedStream::new(byte_sink); + stream.write_to_file(output_path).await } } diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index 9e44278..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; @@ -54,8 +54,8 @@ pub fn get_encoder(format: Format) -> &'static dyn Encoder { } #[async_trait::async_trait] -pub trait Encoder { - async fn encode(&self, samples: Samples) -> Result; +pub trait Encoder: Sync { + 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 87f0098..53b9fab 100644 --- a/src/encoder/mp3.rs +++ b/src/encoder/mp3.rs @@ -3,6 +3,8 @@ use anyhow::Ok; 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; @@ -11,6 +13,8 @@ use super::Samples; pub struct Mp3Encoder; +unsafe impl Sync for Mp3Encoder {} + impl Mp3Encoder { fn build_encoder( &self, @@ -26,7 +30,7 @@ impl Mp3Encoder { anyhow::anyhow!("Failed to set number of channels for mp3 encoder: {}", e) })?; builder - .set_brate(mp3lame_encoder::Birtate::Kbps160) + .set_brate(mp3lame_encoder::Birtate::Kbps320) .map_err(|e| anyhow::anyhow!("Failed to set bitrate for mp3 encoder: {}", e))?; builder @@ -37,14 +41,50 @@ impl Mp3Encoder { #[async_trait::async_trait] impl Encoder for Mp3Encoder { - async fn encode(&self, samples: Samples) -> 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?; + stream.write_to_file(output_path).await?; + + // 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); + + 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 !cover_image_bytes.is_empty() { + let picture = Picture { + mime_type: "image/jpeg".to_string(), + picture_type: PictureType::CoverFront, + description: "cover".to_string(), + data: cover_image_bytes.to_vec(), + }; + tag.add_frame(Frame::with_content("APIC", Content::Picture(picture))); + } + + tag.write_to_path(output_path, Version::Id3v24)?; + Ok(()) + } +} + +impl Mp3Encoder { + async fn encode_raw(&self, samples: &Samples) -> anyhow::Result { let mut mp3_encoder = self.build_encoder(samples.sample_rate, samples.channels)?; let (tx, rx) = tokio::sync::oneshot::channel(); + let samples_vec = samples.samples.clone(); + rayon::spawn(execute_with_result( move || { - let samples: Vec = samples.samples.iter().map(|&x| x as i16).collect(); + let samples: Vec = samples_vec.iter().map(|&x| x as i16).collect(); let input = InterleavedPcm(samples.as_slice()); let mut mp3_out_buffer = Vec::with_capacity(mp3lame_encoder::max_required_buffer_size(samples.len())); let encoded_size = mp3_encoder diff --git a/src/main.rs b/src/main.rs index 389b35a..a059e72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use structopt::StructOpt; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{fmt, EnvFilter}; +use std::io::{self, Write}; #[derive(Debug, StructOpt)] #[structopt( @@ -14,12 +15,11 @@ use tracing_subscriber::{fmt, EnvFilter}; )] struct Opt { #[structopt( - help = "A list of Spotify URIs or URLs (songs, podcasts, playlists or albums)", - required = true + help = "A list of Spotify URIs or URLs (songs, podcasts, playlists or albums)" )] 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 } @@ -72,20 +72,34 @@ pub fn create_destination_if_required(destination: Option) -> anyhow::Re async fn main() -> anyhow::Result<()> { configure_logger(); - let opt = Opt::from_args(); + let mut opt = Opt::from_args(); create_destination_if_required(opt.destination.clone())?; if opt.tracks.is_empty() { - eprintln!("No tracks provided"); - std::process::exit(1); + print!("Enter a Spotify URL or URI: "); + io::stdout().flush().unwrap(); + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); + if input.is_empty() { + eprintln!("No tracks provided"); + std::process::exit(1); + } + opt.tracks.push(input.to_string()); } if opt.compression.is_some() { 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 c96dac9..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,20 +73,20 @@ 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"))?; @@ -119,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() } @@ -152,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() } @@ -161,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() } @@ -190,6 +185,7 @@ impl TrackMetadata { .iter() .map(|artist| ArtistMetadata::from(artist.clone())) .collect(); + let album = AlbumMetadata::from(album); TrackMetadata { @@ -217,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() } } }