diff --git a/Cargo.lock b/Cargo.lock index 2eaaf02..d107495 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3" + [[package]] name = "async-trait" version = "0.1.56" @@ -511,6 +526,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.9.0" @@ -601,12 +637,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" - [[package]] name = "futures" version = "0.3.21" @@ -672,15 +702,6 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" -[[package]] -name = "futures-state-stream" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2776ce933858e98061287622bf43051a7122ce7aa9ac02459ff2d4b9957e2191" -dependencies = [ - "futures 0.1.31", -] - [[package]] name = "futures-task" version = "0.3.21" @@ -908,7 +929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca815a891b24fdfb243fa3239c86154392b0953ee584aa1a2a1f66d20cbe75cc" dependencies = [ "bytes", - "futures 0.3.21", + "futures", "headers", "http", "hyper", @@ -1115,6 +1136,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.4.2", + "libc", +] + [[package]] name = "librespot" version = "0.4.2" @@ -1344,6 +1375,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.9" @@ -1654,6 +1694,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.11.2" @@ -1928,6 +1974,17 @@ dependencies = [ "bitflags 2.4.2", ] +[[package]] +name = "redox_users" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.7.1" @@ -1939,6 +1996,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.6.28" @@ -2121,6 +2187,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.0" @@ -2176,19 +2251,23 @@ dependencies = [ [[package]] name = "spotify-dl" -version = "0.1.3" +version = "0.2.0" dependencies = [ + "anyhow", + "async-trait", "audiotags", + "dirs", "flac-bound", - "futures 0.1.31", - "futures-state-stream", "indicatif", + "lazy_static", "librespot", "machine-uid", "regex", "rpassword 7.3.1", "structopt", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -2327,6 +2406,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.1.43" @@ -2368,6 +2457,7 @@ dependencies = [ "signal-hook-registry", "socket2 0.5.7", "tokio-macros", + "tracing", "windows-sys 0.48.0", ] @@ -2429,16 +2519,58 @@ checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" dependencies = [ "cfg-if", "pin-project-lite", + "tracing-attributes", "tracing-core", ] [[package]] -name = "tracing-core" -version = "0.1.27" +name = "tracing-attributes" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7709595b8878a4965ce5e87ebf880a7d39c9afc6837721b21a5a816a8117d921" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60db860322da191b40952ad9affe65ea23e7dd6a5c442c2c42865810c6ab8e6b" +dependencies = [ + "ansi_term", + "matchers", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2507,6 +2639,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vergen" version = "3.2.0" diff --git a/Cargo.toml b/Cargo.toml index 8143d1e..2916355 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,27 @@ [package] name = "spotify-dl" -version = "0.1.3" -authors = ["Guillem Castro ", "Schreifuchs "] +version = "0.2.0" +authors = ["Guillem Castro "] edition = "2021" readme = "README.md" -licence = "MIT" -#links = "FLAC" +license = "MIT" [dependencies] -futures = "0.1" -futures-state-stream = "0.1" structopt = { version = "0.3", default-features = false } rpassword = "7.0" indicatif = "0.17" librespot = "0.4.2" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "tracing"] } flac-bound = { version = "0.3.0", default-features = false, features = ["libflac-noogg"] } audiotags = "0.5" regex = "1.7.1" machine-uid = "0.5.1" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } +lazy_static = "1.4" +async-trait = "0.1" +dirs = "5.0" [package.metadata.deb] depends="libflac-dev" - -# [build-dependencies] -# pkg-config = "0.3.16" diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..e22d6b7 --- /dev/null +++ b/src/download.rs @@ -0,0 +1,179 @@ +use std::fmt::Write; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use indicatif::MultiProgress; +use indicatif::ProgressBar; +use indicatif::ProgressState; +use indicatif::ProgressStyle; +use librespot::core::session::Session; +use librespot::playback::config::PlayerConfig; +use librespot::playback::mixer::NoOpVolume; +use librespot::playback::mixer::VolumeGetter; +use librespot::playback::player::Player; + +use crate::file_sink::FileSink; +use crate::file_sink::SinkEvent; +use crate::track::Track; +use crate::track::TrackMetadata; + +pub struct Downloader { + player_config: PlayerConfig, + session: Session, + progress_bar: MultiProgress, +} + +#[derive(Debug, Clone)] +pub struct DownloadOptions { + pub destination: PathBuf, + pub compression: Option, + pub parallel: usize, +} + +impl DownloadOptions { + pub fn new(destination: Option, compression: Option, parallel: usize) -> Self { + let destination = + destination.map_or_else(|| std::env::current_dir().unwrap(), PathBuf::from); + DownloadOptions { + destination, + compression, + parallel, + } + } +} + +impl Downloader { + pub fn new(session: Session) -> Self { + Downloader { + player_config: PlayerConfig::default(), + session, + progress_bar: MultiProgress::new(), + } + } + + pub async fn download_tracks( + self, + tracks: Vec, + options: &DownloadOptions, + ) -> Result<()> { + let this = Arc::new(self); + + let chunks = tracks.chunks(options.parallel); + for chunk in chunks { + let mut tasks = Vec::new(); + for track in chunk { + let t = track.clone(); + let downloader = this.clone(); + let options = options.clone(); + tasks.push(tokio::spawn(async move { + downloader.download_track(t, &options).await + })); + } + for task in tasks { + task.await??; + } + } + + Ok(()) + } + + #[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); + + let file_name = self.get_file_name(&metadata); + let path = options + .destination + .join(file_name.clone()) + .with_extension("flac") + .to_str() + .ok_or(anyhow::anyhow!("Could not set the output path"))? + .to_string(); + + let (file_sink, mut sink_channel) = FileSink::new(path.to_string(), metadata); + + let file_size = file_sink.get_approximate_size(); + + let (mut player, _) = Player::new( + self.player_config.clone(), + self.session.clone(), + self.volume_getter(), + move || Box::new(file_sink), + ); + + let pb = self.progress_bar.add(ProgressBar::new(file_size as u64)); + pb.set_style(ProgressStyle::with_template("{spinner:.green} {msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")? + .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()) + .progress_chars("#>-")); + pb.set_message(file_name.clone()); + + player.load(track.id, true, 0); + + let name = file_name.clone(); + tokio::spawn(async move { + while let Some(event) = sink_channel.recv().await { + match event { + SinkEvent::Written { bytes, total } => { + tracing::trace!("Written {} bytes out of {}", bytes, total); + pb.set_position(bytes as u64); + } + SinkEvent::Finished => { + pb.finish_with_message(format!("Downloaded {}", name)); + } + } + } + }); + + player.await_end_of_track().await; + player.stop(); + + tracing::info!("Downloaded track: {:?}", file_name); + Ok(()) + } + + fn volume_getter(&self) -> Box { + Box::new(NoOpVolume) + } + + fn get_file_name(&self, metadata: &TrackMetadata) -> String { + // If there is more than 3 artists, add the first 3 and add "and others" at the end + if metadata.artists.len() > 3 { + let artists_name = metadata + .artists + .iter() + .take(3) + .map(|artist| artist.name.clone()) + .collect::>() + .join(", "); + return self.clean_file_name(format!( + "{}, and others - {}", + artists_name, metadata.track_name + )); + } + + let artists_name = metadata + .artists + .iter() + .map(|artist| artist.name.clone()) + .collect::>() + .join(", "); + self.clean_file_name(format!("{} - {}", artists_name, metadata.track_name)) + } + + fn clean_file_name(&self, file_name: String) -> String { + let invalid_chars = ['<', '>', ':', '\'', '"', '/', '\\', '|', '?', '*']; + let mut clean = String::new(); + + // Everything but Windows should allow non-ascii characters + let allows_non_ascii = !cfg!(windows); + for c in file_name.chars() { + if !invalid_chars.contains(&c) && (c.is_ascii() || allows_non_ascii) && !c.is_control() + { + clean.push(c); + } + } + clean + } +} diff --git a/src/file_sink.rs b/src/file_sink.rs index f8de500..04121ce 100644 --- a/src/file_sink.rs +++ b/src/file_sink.rs @@ -1,44 +1,60 @@ use std::path::Path; -use audiotags::{Tag, TagType}; -use librespot::playback::{ - audio_backend::{Open, Sink, SinkError}, - config::AudioFormat, - convert::Converter, - decoder::AudioPacket, -}; - -// extern crate flac_bound; - +use audiotags::Tag; +use audiotags::TagType; +use librespot::playback::audio_backend::Sink; +use librespot::playback::audio_backend::SinkError; +use librespot::playback::convert::Converter; +use librespot::playback::decoder::AudioPacket; use flac_bound::FlacEncoder; -use crate::TrackMetadata; +use crate::track::TrackMetadata; + +pub enum SinkEvent { + Written { bytes: usize, total: usize }, + Finished, +} +pub type SinkEventChannel = tokio::sync::mpsc::UnboundedReceiver; pub struct FileSink { sink: String, content: Vec, - metadata: Option, + metadata: TrackMetadata, compression: u32, + event_sender: tokio::sync::mpsc::UnboundedSender, } impl FileSink { - pub fn add_metadata(&mut self, meta: TrackMetadata) { - self.metadata = Some(meta); - } pub fn set_compression(&mut self, compression: u32) { self.compression = compression; } -} -impl Open for FileSink { - fn open(path: Option, _audio_format: AudioFormat) -> Self { - let file_path = path.unwrap_or_else(|| panic!()); - FileSink { - sink: file_path, - content: Vec::new(), - metadata: None, - compression: 4, - } + pub fn new(path: String, track: TrackMetadata) -> (Self, SinkEventChannel) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + + ( + FileSink { + sink: path, + content: Vec::new(), + metadata: track, + compression: 4, + event_sender: tx, + }, + rx, + ) + } + + pub fn get_approximate_size(&self) -> usize { + self.convert_track_duration_to_size() + } + + fn convert_track_duration_to_size(&self) -> usize { + let duration = self.metadata.duration / 1000; + let sample_rate = 44100; + let channels = 2; + let bits_per_sample = 16; + let bytes_per_sample = bits_per_sample / 8; + (duration as usize) * sample_rate * channels * bytes_per_sample * 2 } } @@ -48,42 +64,60 @@ impl Sink for FileSink { } fn stop(&mut self) -> Result<(), SinkError> { + tracing::info!("Writing to file: {:?}", &self.sink); let mut encoder = FlacEncoder::new() - .unwrap() + .ok_or(SinkError::OnWrite( + "Failed to create flac encoder".to_string(), + ))? .channels(2) .bits_per_sample(16) - .compression_level(*&self.compression) + .compression_level(self.compression) .init_file(&self.sink) - .unwrap(); + .map_err(|e| { + SinkError::OnWrite(format!("Failed to init flac encoder: {:?}", e).to_string()) + })?; encoder .process_interleaved(self.content.as_slice(), (self.content.len() / 2) as u32) - .unwrap(); - encoder.finish().unwrap(); + .map_err(|_| SinkError::OnWrite("Failed to write flac".to_string()))?; + encoder + .finish() + .map_err(|_| SinkError::OnWrite("Failed to finish encondig".to_string()))?; - match &self.metadata { - Some(meta) => { - let mut tag = Tag::new() - .with_tag_type(TagType::Flac) - .read_from_path(Path::new(&self.sink)) - .unwrap(); + let mut tag = Tag::new() + .with_tag_type(TagType::Flac) + .read_from_path(Path::new(&self.sink)) + .map_err(|_| SinkError::OnWrite("Failed to read metadata".to_string()))?; - tag.set_album_title(&meta.album); - for artist in &meta.artists { - tag.add_artist(artist); - } - tag.set_title(&meta.track_name); - tag.write_to_path(&self.sink) - .expect("Failed to write metadata"); - } - None => (), + tag.set_album_title(&self.metadata.album.name); + for artist in &self.metadata.artists { + tag.add_artist(&artist.name); } + tag.set_title(&self.metadata.track_name); + tag.write_to_path(&self.sink) + .map_err(|_| SinkError::OnWrite("Failed to write metadata".to_string()))?; + + self.event_sender + .send(SinkEvent::Finished) + .map_err(|_| SinkError::OnWrite("Failed to send finished event".to_string()))?; Ok(()) } fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> Result<(), SinkError> { - let data = converter.f64_to_s16(packet.samples().unwrap()); + let data = converter.f64_to_s16( + packet + .samples() + .map_err(|_| SinkError::OnWrite("Failed to get samples".to_string()))?, + ); let mut data32: Vec = data.iter().map(|el| i32::from(*el)).collect(); self.content.append(&mut data32); + + self.event_sender + .send(SinkEvent::Written { + bytes: self.content.len() * std::mem::size_of::(), + total: self.convert_track_duration_to_size(), + }) + .map_err(|_| SinkError::OnWrite("Failed to send event".to_string()))?; + Ok(()) } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8738005 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod download; +pub mod file_sink; +pub mod session; +pub mod track; diff --git a/src/main.rs b/src/main.rs index 4aa6ed2..0baea4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,10 @@ -mod file_sink; - -extern crate rpassword; - -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; - -use librespot::core::config::SessionConfig; -use librespot::core::session::Session; -use librespot::core::spotify_id::SpotifyId; -use librespot::playback::config::PlayerConfig; -use librespot::playback::mixer::NoOpVolume; -use librespot::{core::authentication::Credentials, metadata::Playlist}; - -use librespot::playback::audio_backend::Open; -use librespot::playback::player::Player; - -use librespot::metadata::{Album, Artist, Metadata, Track}; - -use regex::Regex; +use spotify_dl::download::{DownloadOptions, Downloader}; +use spotify_dl::session::create_session; +use spotify_dl::track::get_tracks; use structopt::StructOpt; - -use indicatif::{ProgressBar, ProgressStyle}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{fmt, EnvFilter}; #[derive(Debug, StructOpt)] #[structopt( @@ -29,7 +12,10 @@ use indicatif::{ProgressBar, ProgressStyle}; about = "A commandline utility to download music directly from Spotify" )] struct Opt { - #[structopt(help = "A list of Spotify URIs (songs, podcasts, playlists or albums)")] + #[structopt( + help = "A list of Spotify URIs or URLs (songs, podcasts, playlists or albums)", + required = true + )] tracks: Vec, #[structopt(short = "u", long = "username", help = "Your Spotify username")] username: String, @@ -38,16 +24,9 @@ struct Opt { #[structopt( short = "d", long = "destination", - default_value = ".", help = "The directory where the songs will be downloaded" )] - destination: String, - #[structopt( - short = "o", - long = "ordered", - help = "Prefixing the filename with its index in the playlist" - )] - ordered: bool, + destination: Option, #[structopt( short = "c", long = "compression", @@ -55,202 +34,53 @@ struct Opt { 8 (slowest, most compression). A value larger than 8 will be Treated as 8. Default is 4." )] compression: Option, + #[structopt( + short = "t", + long = "parallel", + help = "Number of parallel downloads. Default is 5.", + default_value = "5" + )] + parallel: usize, } -#[derive(Clone)] -pub struct TrackMetadata { - artists: Vec, - track_name: String, - album: String, +pub fn configure_logger() { + tracing_subscriber::registry() + .with(fmt::layer()) + .with(EnvFilter::from_default_env()) + .init(); } -async fn create_session(credentials: Credentials) -> Session { - let mut session_config = SessionConfig::default(); - session_config.device_id = machine_uid::get().unwrap(); - let (session, _) = Session::connect(session_config, credentials, None, true) - .await - .unwrap(); - session -} - -fn make_filename_compatible(filename: &str) -> String { - let invalid_chars = ['<', '>', ':', '\'', '"', '/', '\\', '|', '?', '*']; - let mut clean = String::new(); - for c in filename.chars() { - if !invalid_chars.contains(&c) && c.is_ascii() && !c.is_control() && c.len_utf8() == 1 { - clean.push(c); - } - } - clean -} - -async fn download_tracks( - session: &Session, - destination: PathBuf, - tracks: Vec, - ordered: bool, - compression: Option, -) { - let player_config = PlayerConfig::default(); - let bar_style = ProgressStyle::default_bar() - .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} (ETA: {eta}) {msg}").unwrap() - .progress_chars("##-"); - let bar = ProgressBar::new(tracks.len() as u64); - bar.set_style(bar_style); - bar.enable_steady_tick(Duration::from_millis(100)); - - for (i, track) in tracks.iter().enumerate() { - let track_item = Track::get(&session, *track).await.unwrap(); - let artist_name: String; - - let mut metadata = TrackMetadata { - artists: Vec::new(), - track_name: track_item.name, - album: Album::get(session, track_item.album).await.unwrap().name, - }; - if track_item.artists.len() > 1 { - let mut tmp: String = String::new(); - for artist in track_item.artists { - let artist_item = Artist::get(&session, artist).await.unwrap(); - metadata.artists.push(artist_item.name.clone()); - tmp.push_str(artist_item.name.as_str()); - tmp.push_str(", "); - } - artist_name = String::from(tmp.trim_end_matches(", ")); - } else { - artist_name = Artist::get(&session, track_item.artists[0]) - .await - .unwrap() - .name; - metadata.artists.push(artist_name.clone()); - } - - let full_track_name = format!("{} - {}", artist_name, metadata.track_name); - let full_track_name_clean = make_filename_compatible(full_track_name.as_str()); - //let filename = format!("{}.flac", full_track_name_clean); - let filename: String; - if ordered { - filename = format!("{:03} - {}.flac", i + 1, full_track_name_clean); - } else { - filename = format!("{}.flac", full_track_name_clean); - } - let joined_path = destination.join(&filename); - let path = joined_path.to_str().unwrap(); - bar.set_message(full_track_name_clean); - - let file_name = Path::new(path).file_stem().unwrap().to_str().unwrap(); - - let path_parent = Path::new(path).parent().unwrap(); - let entries = path_parent.read_dir().unwrap(); - - let mut file_exists = false; - for entry in entries { - let entry = entry.unwrap(); - let entry_path = entry.path(); - let entry_file_name = entry_path.file_stem().unwrap().to_str().unwrap(); - if entry_file_name == file_name { - file_exists = true; - break; - } - } - - if !file_exists { - let mut file_sink = file_sink::FileSink::open( - Some(path.to_owned()), - librespot::playback::config::AudioFormat::S16, - ); - file_sink.add_metadata(metadata); - file_sink.set_compression(compression.unwrap_or(4)); - let (mut player, _) = - Player::new(player_config.clone(), session.clone(), Box::new(NoOpVolume{}), move || { - Box::new(file_sink) - }); - player.load(*track, true, 0); - player.await_end_of_track().await; - player.stop(); - bar.inc(1); - } else { - // println!("File with the same name already exists, skipping: {}", path); - bar.inc(1); - } - } - bar.finish(); -} - -async fn get_tracks_from_playlist_or_album(session: &Session, track: SpotifyId, track_url: &String) -> Vec { - match Playlist::get(&session, track).await { - Ok(playlist) => { - println!( - "Adding all songs from playlist {} (by {}) to the queue", - &playlist.name, &playlist.user - ); - return playlist.tracks; - }, - _ => () - } - - match Album::get(&session, track).await { - Ok(album) => { - println!( - "Adding all songs from album {} (by {:?}) to the queue", - &album.name, &album.artists - ); - return album.tracks; - } - Err(_) => { - println!("Unsupported URI {}", &track_url); - vec![] +pub fn create_destination_if_required(destination: Option) -> anyhow::Result<()> { + if let Some(destination) = destination { + if !std::path::Path::new(&destination).exists() { + tracing::info!("Creating destination directory: {}", destination); + std::fs::create_dir_all(destination)?; } } + Ok(()) } #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { + configure_logger(); + let opt = Opt::from_args(); + create_destination_if_required(opt.destination.clone())?; - let username = opt.username; - let password = opt - .password - .unwrap_or_else(|| rpassword::prompt_password("Password: ").unwrap()); - let credentials = Credentials::with_password(username, password); - - let session = create_session(credentials.clone()).await; - - let mut tracks: Vec = Vec::new(); - - for track_url in opt.tracks { - let track = SpotifyId::from_uri(track_url.as_str()).unwrap_or_else(|_| { - let regex = Regex::new(r"https://open.spotify.com/(\w+)/(.*)\?").unwrap(); - - let results = regex.captures(track_url.as_str()).unwrap(); - let uri = format!( - "spotify:{}:{}", - results.get(1).unwrap().as_str(), - results.get(2).unwrap().as_str() - ); - - SpotifyId::from_uri(&uri).unwrap() - }); - match &track.audio_type { - librespot::core::spotify_id::SpotifyAudioType::Track => { - tracks.push(track); - } - librespot::core::spotify_id::SpotifyAudioType::Podcast => { - tracks.push(track); - } - librespot::core::spotify_id::SpotifyAudioType::NonPlayable => { - let mut multiple_tracks = get_tracks_from_playlist_or_album(&session, track, &track_url).await; - tracks.append(&mut multiple_tracks) - } - } + if opt.tracks.is_empty() { + eprintln!("No tracks provided"); + std::process::exit(1); } - download_tracks( - &session, - PathBuf::from(opt.destination), - tracks, - opt.ordered, - opt.compression, - ) - .await; + let session = create_session(opt.username, opt.password).await?; + + let track = get_tracks(opt.tracks, &session).await?; + + let downloader = Downloader::new(session); + downloader + .download_tracks( + track, + &DownloadOptions::new(opt.destination, opt.compression, opt.parallel), + ) + .await } diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..b8ca0d3 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use librespot::core::cache::Cache; +use librespot::core::config::SessionConfig; +use librespot::core::session::Session; +use librespot::discovery::Credentials; + +pub async fn create_session(username: String, password: Option) -> Result { + let credentials_store = dirs::home_dir().map(|p| p.join(".spotify-dl")); + let cache = Cache::new(credentials_store, None, None, None)?; + + let session_config = SessionConfig::default(); + let credentials = get_credentials(username, password, &cache); + + cache.save_credentials(&credentials); + + let (session, _) = Session::connect(session_config, credentials, Some(cache), false).await?; + Ok(session) +} + +fn prompt_password() -> Result { + tracing::info!("Spotify password was not provided. Please enter your Spotify password below"); + rpassword::prompt_password("Password: ").map_err(|e| e.into()) +} + +fn get_credentials(username: String, password: Option, cache: &Cache) -> Credentials { + match password { + Some(password) => Credentials::with_password(username, password), + None => cache.credentials().unwrap_or_else(|| { + tracing::warn!("No credentials found in cache"); + Credentials::with_password( + username, + prompt_password().unwrap_or_else(|_| panic!("Failed to get password")), + ) + }), + } +} diff --git a/src/track.rs b/src/track.rs new file mode 100644 index 0000000..5f3ff85 --- /dev/null +++ b/src/track.rs @@ -0,0 +1,225 @@ +use anyhow::Result; +use lazy_static::lazy_static; +use librespot::core::session::Session; +use librespot::core::spotify_id::SpotifyId; +use librespot::metadata::Metadata; +use regex::Regex; + +#[async_trait::async_trait] +trait TrackCollection { + async fn get_tracks(&self, session: &Session) -> Vec; +} + +#[tracing::instrument(name = "get_tracks", skip(session), level = "debug")] +pub async fn get_tracks(spotify_ids: Vec, session: &Session) -> Result> { + let mut tracks: Vec = Vec::new(); + for id in spotify_ids { + 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![] + } + } + }; + tracks.extend(new_tracks); + } + tracing::debug!("Got tracks: {:?}", tracks); + Ok(tracks) +} + +fn parse_uri_or_url(track: &str) -> Option { + parse_uri(track).or_else(|| parse_url(track)) +} + +fn parse_uri(track_uri: &str) -> Option { + SpotifyId::from_uri(track_uri).ok() +} + +fn parse_url(track_url: &str) -> Option { + let results = SPOTIFY_URL_REGEX.captures(track_url)?; + let uri = format!( + "spotify:{}:{}", + results.get(1)?.as_str(), + results.get(2)?.as_str() + ); + SpotifyId::from_uri(&uri).ok() +} + +#[derive(Clone, Debug)] +pub struct Track { + pub id: SpotifyId, +} + +lazy_static! { + static ref SPOTIFY_URL_REGEX: Regex = + Regex::new(r"https://open.spotify.com/(\w+)/(.*)\?").unwrap(); +} + +impl Track { + pub fn new(track: &str) -> Result { + let id = parse_uri_or_url(track).ok_or(anyhow::anyhow!("Invalid track"))?; + Ok(Track { id }) + } + + pub fn from_id(id: SpotifyId) -> Self { + Track { id } + } + + pub async fn metadata(&self, session: &Session) -> Result { + 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 { + artists.push( + librespot::metadata::Artist::get(session, *artist) + .await + .map_err(|_| anyhow::anyhow!("Failed to get artist"))?, + ); + } + + let album = librespot::metadata::Album::get(session, metadata.album) + .await + .map_err(|_| anyhow::anyhow!("Failed to get album"))?; + + Ok(TrackMetadata::from(metadata, artists, album)) + } +} + +#[async_trait::async_trait] +impl TrackCollection for Track { + async fn get_tracks(&self, _session: &Session) -> Vec { + vec![self.clone()] + } +} + +pub struct Album { + id: SpotifyId, +} + +impl Album { + pub fn new(album: &str) -> Result { + let id = parse_uri_or_url(album).ok_or(anyhow::anyhow!("Invalid album"))?; + Ok(Album { id }) + } + + pub fn from_id(id: SpotifyId) -> Self { + Album { id } + } + + pub async fn is_album(id: SpotifyId, session: &Session) -> bool { + 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) + .await + .expect("Failed to get album"); + album + .tracks + .iter() + .map(|track| Track::from_id(*track)) + .collect() + } +} + +pub struct Playlist { + id: SpotifyId, +} + +impl Playlist { + pub fn new(playlist: &str) -> Result { + let id = parse_uri_or_url(playlist).ok_or(anyhow::anyhow!("Invalid playlist"))?; + Ok(Playlist { id }) + } + + pub fn from_id(id: SpotifyId) -> Self { + Playlist { id } + } + + pub async fn is_playlist(id: SpotifyId, session: &Session) -> bool { + librespot::metadata::Playlist::get(session, id) + .await + .is_ok() + } +} + +#[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) + .await + .expect("Failed to get playlist"); + playlist + .tracks + .iter() + .map(|track| Track::from_id(*track)) + .collect() + } +} + +#[derive(Clone, Debug)] +pub struct TrackMetadata { + pub artists: Vec, + pub track_name: String, + pub album: AlbumMetadata, + pub duration: i32, +} + +impl TrackMetadata { + pub fn from( + track: librespot::metadata::Track, + artists: Vec, + album: librespot::metadata::Album, + ) -> Self { + let artists = artists + .iter() + .map(|artist| ArtistMetadata::from(artist.clone())) + .collect(); + let album = AlbumMetadata::from(album); + + TrackMetadata { + artists, + track_name: track.name.clone(), + album, + duration: track.duration, + } + } +} + +#[derive(Clone, Debug)] +pub struct ArtistMetadata { + pub name: String, +} + +impl From for ArtistMetadata { + fn from(artist: librespot::metadata::Artist) -> Self { + ArtistMetadata { + name: artist.name.clone(), + } + } +} + +#[derive(Clone, Debug)] +pub struct AlbumMetadata { + pub name: String, +} + +impl From for AlbumMetadata { + fn from(album: librespot::metadata::Album) -> Self { + AlbumMetadata { + name: album.name.clone(), + } + } +}