commit 3fc874ce348fc3ce933de914de0b4605100eab15 Author: Guillem Castro Date: Sun Oct 25 01:39:20 2020 +0200 Add files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5c83d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/target +.vscode +*.flac + +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e623649 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "librespot"] + path = librespot + url = https://github.com/librespot-org/librespot diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5fc25a5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "spotify-dl" +version = "0.1.0" +authors = ["Guillem Castro "] +edition = "2018" + +[dependencies] +librespot = { path = "./librespot" } +tokio-core = "0.1.17" +futures = "0.1" +futures-state-stream = "0.1" +structopt = { version = "0.3", default-features = false } +rpassword = "5.0" +indicatif = "0.15.0" + +[dependencies.flac-bound] +version = "0.2.0" + +[package.metadata.deb] +depends="libflac-dev" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34d360c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Guillem Castro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da43301 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# spotify-dl + +A command line utility to download songs and playlists directly from Spotify's servers. + +You need a Spotify Premium account. + +## Usage + +``` +spotify-dl 0.1.0 +A commandline utility to download music directly from Spotify + +USAGE: + spotify-dl [OPTIONS] --username [tracks]... + +FLAGS: + -h, --help Prints help information + -V, --version Prints version information + +OPTIONS: + -d, --destination The directory where the songs will be downloaded [default: .] + -u, --username Your Spotify username + +ARGS: + ... A list of Spotify URIs (songs, podcasts or playlists) +``` + +Songs and playlists must be passed as Spotify URIs (e.g. `spotify:track:123456789abcdefghABCDEF` for songs and `spotify:playlist:123456789abcdefghABCDEF` for playlists). + +## License + +spotify-dl is licensed under the MIT license. See [LICENSE](LICENSE). \ No newline at end of file diff --git a/librespot b/librespot new file mode 160000 index 0000000..1b1c22b --- /dev/null +++ b/librespot @@ -0,0 +1 @@ +Subproject commit 1b1c22b6bc1e330fef731f5b3bc3b130d52a5bff diff --git a/src/file_sink.rs b/src/file_sink.rs new file mode 100644 index 0000000..b6180f7 --- /dev/null +++ b/src/file_sink.rs @@ -0,0 +1,44 @@ +use librespot::playback::audio_backend::{Open, Sink}; +use std::io::{self}; + +extern crate flac_bound; + +use flac_bound::{FlacEncoder}; + +pub struct FileSink { + sink: String, + content: Vec +} + +impl Open for FileSink { + fn open(path: Option) -> Self { + if let Some(path) = path { + let file = path; + FileSink { + sink: file, + content: Vec::new() + } + } else { + panic!(); + } + } +} + +impl Sink for FileSink { + fn start(&mut self) -> io::Result<()> { + Ok(()) + } + + fn stop(&mut self) -> io::Result<()> { + let mut encoder = FlacEncoder::new().unwrap().channels(2).bits_per_sample(16).compression_level(0).init_file(&self.sink).unwrap(); + encoder.process_interleaved(self.content.as_slice(), (self.content.len()/2) as u32).unwrap(); + encoder.finish().unwrap(); + Ok(()) + } + + fn write(&mut self, data: &[i16]) -> io::Result<()> { + let mut input: Vec = data.iter().map(|el| i32::from(*el)).collect(); + self.content.append(&mut input); + Ok(()) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2d5a06e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,120 @@ +mod file_sink; + +extern crate rpassword; + +use std::{path::PathBuf}; +use tokio_core::reactor::Core; + +use librespot::{core::authentication::Credentials, metadata::Playlist}; +use librespot::core::config::SessionConfig; +use librespot::core::session::Session; +use librespot::core::spotify_id::SpotifyId; +use librespot::playback::config::PlayerConfig; + +use librespot::playback::player::Player; +use librespot::playback::audio_backend::{Open}; + +use librespot::metadata::{Track, Artist, Metadata}; + +use structopt::StructOpt; + +use indicatif::{ProgressBar, ProgressStyle}; + +#[derive(Debug, StructOpt)] +#[structopt(name = "spotify-dl", about = "A commandline utility to download music directly from Spotify")] +struct Opt { + #[structopt(help = "A list of Spotify URIs (songs, podcasts or playlists)")] + tracks: Vec, + #[structopt(short = "u", long = "username", help = "Your Spotify username")] + username: String, + #[structopt(short = "d", long = "destination", default_value = ".", help = "The directory where the songs will be downloaded")] + destination: String +} + +fn create_session(core: &mut Core, credentials: Credentials) -> Session { + let session_config = SessionConfig::default(); + let session = core.run(Session::connect(session_config, credentials, None, core.handle())) + .unwrap(); + session +} + +fn download_tracks(core: &mut Core, session: &Session, destination: PathBuf, tracks: Vec) { + 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}") + .progress_chars("##-"); + let bar = ProgressBar::new(tracks.len() as u64); + bar.set_style(bar_style); + bar.enable_steady_tick(500); + for track in tracks { + let track_item = core.run(Track::get(&session, track)).unwrap(); + let artist_name: String; + if track_item.artists.len() > 1 { + let mut tmp: String = String::new(); + for artist in track_item.artists { + let artist_item = core.run(Artist::get(&session, artist)).unwrap(); + tmp.push_str(artist_item.name.as_str()); + tmp.push_str(", "); + } + artist_name = String::from(tmp.trim_end_matches(", ")); + } + else { + artist_name = core.run(Artist::get(&session, track_item.artists[0])).unwrap().name; + } + let full_track_name = format!("{} - {}", artist_name, track_item.name); + let filename = format!("{}.flac", full_track_name); + let joined_path = destination.join(&filename); + let path = joined_path.to_str().unwrap(); + bar.set_message(full_track_name.as_str()); + let file_sink = file_sink::FileSink::open(Some(path.to_owned())); + let (mut player, _) = Player::new(player_config.clone(), session.clone(), None, move || { + Box::new(file_sink) + }); + player.load(track, true, 0); + core.run(player.get_end_of_track_future()).unwrap(); + player.stop(); + bar.inc(1); + } + bar.finish(); +} + +fn main() { + + let opt = Opt::from_args(); + + let mut core = Core::new().unwrap(); + + let username = opt.username; + let password = rpassword::read_password_from_tty(Some("Password: ")).unwrap(); + let credentials = Credentials::with_password(username, password); + + let session = create_session(&mut core, credentials.clone()); + + let mut tracks: Vec = Vec::new(); + + for track_url in opt.tracks { + let track = SpotifyId::from_uri(track_url.as_str()).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 => { + match core.run(Playlist::get(&session, track)) { + Ok(mut playlist) => { + println!("Adding all songs from playlist {} (by {}) to the queue", &playlist.name, &playlist.user); + tracks.append(&mut playlist.tracks); + } + Err(_) => { + println!("Unsupported track {}", &track_url); + } + } + } + } + } + + download_tracks(&mut core, &session, PathBuf::from(opt.destination), tracks); + +}