Compare commits
6 commits
main
...
librespot-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab4b57b4f | ||
|
|
2cbe41ad14 | ||
|
|
3a5224e440 | ||
|
|
3c1c4cad2e | ||
|
|
d98a84e1d0 | ||
|
|
ec66b5ad91 |
10 changed files with 1384 additions and 753 deletions
1997
Cargo.lock
generated
1997
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@ reqwest = "0"
|
||||||
colored = "2"
|
colored = "2"
|
||||||
lame = "0"
|
lame = "0"
|
||||||
aspotify = "0"
|
aspotify = "0"
|
||||||
librespot = { git = "ssh://git@github.com/oSumAtrIX/free-librespot.git" }
|
librespot = { git = "https://github.com/librespot-org/librespot", rev = "f647331" }
|
||||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
async-stream = "0"
|
async-stream = "0"
|
||||||
|
|
|
||||||
42
README.md
42
README.md
|
|
@ -2,41 +2,9 @@
|
||||||
|
|
||||||
# DownOnSpot
|
# DownOnSpot
|
||||||
|
|
||||||
A Spotify downloader written in Rust
|
|
||||||
|
|
||||||
<img src="assets/icon.svg" alt="drawing" width="500"/>
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
[](https://github.com/oSumAtrIX/DownOnSpot/actions/workflows/build.yml)
|
|
||||||
[](https://github.com/oSumAtrIX/DownOnSpot/blob/main/LICENSE)
|
|
||||||
[](https://github.com/oSumAtrIX/DownOnSpot/issues)
|
|
||||||
[](https://github.com/oSumAtrIX/DownOnSpot/network)
|
|
||||||
[](https://github.com/oSumAtrIX/DownOnSpot/stargazers)
|
|
||||||
[](https://masterminds.github.io/stability/experimental.html)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🆘 Help needed
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
Currently, I am [rewriting DownOnSpot](https://github.com/oSumAtrIX/DownOnSpot/pull/68).
|
|
||||||
If you want to help me accelerate this process, please feel free to contact me at [osumatrix.me](https://osumatrix.me).
|
|
||||||
|
|
||||||
## ⭐ Features
|
|
||||||
|
|
||||||
- ✅ Actually downloads from Spotify, free and premium
|
|
||||||
- ✅ Chose between 96, 160, 256 and 320 kbit/s (free users can't exceed 160kbit/s)
|
|
||||||
- ✅ Download tracks, playlists, albums and artists
|
|
||||||
- ✅ Multi-threaded
|
|
||||||
- ✅ Search for tracks
|
|
||||||
- ✅ Download MP3 and original OGG files
|
|
||||||
- ✅ Metadata tagging
|
|
||||||
- ✅ Simple CLI interface
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Free Spotify users can not exceed 160kbit/s. Change the `quality` setting in the `settings.json` file to `Q160` or lower. If you want to download 256 or 320kbit/s, you need to use a premium account.
|
|
||||||
|
|
||||||
## ⚒️ Building
|
## ⚒️ Building
|
||||||
|
|
||||||
1. Clone the repository using git and change to the local repository directory:
|
1. Clone the repository using git and change to the local repository directory:
|
||||||
|
|
@ -58,12 +26,8 @@ If you want to help me accelerate this process, please feel free to contact me a
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you do not want to use `free-librespot` (i.e. if you are using a paid Spotify account), replace `git = "ssh://git@github.com/oSumAtrIX/free-librespot.git"` with `librespot = "0.4.2"` inside the `Cargo.toml` file.
|
|
||||||
|
|
||||||
## 🕹️ Usage
|
## 🕹️ Usage
|
||||||
|
|
||||||
1. Create a [new application](https://developer.spotify.com/dashboard/applications) on the Spotify developer dashboard
|
|
||||||
2. Run DownOnSpot
|
2. Run DownOnSpot
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -111,12 +75,6 @@ You can use the following template variables for `path` and `filename_template`
|
||||||
- Slow MP3 downloads due to libmp3lame
|
- Slow MP3 downloads due to libmp3lame
|
||||||
- Sporadic `channel error` when downloading tracks
|
- Sporadic `channel error` when downloading tracks
|
||||||
|
|
||||||
## 💪 Contributors
|
|
||||||
|
|
||||||
<a href="https://github.com/osumatrix/downonspot/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=osumatrix/downonspot" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## 🔑 License
|
## 🔑 License
|
||||||
|
|
||||||
DownOnSpot is licensed under the GPLv3 licence. Please see the [licence file](LICENSE) for more information.
|
DownOnSpot is licensed under the GPLv3 licence. Please see the [licence file](LICENSE) for more information.
|
||||||
|
|
|
||||||
Binary file not shown.
BIN
assets/icon.ico
BIN
assets/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.2 KiB |
|
|
@ -1,3 +1,4 @@
|
||||||
|
use aspotify::Tracks;
|
||||||
use async_std::channel::{bounded, Receiver, Sender};
|
use async_std::channel::{bounded, Receiver, Sender};
|
||||||
use async_stream::try_stream;
|
use async_stream::try_stream;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
|
@ -7,7 +8,8 @@ use librespot::audio::{AudioDecrypt, AudioFile};
|
||||||
use librespot::core::audio_key::AudioKey;
|
use librespot::core::audio_key::AudioKey;
|
||||||
use librespot::core::session::Session;
|
use librespot::core::session::Session;
|
||||||
use librespot::core::spotify_id::SpotifyId;
|
use librespot::core::spotify_id::SpotifyId;
|
||||||
use librespot::metadata::{FileFormat, Metadata, Track};
|
use librespot::metadata::{Metadata, Track};
|
||||||
|
use librespot::protocol::metadata::audio_file::Format as FileFormat;
|
||||||
use sanitize_filename::sanitize;
|
use sanitize_filename::sanitize;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
@ -199,6 +201,7 @@ async fn communication_thread(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spotify downloader
|
/// Spotify downloader
|
||||||
|
|
||||||
pub struct DownloaderInternal {
|
pub struct DownloaderInternal {
|
||||||
spotify: Spotify,
|
spotify: Spotify,
|
||||||
pub tx: Sender<DownloaderMessage>,
|
pub tx: Sender<DownloaderMessage>,
|
||||||
|
|
@ -483,16 +486,23 @@ impl DownloaderInternal {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_alternative(session: &Session, track: Track) -> Result<Track, SpotifyError> {
|
|
||||||
for alt in track.alternatives {
|
|
||||||
let t = Track::get(session, alt).await?;
|
|
||||||
if t.available {
|
|
||||||
return Ok(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(SpotifyError::Unavailable)
|
async fn find_alternative(session: &Session, track: Track) -> Result<Track, SpotifyError> {
|
||||||
}
|
let librespot::metadata::track::Tracks(ids) = track.alternatives;
|
||||||
|
for id in ids {
|
||||||
|
let t = Track::get(session, &id).await?;
|
||||||
|
if !Self::track_has_alternatives(&t) {
|
||||||
|
return Ok(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(SpotifyError::Unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_has_alternatives(track: &Track) -> bool {
|
||||||
|
let librespot::metadata::track::Tracks(alts) = &track.alternatives;
|
||||||
|
!alts.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
/// Download track by id
|
/// Download track by id
|
||||||
async fn download_track(
|
async fn download_track(
|
||||||
|
|
@ -504,12 +514,18 @@ impl DownloaderInternal {
|
||||||
job_id: i64,
|
job_id: i64,
|
||||||
) -> Result<(PathBuf, AudioFormat), SpotifyError> {
|
) -> Result<(PathBuf, AudioFormat), SpotifyError> {
|
||||||
let id = SpotifyId::from_base62(id)?;
|
let id = SpotifyId::from_base62(id)?;
|
||||||
let mut track = Track::get(session, id).await?;
|
let mut track = Track::get(session, &id).await?;
|
||||||
|
|
||||||
// Fallback if unavailable
|
// Fallback if unavailable
|
||||||
if !track.available {
|
if Self::track_has_alternatives(&track) {
|
||||||
track = DownloaderInternal::find_alternative(session, track).await?;
|
track = Self::find_alternative(session, track).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// if !track.available {
|
||||||
|
// track = DownloaderInternal::find_alternative(session, track).await?;
|
||||||
|
// } //TODO
|
||||||
|
|
||||||
// Quality fallback
|
// Quality fallback
|
||||||
let mut quality = config.quality;
|
let mut quality = config.quality;
|
||||||
|
|
@ -554,8 +570,8 @@ impl DownloaderInternal {
|
||||||
let path_clone = path.clone();
|
let path_clone = path.clone();
|
||||||
|
|
||||||
let key = session.audio_key().request(track.id, *file_id).await?;
|
let key = session.audio_key().request(track.id, *file_id).await?;
|
||||||
let encrypted = AudioFile::open(session, *file_id, 1024 * 1024, true).await?;
|
let encrypted = AudioFile::open(session, *file_id, 1024 * 1024).await?;
|
||||||
let size = encrypted.get_stream_loader_controller().len();
|
let size = encrypted.get_stream_loader_controller()?.len();
|
||||||
// Download
|
// Download
|
||||||
let s = match config.convert_to_mp3 {
|
let s = match config.convert_to_mp3 {
|
||||||
true => {
|
true => {
|
||||||
|
|
@ -604,7 +620,7 @@ impl DownloaderInternal {
|
||||||
) -> impl Stream<Item = Result<usize, SpotifyError>> {
|
) -> impl Stream<Item = Result<usize, SpotifyError>> {
|
||||||
try_stream! {
|
try_stream! {
|
||||||
let mut file = File::create(path).await?;
|
let mut file = File::create(path).await?;
|
||||||
let mut decrypted = AudioDecrypt::new(key, encrypted);
|
let mut decrypted = AudioDecrypt::new(Some(key), encrypted);
|
||||||
// Skip (i guess encrypted shit)
|
// Skip (i guess encrypted shit)
|
||||||
let mut skip: [u8; 0xa7] = [0; 0xa7];
|
let mut skip: [u8; 0xa7] = [0; 0xa7];
|
||||||
let mut decrypted = tokio::task::spawn_blocking(move || {
|
let mut decrypted = tokio::task::spawn_blocking(move || {
|
||||||
|
|
@ -642,7 +658,7 @@ impl DownloaderInternal {
|
||||||
) -> impl Stream<Item = Result<usize, SpotifyError>> {
|
) -> impl Stream<Item = Result<usize, SpotifyError>> {
|
||||||
try_stream! {
|
try_stream! {
|
||||||
let mut file = File::create(path).await?;
|
let mut file = File::create(path).await?;
|
||||||
let mut decrypted = AudioDecrypt::new(key, encrypted);
|
let mut decrypted = AudioDecrypt::new(Some(key), encrypted);
|
||||||
// Skip (i guess encrypted shit)
|
// Skip (i guess encrypted shit)
|
||||||
let mut skip: [u8; 0xa7] = [0; 0xa7];
|
let mut skip: [u8; 0xa7] = [0; 0xa7];
|
||||||
let decrypted = tokio::task::spawn_blocking(move || {
|
let decrypted = tokio::task::spawn_blocking(move || {
|
||||||
|
|
@ -683,6 +699,7 @@ pub enum AudioFormat {
|
||||||
Aac,
|
Aac,
|
||||||
Mp3,
|
Mp3,
|
||||||
Mp4,
|
Mp4,
|
||||||
|
Flac,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -694,6 +711,7 @@ impl AudioFormat {
|
||||||
AudioFormat::Aac => "m4a",
|
AudioFormat::Aac => "m4a",
|
||||||
AudioFormat::Mp3 => "mp3",
|
AudioFormat::Mp3 => "mp3",
|
||||||
AudioFormat::Mp4 => "mp4",
|
AudioFormat::Mp4 => "mp4",
|
||||||
|
AudioFormat::Flac => "flac",
|
||||||
AudioFormat::Unknown => "",
|
AudioFormat::Unknown => "",
|
||||||
}
|
}
|
||||||
.to_string()
|
.to_string()
|
||||||
|
|
@ -711,12 +729,9 @@ impl From<FileFormat> for AudioFormat {
|
||||||
FileFormat::MP3_160 => Self::Mp3,
|
FileFormat::MP3_160 => Self::Mp3,
|
||||||
FileFormat::MP3_96 => Self::Mp3,
|
FileFormat::MP3_96 => Self::Mp3,
|
||||||
FileFormat::MP3_160_ENC => Self::Mp3,
|
FileFormat::MP3_160_ENC => Self::Mp3,
|
||||||
FileFormat::MP4_128_DUAL => Self::Mp4,
|
FileFormat::AAC_24 => Self::Aac,
|
||||||
FileFormat::OTHER3 => Self::Unknown,
|
FileFormat::AAC_48 => Self::Aac,
|
||||||
FileFormat::AAC_160 => Self::Aac,
|
FileFormat::FLAC_FLAC => Self::Flac
|
||||||
FileFormat::AAC_320 => Self::Aac,
|
|
||||||
FileFormat::MP4_128 => Self::Mp4,
|
|
||||||
FileFormat::OTHER5 => Self::Unknown,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -727,13 +742,13 @@ impl Quality {
|
||||||
match self {
|
match self {
|
||||||
Self::Q320 => vec![
|
Self::Q320 => vec![
|
||||||
FileFormat::OGG_VORBIS_320,
|
FileFormat::OGG_VORBIS_320,
|
||||||
FileFormat::AAC_320,
|
FileFormat::AAC_48, // TODO
|
||||||
FileFormat::MP3_320,
|
FileFormat::MP3_320,
|
||||||
],
|
],
|
||||||
Self::Q256 => vec![FileFormat::MP3_256],
|
Self::Q256 => vec![FileFormat::MP3_256],
|
||||||
Self::Q160 => vec![
|
Self::Q160 => vec![
|
||||||
FileFormat::OGG_VORBIS_160,
|
FileFormat::OGG_VORBIS_160,
|
||||||
FileFormat::AAC_160,
|
FileFormat::AAC_24, // TODO
|
||||||
FileFormat::MP3_160,
|
FileFormat::MP3_160,
|
||||||
],
|
],
|
||||||
Self::Q96 => vec![FileFormat::OGG_VORBIS_96, FileFormat::MP3_96],
|
Self::Q96 => vec![FileFormat::OGG_VORBIS_96, FileFormat::MP3_96],
|
||||||
|
|
|
||||||
14
src/error.rs
14
src/error.rs
|
|
@ -19,6 +19,8 @@ pub enum SpotifyError {
|
||||||
ID3Error(String, String),
|
ID3Error(String, String),
|
||||||
Reqwest(String),
|
Reqwest(String),
|
||||||
InvalidFormat,
|
InvalidFormat,
|
||||||
|
NotConnected,
|
||||||
|
UnknownPacket(u8),
|
||||||
AlreadyDownloaded,
|
AlreadyDownloaded,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +45,8 @@ impl fmt::Display for SpotifyError {
|
||||||
SpotifyError::ID3Error(k, e) => write!(f, "ID3 Error: {} {}", k, e),
|
SpotifyError::ID3Error(k, e) => write!(f, "ID3 Error: {} {}", k, e),
|
||||||
SpotifyError::Reqwest(e) => write!(f, "Reqwest Error: {}", e),
|
SpotifyError::Reqwest(e) => write!(f, "Reqwest Error: {}", e),
|
||||||
SpotifyError::InvalidFormat => write!(f, "Invalid Format!"),
|
SpotifyError::InvalidFormat => write!(f, "Invalid Format!"),
|
||||||
|
SpotifyError::NotConnected => write!(f, "Not Connected"),
|
||||||
|
SpotifyError::UnknownPacket(e) => write!(f, "Unknown Packet: {}", e),
|
||||||
SpotifyError::AlreadyDownloaded => write!(f, "Already Downloaded"),
|
SpotifyError::AlreadyDownloaded => write!(f, "Already Downloaded"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,13 +68,21 @@ impl From<librespot::core::mercury::MercuryError> for SpotifyError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<librespot::core::error::Error> for SpotifyError {
|
||||||
|
fn from(e: librespot::core::error::Error) -> Self {
|
||||||
|
SpotifyError::Error(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<librespot::core::session::SessionError> for SpotifyError {
|
impl From<librespot::core::session::SessionError> for SpotifyError {
|
||||||
fn from(e: librespot::core::session::SessionError) -> Self {
|
fn from(e: librespot::core::session::SessionError) -> Self {
|
||||||
match e {
|
match e {
|
||||||
librespot::core::session::SessionError::IoError(e) => e.into(),
|
librespot::core::session::SessionError::IoError(e) => e.into(),
|
||||||
librespot::core::session::SessionError::AuthenticationError(_) => {
|
librespot::core::session::SessionError::AuthenticationError(_) => {
|
||||||
SpotifyError::AuthenticationError
|
SpotifyError::AuthenticationError
|
||||||
}
|
},
|
||||||
|
librespot::core::session::SessionError::NotConnected => SpotifyError::NotConnected,
|
||||||
|
librespot::core::session::SessionError::Packet(e) => SpotifyError::UnknownPacket(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use async_std::task;
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use downloader::{DownloadState, Downloader};
|
use downloader::{DownloadState, Downloader};
|
||||||
use error::SpotifyError;
|
use error::SpotifyError;
|
||||||
|
use librespot::core::spotify_id::SpotifyIdResult;
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
use spotify::Spotify;
|
use spotify::Spotify;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
@ -333,7 +334,7 @@ async fn start() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn secs_to_hrs_min_sec(secs: i32) -> String {
|
fn secs_to_hrs_min_sec(secs: i32) -> String {
|
||||||
format!("{:0>2}:{:0>2}:{:0>2}", secs / 360, secs / 60, secs % 60)
|
format!("{:0>2}:{:0>2}:{:0>2}", secs / 3600, (secs % 3600) / 60, secs % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
// !cargo b --release
|
// !cargo b --release
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use url::Url;
|
||||||
|
|
||||||
use crate::error::SpotifyError;
|
use crate::error::SpotifyError;
|
||||||
|
|
||||||
|
|
||||||
pub struct Spotify {
|
pub struct Spotify {
|
||||||
// librespotify sessopm
|
// librespotify sessopm
|
||||||
pub session: Session,
|
pub session: Session,
|
||||||
|
|
@ -34,13 +35,9 @@ impl Spotify {
|
||||||
Some(creds) => creds,
|
Some(creds) => creds,
|
||||||
None => Credentials::with_password(username, password),
|
None => Credentials::with_password(username, password),
|
||||||
};
|
};
|
||||||
let (session, _) = Session::connect(
|
|
||||||
SessionConfig::default(),
|
let session = Session::new(SessionConfig::default(), Some(cache));
|
||||||
credentials,
|
session.connect(credentials, true).await?;
|
||||||
Some(cache),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
//aspotify
|
//aspotify
|
||||||
let credentials = ClientCredentials {
|
let credentials = ClientCredentials {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue