mirror of
https://github.com/GuillemCastro/spotify-dl.git
synced 2024-11-21 18:00:26 +01:00
commit
301bbb5623
8 changed files with 741 additions and 295 deletions
182
Cargo.lock
generated
182
Cargo.lock
generated
|
@ -91,6 +91,21 @@ dependencies = [
|
||||||
"pkg-config",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.56"
|
version = "0.1.56"
|
||||||
|
@ -511,6 +526,27 @@ dependencies = [
|
||||||
"crypto-common",
|
"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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
|
@ -601,12 +637,6 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures"
|
|
||||||
version = "0.1.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.21"
|
version = "0.3.21"
|
||||||
|
@ -672,15 +702,6 @@ version = "0.3.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
|
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]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.21"
|
version = "0.3.21"
|
||||||
|
@ -908,7 +929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca815a891b24fdfb243fa3239c86154392b0953ee584aa1a2a1f66d20cbe75cc"
|
checksum = "ca815a891b24fdfb243fa3239c86154392b0953ee584aa1a2a1f66d20cbe75cc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures 0.3.21",
|
"futures",
|
||||||
"headers",
|
"headers",
|
||||||
"http",
|
"http",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
@ -1115,6 +1136,16 @@ dependencies = [
|
||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "librespot"
|
name = "librespot"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
|
@ -1344,6 +1375,15 @@ version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
|
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]]
|
[[package]]
|
||||||
name = "matches"
|
name = "matches"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
|
@ -1654,6 +1694,12 @@ version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.11.2"
|
version = "0.11.2"
|
||||||
|
@ -1928,6 +1974,17 @@ dependencies = [
|
||||||
"bitflags 2.4.2",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.7.1"
|
version = "1.7.1"
|
||||||
|
@ -1939,6 +1996,15 @@ dependencies = [
|
||||||
"regex-syntax",
|
"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]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.6.28"
|
version = "0.6.28"
|
||||||
|
@ -2121,6 +2187,15 @@ dependencies = [
|
||||||
"byteorder",
|
"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]]
|
[[package]]
|
||||||
name = "shell-words"
|
name = "shell-words"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
@ -2176,19 +2251,23 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spotify-dl"
|
name = "spotify-dl"
|
||||||
version = "0.1.3"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"audiotags",
|
"audiotags",
|
||||||
|
"dirs",
|
||||||
"flac-bound",
|
"flac-bound",
|
||||||
"futures 0.1.31",
|
|
||||||
"futures-state-stream",
|
|
||||||
"indicatif",
|
"indicatif",
|
||||||
|
"lazy_static",
|
||||||
"librespot",
|
"librespot",
|
||||||
"machine-uid",
|
"machine-uid",
|
||||||
"regex",
|
"regex",
|
||||||
"rpassword 7.3.1",
|
"rpassword 7.3.1",
|
||||||
"structopt",
|
"structopt",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2327,6 +2406,16 @@ dependencies = [
|
||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.1.43"
|
version = "0.1.43"
|
||||||
|
@ -2368,6 +2457,7 @@ dependencies = [
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2 0.5.7",
|
"socket2 0.5.7",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -2429,16 +2519,58 @@ checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-attributes"
|
||||||
version = "0.1.27"
|
version = "0.1.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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 = [
|
dependencies = [
|
||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
|
@ -2507,6 +2639,12 @@ dependencies = [
|
||||||
"getrandom",
|
"getrandom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vergen"
|
name = "vergen"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
|
|
20
Cargo.toml
20
Cargo.toml
|
@ -1,27 +1,27 @@
|
||||||
[package]
|
[package]
|
||||||
name = "spotify-dl"
|
name = "spotify-dl"
|
||||||
version = "0.1.3"
|
version = "0.2.0"
|
||||||
authors = ["Guillem Castro <guillemcastro4@gmail.com>", "Schreifuchs <kontakt@schreifuchs.ch>"]
|
authors = ["Guillem Castro <guillemcastro4@gmail.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
licence = "MIT"
|
license = "MIT"
|
||||||
#links = "FLAC"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
futures = "0.1"
|
|
||||||
futures-state-stream = "0.1"
|
|
||||||
structopt = { version = "0.3", default-features = false }
|
structopt = { version = "0.3", default-features = false }
|
||||||
rpassword = "7.0"
|
rpassword = "7.0"
|
||||||
indicatif = "0.17"
|
indicatif = "0.17"
|
||||||
librespot = "0.4.2"
|
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"] }
|
flac-bound = { version = "0.3.0", default-features = false, features = ["libflac-noogg"] }
|
||||||
audiotags = "0.5"
|
audiotags = "0.5"
|
||||||
regex = "1.7.1"
|
regex = "1.7.1"
|
||||||
machine-uid = "0.5.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]
|
[package.metadata.deb]
|
||||||
depends="libflac-dev"
|
depends="libflac-dev"
|
||||||
|
|
||||||
# [build-dependencies]
|
|
||||||
# pkg-config = "0.3.16"
|
|
||||||
|
|
179
src/download.rs
Normal file
179
src/download.rs
Normal file
|
@ -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<u32>,
|
||||||
|
pub parallel: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadOptions {
|
||||||
|
pub fn new(destination: Option<String>, compression: Option<u32>, 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<Track>,
|
||||||
|
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<dyn VolumeGetter + Send> {
|
||||||
|
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::<Vec<String>>()
|
||||||
|
.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::<Vec<String>>()
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
126
src/file_sink.rs
126
src/file_sink.rs
|
@ -1,44 +1,60 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use audiotags::{Tag, TagType};
|
use audiotags::Tag;
|
||||||
use librespot::playback::{
|
use audiotags::TagType;
|
||||||
audio_backend::{Open, Sink, SinkError},
|
use librespot::playback::audio_backend::Sink;
|
||||||
config::AudioFormat,
|
use librespot::playback::audio_backend::SinkError;
|
||||||
convert::Converter,
|
use librespot::playback::convert::Converter;
|
||||||
decoder::AudioPacket,
|
use librespot::playback::decoder::AudioPacket;
|
||||||
};
|
|
||||||
|
|
||||||
// extern crate flac_bound;
|
|
||||||
|
|
||||||
use flac_bound::FlacEncoder;
|
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<SinkEvent>;
|
||||||
|
|
||||||
pub struct FileSink {
|
pub struct FileSink {
|
||||||
sink: String,
|
sink: String,
|
||||||
content: Vec<i32>,
|
content: Vec<i32>,
|
||||||
metadata: Option<TrackMetadata>,
|
metadata: TrackMetadata,
|
||||||
compression: u32,
|
compression: u32,
|
||||||
|
event_sender: tokio::sync::mpsc::UnboundedSender<SinkEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileSink {
|
impl FileSink {
|
||||||
pub fn add_metadata(&mut self, meta: TrackMetadata) {
|
|
||||||
self.metadata = Some(meta);
|
|
||||||
}
|
|
||||||
pub fn set_compression(&mut self, compression: u32) {
|
pub fn set_compression(&mut self, compression: u32) {
|
||||||
self.compression = compression;
|
self.compression = compression;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Open for FileSink {
|
pub fn new(path: String, track: TrackMetadata) -> (Self, SinkEventChannel) {
|
||||||
fn open(path: Option<String>, _audio_format: AudioFormat) -> Self {
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let file_path = path.unwrap_or_else(|| panic!());
|
|
||||||
FileSink {
|
(
|
||||||
sink: file_path,
|
FileSink {
|
||||||
content: Vec::new(),
|
sink: path,
|
||||||
metadata: None,
|
content: Vec::new(),
|
||||||
compression: 4,
|
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> {
|
fn stop(&mut self) -> Result<(), SinkError> {
|
||||||
|
tracing::info!("Writing to file: {:?}", &self.sink);
|
||||||
let mut encoder = FlacEncoder::new()
|
let mut encoder = FlacEncoder::new()
|
||||||
.unwrap()
|
.ok_or(SinkError::OnWrite(
|
||||||
|
"Failed to create flac encoder".to_string(),
|
||||||
|
))?
|
||||||
.channels(2)
|
.channels(2)
|
||||||
.bits_per_sample(16)
|
.bits_per_sample(16)
|
||||||
.compression_level(*&self.compression)
|
.compression_level(self.compression)
|
||||||
.init_file(&self.sink)
|
.init_file(&self.sink)
|
||||||
.unwrap();
|
.map_err(|e| {
|
||||||
|
SinkError::OnWrite(format!("Failed to init flac encoder: {:?}", e).to_string())
|
||||||
|
})?;
|
||||||
encoder
|
encoder
|
||||||
.process_interleaved(self.content.as_slice(), (self.content.len() / 2) as u32)
|
.process_interleaved(self.content.as_slice(), (self.content.len() / 2) as u32)
|
||||||
.unwrap();
|
.map_err(|_| SinkError::OnWrite("Failed to write flac".to_string()))?;
|
||||||
encoder.finish().unwrap();
|
encoder
|
||||||
|
.finish()
|
||||||
|
.map_err(|_| SinkError::OnWrite("Failed to finish encondig".to_string()))?;
|
||||||
|
|
||||||
match &self.metadata {
|
let mut tag = Tag::new()
|
||||||
Some(meta) => {
|
.with_tag_type(TagType::Flac)
|
||||||
let mut tag = Tag::new()
|
.read_from_path(Path::new(&self.sink))
|
||||||
.with_tag_type(TagType::Flac)
|
.map_err(|_| SinkError::OnWrite("Failed to read metadata".to_string()))?;
|
||||||
.read_from_path(Path::new(&self.sink))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
tag.set_album_title(&meta.album);
|
tag.set_album_title(&self.metadata.album.name);
|
||||||
for artist in &meta.artists {
|
for artist in &self.metadata.artists {
|
||||||
tag.add_artist(artist);
|
tag.add_artist(&artist.name);
|
||||||
}
|
|
||||||
tag.set_title(&meta.track_name);
|
|
||||||
tag.write_to_path(&self.sink)
|
|
||||||
.expect("Failed to write metadata");
|
|
||||||
}
|
|
||||||
None => (),
|
|
||||||
}
|
}
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> Result<(), SinkError> {
|
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<i32> = data.iter().map(|el| i32::from(*el)).collect();
|
let mut data32: Vec<i32> = data.iter().map(|el| i32::from(*el)).collect();
|
||||||
self.content.append(&mut data32);
|
self.content.append(&mut data32);
|
||||||
|
|
||||||
|
self.event_sender
|
||||||
|
.send(SinkEvent::Written {
|
||||||
|
bytes: self.content.len() * std::mem::size_of::<i32>(),
|
||||||
|
total: self.convert_track_duration_to_size(),
|
||||||
|
})
|
||||||
|
.map_err(|_| SinkError::OnWrite("Failed to send event".to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod download;
|
||||||
|
pub mod file_sink;
|
||||||
|
pub mod session;
|
||||||
|
pub mod track;
|
264
src/main.rs
264
src/main.rs
|
@ -1,27 +1,10 @@
|
||||||
mod file_sink;
|
use spotify_dl::download::{DownloadOptions, Downloader};
|
||||||
|
use spotify_dl::session::create_session;
|
||||||
extern crate rpassword;
|
use spotify_dl::track::get_tracks;
|
||||||
|
|
||||||
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 structopt::StructOpt;
|
use structopt::StructOpt;
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::{fmt, EnvFilter};
|
||||||
|
|
||||||
#[derive(Debug, StructOpt)]
|
#[derive(Debug, StructOpt)]
|
||||||
#[structopt(
|
#[structopt(
|
||||||
|
@ -29,7 +12,10 @@ use indicatif::{ProgressBar, ProgressStyle};
|
||||||
about = "A commandline utility to download music directly from Spotify"
|
about = "A commandline utility to download music directly from Spotify"
|
||||||
)]
|
)]
|
||||||
struct Opt {
|
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<String>,
|
tracks: Vec<String>,
|
||||||
#[structopt(short = "u", long = "username", help = "Your Spotify username")]
|
#[structopt(short = "u", long = "username", help = "Your Spotify username")]
|
||||||
username: String,
|
username: String,
|
||||||
|
@ -38,16 +24,9 @@ struct Opt {
|
||||||
#[structopt(
|
#[structopt(
|
||||||
short = "d",
|
short = "d",
|
||||||
long = "destination",
|
long = "destination",
|
||||||
default_value = ".",
|
|
||||||
help = "The directory where the songs will be downloaded"
|
help = "The directory where the songs will be downloaded"
|
||||||
)]
|
)]
|
||||||
destination: String,
|
destination: Option<String>,
|
||||||
#[structopt(
|
|
||||||
short = "o",
|
|
||||||
long = "ordered",
|
|
||||||
help = "Prefixing the filename with its index in the playlist"
|
|
||||||
)]
|
|
||||||
ordered: bool,
|
|
||||||
#[structopt(
|
#[structopt(
|
||||||
short = "c",
|
short = "c",
|
||||||
long = "compression",
|
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."
|
8 (slowest, most compression). A value larger than 8 will be Treated as 8. Default is 4."
|
||||||
)]
|
)]
|
||||||
compression: Option<u32>,
|
compression: Option<u32>,
|
||||||
|
#[structopt(
|
||||||
|
short = "t",
|
||||||
|
long = "parallel",
|
||||||
|
help = "Number of parallel downloads. Default is 5.",
|
||||||
|
default_value = "5"
|
||||||
|
)]
|
||||||
|
parallel: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
pub fn configure_logger() {
|
||||||
pub struct TrackMetadata {
|
tracing_subscriber::registry()
|
||||||
artists: Vec<String>,
|
.with(fmt::layer())
|
||||||
track_name: String,
|
.with(EnvFilter::from_default_env())
|
||||||
album: String,
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_session(credentials: Credentials) -> Session {
|
pub fn create_destination_if_required(destination: Option<String>) -> anyhow::Result<()> {
|
||||||
let mut session_config = SessionConfig::default();
|
if let Some(destination) = destination {
|
||||||
session_config.device_id = machine_uid::get().unwrap();
|
if !std::path::Path::new(&destination).exists() {
|
||||||
let (session, _) = Session::connect(session_config, credentials, None, true)
|
tracing::info!("Creating destination directory: {}", destination);
|
||||||
.await
|
std::fs::create_dir_all(destination)?;
|
||||||
.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<SpotifyId>,
|
|
||||||
ordered: bool,
|
|
||||||
compression: Option<u32>,
|
|
||||||
) {
|
|
||||||
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<SpotifyId> {
|
|
||||||
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![]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
configure_logger();
|
||||||
|
|
||||||
let opt = Opt::from_args();
|
let opt = Opt::from_args();
|
||||||
|
create_destination_if_required(opt.destination.clone())?;
|
||||||
|
|
||||||
let username = opt.username;
|
if opt.tracks.is_empty() {
|
||||||
let password = opt
|
eprintln!("No tracks provided");
|
||||||
.password
|
std::process::exit(1);
|
||||||
.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<SpotifyId> = 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
download_tracks(
|
let session = create_session(opt.username, opt.password).await?;
|
||||||
&session,
|
|
||||||
PathBuf::from(opt.destination),
|
let track = get_tracks(opt.tracks, &session).await?;
|
||||||
tracks,
|
|
||||||
opt.ordered,
|
let downloader = Downloader::new(session);
|
||||||
opt.compression,
|
downloader
|
||||||
)
|
.download_tracks(
|
||||||
.await;
|
track,
|
||||||
|
&DownloadOptions::new(opt.destination, opt.compression, opt.parallel),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
36
src/session.rs
Normal file
36
src/session.rs
Normal file
|
@ -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<String>) -> Result<Session> {
|
||||||
|
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<String> {
|
||||||
|
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<String>, 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")),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
225
src/track.rs
Normal file
225
src/track.rs
Normal file
|
@ -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<Track>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "get_tracks", skip(session), level = "debug")]
|
||||||
|
pub async fn get_tracks(spotify_ids: Vec<String>, session: &Session) -> Result<Vec<Track>> {
|
||||||
|
let mut tracks: Vec<Track> = 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<SpotifyId> {
|
||||||
|
parse_uri(track).or_else(|| parse_url(track))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uri(track_uri: &str) -> Option<SpotifyId> {
|
||||||
|
SpotifyId::from_uri(track_uri).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_url(track_url: &str) -> Option<SpotifyId> {
|
||||||
|
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<Self> {
|
||||||
|
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<TrackMetadata> {
|
||||||
|
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<Track> {
|
||||||
|
vec![self.clone()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Album {
|
||||||
|
id: SpotifyId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Album {
|
||||||
|
pub fn new(album: &str) -> Result<Self> {
|
||||||
|
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<Track> {
|
||||||
|
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<Self> {
|
||||||
|
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<Track> {
|
||||||
|
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<ArtistMetadata>,
|
||||||
|
pub track_name: String,
|
||||||
|
pub album: AlbumMetadata,
|
||||||
|
pub duration: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackMetadata {
|
||||||
|
pub fn from(
|
||||||
|
track: librespot::metadata::Track,
|
||||||
|
artists: Vec<librespot::metadata::Artist>,
|
||||||
|
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<librespot::metadata::Artist> 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<librespot::metadata::Album> for AlbumMetadata {
|
||||||
|
fn from(album: librespot::metadata::Album) -> Self {
|
||||||
|
AlbumMetadata {
|
||||||
|
name: album.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue