please don't sue me
This commit is contained in:
commit
187a1eeb55
10 changed files with 3611 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
2856
Cargo.lock
generated
Normal file
2856
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
[package]
|
||||||
|
name = "spotty"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "A downloader for Spotify"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.4.18", features = ["derive"] }
|
||||||
|
color-eyre = "0.6.2"
|
||||||
|
dirs = "5.0.1"
|
||||||
|
futures = "0.3.30"
|
||||||
|
indicatif = "0.17.7"
|
||||||
|
itertools = "0.12.0"
|
||||||
|
librespot = { version = "0.4.2", default-features = false }
|
||||||
|
lofty = "0.18.0"
|
||||||
|
rayon = "1.8.1"
|
||||||
|
rspotify = { version = "0.12.0", default-features = false, features = ["client-reqwest", "reqwest-rustls-tls"] }
|
||||||
|
serde = { version = "1.0.195", features = ["derive"] }
|
||||||
|
tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread", "fs"] }
|
||||||
|
toml = "0.8.8"
|
||||||
|
url = "2.5.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
67
src/args.rs
Normal file
67
src/args.rs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
use clap::{arg, Parser};
|
||||||
|
use librespot::metadata::FileFormat;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum QualityPreset {
|
||||||
|
OggOnly,
|
||||||
|
Mp3Only,
|
||||||
|
BestBitrate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<QualityPreset> for &'static [FileFormat] {
|
||||||
|
fn from(value: QualityPreset) -> Self {
|
||||||
|
match value {
|
||||||
|
QualityPreset::OggOnly => &[
|
||||||
|
FileFormat::OGG_VORBIS_320,
|
||||||
|
FileFormat::OGG_VORBIS_160,
|
||||||
|
FileFormat::OGG_VORBIS_96,
|
||||||
|
],
|
||||||
|
QualityPreset::Mp3Only => &[
|
||||||
|
FileFormat::MP3_320,
|
||||||
|
FileFormat::MP3_256,
|
||||||
|
FileFormat::MP3_160,
|
||||||
|
FileFormat::MP3_160_ENC,
|
||||||
|
FileFormat::MP3_96,
|
||||||
|
],
|
||||||
|
QualityPreset::BestBitrate => &[
|
||||||
|
FileFormat::OGG_VORBIS_320,
|
||||||
|
FileFormat::MP3_320,
|
||||||
|
FileFormat::MP3_256,
|
||||||
|
FileFormat::OGG_VORBIS_160,
|
||||||
|
FileFormat::MP3_160,
|
||||||
|
FileFormat::MP3_160_ENC,
|
||||||
|
FileFormat::OGG_VORBIS_96,
|
||||||
|
FileFormat::MP3_96,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spotty - A downloader for Spotify
|
||||||
|
#[derive(Parser, Debug, Clone)]
|
||||||
|
pub struct Args {
|
||||||
|
/// Track / Album / Playlist / Artist URL or ID
|
||||||
|
pub input: String,
|
||||||
|
|
||||||
|
/// Quality preset
|
||||||
|
#[arg(value_enum, short, long)]
|
||||||
|
pub quality_preset: Option<QualityPreset>,
|
||||||
|
|
||||||
|
/// Maximum number of concurrent downloads
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub concurrent_downloads: Option<usize>,
|
||||||
|
|
||||||
|
/// Path template for output file
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub output_template: Option<String>,
|
||||||
|
|
||||||
|
/// Separator between artists
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub artist_separator: Option<String>,
|
||||||
|
|
||||||
|
/// Skip download if file exists
|
||||||
|
#[arg(long)]
|
||||||
|
pub skip_existing: Option<bool>,
|
||||||
|
}
|
35
src/config.rs
Normal file
35
src/config.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::args::QualityPreset;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub credentials: Credentials,
|
||||||
|
pub concurrent_downloads: usize,
|
||||||
|
pub output_template: String,
|
||||||
|
pub artist_separator: String,
|
||||||
|
pub skip_existing: bool,
|
||||||
|
pub quality_preset: QualityPreset,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
credentials: Credentials::default(),
|
||||||
|
concurrent_downloads: 4,
|
||||||
|
output_template: "%artist%/%album%/%title%.%ext%".to_string(),
|
||||||
|
artist_separator: ", ".to_string(),
|
||||||
|
skip_existing: true,
|
||||||
|
quality_preset: QualityPreset::BestBitrate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
}
|
191
src/download_stream/mod.rs
Normal file
191
src/download_stream/mod.rs
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
use std::io::{self, Read, Seek};
|
||||||
|
|
||||||
|
use color_eyre::{
|
||||||
|
eyre::{eyre, Context},
|
||||||
|
Result,
|
||||||
|
};
|
||||||
|
use librespot::{
|
||||||
|
audio::{AudioDecrypt, AudioFile},
|
||||||
|
core::session::Session,
|
||||||
|
metadata::{FileFormat, Track},
|
||||||
|
};
|
||||||
|
use tokio::task::spawn_blocking;
|
||||||
|
|
||||||
|
use self::utils::SkippedRead;
|
||||||
|
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum DecryptStream {
|
||||||
|
Raw(AudioDecrypt<AudioFile>),
|
||||||
|
Skipped(SkippedRead<AudioDecrypt<AudioFile>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for DecryptStream {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.read(buf),
|
||||||
|
Self::Skipped(stream) => stream.read(buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_vectored(&mut self, bufs: &mut [io::IoSliceMut<'_>]) -> io::Result<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.read_vectored(bufs),
|
||||||
|
Self::Skipped(stream) => stream.read_vectored(bufs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_to_end(&mut self, buf: &mut Vec<u8>) -> io::Result<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.read_to_end(buf),
|
||||||
|
Self::Skipped(stream) => stream.read_to_end(buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.read_to_string(buf),
|
||||||
|
Self::Skipped(stream) => stream.read_to_string(buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.read_exact(buf),
|
||||||
|
Self::Skipped(stream) => stream.read_exact(buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for DecryptStream {
|
||||||
|
fn rewind(&mut self) -> io::Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.rewind(),
|
||||||
|
Self::Skipped(stream) => stream.rewind(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_position(&mut self) -> io::Result<u64> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.stream_position(),
|
||||||
|
Self::Skipped(stream) => stream.stream_position(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
|
||||||
|
match self {
|
||||||
|
Self::Raw(stream) => stream.seek(pos),
|
||||||
|
Self::Skipped(stream) => stream.seek(pos),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
OggOpus,
|
||||||
|
Mp3,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OutputFormat> for &'static str {
|
||||||
|
fn from(value: OutputFormat) -> Self {
|
||||||
|
match value {
|
||||||
|
OutputFormat::OggOpus => "ogg",
|
||||||
|
OutputFormat::Mp3 => "mp3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<FileFormat> for OutputFormat {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn try_from(value: FileFormat) -> Result<Self, Self::Error> {
|
||||||
|
Self::try_from(&value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&FileFormat> for OutputFormat {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn try_from(value: &FileFormat) -> Result<Self, Self::Error> {
|
||||||
|
match *value {
|
||||||
|
FileFormat::OGG_VORBIS_96 | FileFormat::OGG_VORBIS_160 | FileFormat::OGG_VORBIS_320 => {
|
||||||
|
Ok(OutputFormat::OggOpus)
|
||||||
|
}
|
||||||
|
FileFormat::MP3_256
|
||||||
|
| FileFormat::MP3_320
|
||||||
|
| FileFormat::MP3_160
|
||||||
|
| FileFormat::MP3_96
|
||||||
|
| FileFormat::MP3_160_ENC => Ok(OutputFormat::Mp3),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StreamWithInfo {
|
||||||
|
pub stream: DecryptStream,
|
||||||
|
pub size: usize,
|
||||||
|
pub format: OutputFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn guess_ext(track: &Track, format_prefs: &[FileFormat]) -> Option<OutputFormat> {
|
||||||
|
format_prefs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|format| {
|
||||||
|
format
|
||||||
|
.try_into()
|
||||||
|
.ok()
|
||||||
|
.map(|out: OutputFormat| (format, out))
|
||||||
|
})
|
||||||
|
.find_map(|(format, out)| track.files.get(format).map(|_| out))
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPOTIFY_OGG_HEADER_END: usize = 0xA7;
|
||||||
|
|
||||||
|
pub async fn new_stream_from_track(
|
||||||
|
session: &Session,
|
||||||
|
track: &Track,
|
||||||
|
format_prefs: &[FileFormat],
|
||||||
|
) -> Result<StreamWithInfo> {
|
||||||
|
let (file_id, format) = format_prefs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|format| {
|
||||||
|
format
|
||||||
|
.try_into()
|
||||||
|
.ok()
|
||||||
|
.map(|out: OutputFormat| (format, out))
|
||||||
|
})
|
||||||
|
.find_map(|(format, out)| track.files.get(format).map(|&file| (file, out)))
|
||||||
|
.ok_or(eyre!(
|
||||||
|
"Couldn't find any available files with the requested format"
|
||||||
|
))?;
|
||||||
|
let file = AudioFile::open(session, file_id, 1024 * 1024 * 1024, true)
|
||||||
|
.await
|
||||||
|
.or(Err(eyre!("Failed to open encrypted stream")))?;
|
||||||
|
let size = file.get_stream_loader_controller().len();
|
||||||
|
let key = session
|
||||||
|
.audio_key()
|
||||||
|
.request(track.id, file_id)
|
||||||
|
.await
|
||||||
|
.or(Err(eyre!("Failed to obtain decryption key")))?;
|
||||||
|
let decrypted = AudioDecrypt::new(key, file);
|
||||||
|
|
||||||
|
Ok(StreamWithInfo {
|
||||||
|
stream: if let OutputFormat::OggOpus = format {
|
||||||
|
DecryptStream::Skipped(
|
||||||
|
spawn_blocking(|| SkippedRead::new(decrypted, SPOTIFY_OGG_HEADER_END))
|
||||||
|
.await?
|
||||||
|
.wrap_err("Failed to skip Spotify's Ogg header")?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DecryptStream::Raw(decrypted)
|
||||||
|
},
|
||||||
|
size: if let OutputFormat::OggOpus = format {
|
||||||
|
size - SPOTIFY_OGG_HEADER_END
|
||||||
|
} else {
|
||||||
|
size
|
||||||
|
},
|
||||||
|
format,
|
||||||
|
})
|
||||||
|
}
|
58
src/download_stream/utils.rs
Normal file
58
src/download_stream/utils.rs
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
use std::io::{self, Read, Seek, SeekFrom};
|
||||||
|
|
||||||
|
pub struct SkippedRead<S: Seek + Read> {
|
||||||
|
skipped: usize,
|
||||||
|
read: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Seek + Read> SkippedRead<S> {
|
||||||
|
pub fn new(mut read: S, skipped: usize) -> io::Result<Self> {
|
||||||
|
read.seek(std::io::SeekFrom::Start(skipped as u64))?;
|
||||||
|
Ok(Self { skipped, read })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Seek + Read> Read for SkippedRead<S> {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
self.read.read(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_vectored(&mut self, bufs: &mut [io::IoSliceMut<'_>]) -> io::Result<usize> {
|
||||||
|
self.read.read_vectored(bufs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_to_end(&mut self, buf: &mut Vec<u8>) -> io::Result<usize> {
|
||||||
|
self.read.read_to_end(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
|
||||||
|
self.read.read_to_string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> {
|
||||||
|
self.read.read_exact(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Seek + Read> Seek for SkippedRead<S> {
|
||||||
|
fn rewind(&mut self) -> io::Result<()> {
|
||||||
|
self.read.seek(io::SeekFrom::Start(self.skipped as u64))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_position(&mut self) -> io::Result<u64> {
|
||||||
|
self.read
|
||||||
|
.stream_position()
|
||||||
|
.map(|offset| offset - self.skipped as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
|
||||||
|
if let SeekFrom::Start(offset) = pos {
|
||||||
|
self.read
|
||||||
|
.seek(SeekFrom::Start(offset + self.skipped as u64))
|
||||||
|
} else {
|
||||||
|
self.read.seek(pos)
|
||||||
|
}
|
||||||
|
.map(|offset| offset - self.skipped as u64)
|
||||||
|
}
|
||||||
|
}
|
113
src/lookup.rs
Normal file
113
src/lookup.rs
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
use color_eyre::eyre::{eyre, Result};
|
||||||
|
use futures::{
|
||||||
|
future::{join_all, ready},
|
||||||
|
stream::iter,
|
||||||
|
StreamExt,
|
||||||
|
};
|
||||||
|
use librespot::{
|
||||||
|
core::{session::Session, spotify_id::SpotifyId},
|
||||||
|
metadata::{Album, Metadata, Playlist, Track},
|
||||||
|
};
|
||||||
|
use rspotify::{clients::BaseClient, model::ArtistId, ClientCredsSpotify};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub async fn get_tracks(session: &Session, query: &str, config: &Config) -> Result<Vec<Track>> {
|
||||||
|
let uri = to_uri(query).ok_or(eyre!("Invalid URL/URI"))?;
|
||||||
|
let kind = uri.split(':').nth(1).ok_or(eyre!("Invalid URL/URI"))?;
|
||||||
|
let id = SpotifyId::from_uri(&uri).unwrap();
|
||||||
|
match kind {
|
||||||
|
"track" => Ok(vec![Track::get(session, id)
|
||||||
|
.await
|
||||||
|
.or(Err(eyre!("Failed to get track")))?]),
|
||||||
|
"album" => {
|
||||||
|
let album = Album::get(session, id)
|
||||||
|
.await
|
||||||
|
.or(Err(eyre!("Failed to get album")))?;
|
||||||
|
|
||||||
|
let futures = album
|
||||||
|
.tracks
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| Track::get(session, track));
|
||||||
|
|
||||||
|
let tracks = join_all(futures)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(tracks)
|
||||||
|
}
|
||||||
|
"playlist" => {
|
||||||
|
let playlist = Playlist::get(session, id)
|
||||||
|
.await
|
||||||
|
.or(Err(eyre!("Failed to get playlist")))?;
|
||||||
|
|
||||||
|
let futures = playlist
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| Track::get(session, *track));
|
||||||
|
|
||||||
|
let tracks = join_all(futures)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(tracks)
|
||||||
|
}
|
||||||
|
"artist" => {
|
||||||
|
let rspotify_client = ClientCredsSpotify::new(rspotify::Credentials {
|
||||||
|
id: config.credentials.client_id.clone(),
|
||||||
|
secret: Some(config.credentials.client_secret.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
rspotify_client.request_token().await?;
|
||||||
|
|
||||||
|
let tracks = rspotify_client
|
||||||
|
.artist_albums(ArtistId::from_id(id.to_base62()?)?, None, None)
|
||||||
|
.filter_map(|album| async {
|
||||||
|
let id = album.ok()?.id?.to_string();
|
||||||
|
Album::get(session, SpotifyId::from_uri(&id).ok()?)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|album| iter(album.tracks))
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|id| async move { Track::get(session, id).await.ok() })
|
||||||
|
.fold(vec![], |mut vec: Vec<Track>, elem| {
|
||||||
|
if !vec.iter().any(|track| track.id == elem.id) {
|
||||||
|
vec.push(elem);
|
||||||
|
}
|
||||||
|
ready(vec)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(tracks)
|
||||||
|
}
|
||||||
|
_ => Err(eyre!("Unsupported item type: {}", kind)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_uri(query: &str) -> Option<String> {
|
||||||
|
if SpotifyId::from_uri(query).is_ok() {
|
||||||
|
Some(query.to_string())
|
||||||
|
} else if let Ok(parsed) = Url::parse(query) {
|
||||||
|
if parsed.domain()?.ends_with("spotify.com") {
|
||||||
|
let mut segments = parsed.path_segments()?;
|
||||||
|
let kind = segments.next()?;
|
||||||
|
let id = segments.next()?;
|
||||||
|
let uri = format!("spotify:{kind}:{id}");
|
||||||
|
if SpotifyId::from_uri(&uri).is_ok() {
|
||||||
|
Some(uri)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
191
src/main.rs
Normal file
191
src/main.rs
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
#![warn(clippy::pedantic)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs::{create_dir_all, rename, OpenOptions},
|
||||||
|
io::{self, Seek},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use args::Args;
|
||||||
|
use clap::Parser;
|
||||||
|
use color_eyre::eyre::{eyre, Context, Report, Result};
|
||||||
|
use config::Config;
|
||||||
|
use download_stream::{guess_ext, new_stream_from_track, OutputFormat};
|
||||||
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use librespot::{
|
||||||
|
core::{cache::Cache, config::SessionConfig, session::Session},
|
||||||
|
discovery::Credentials,
|
||||||
|
metadata::{Album, Artist, Metadata, Track},
|
||||||
|
};
|
||||||
|
use lookup::get_tracks;
|
||||||
|
use rayon::ThreadPoolBuilder;
|
||||||
|
use tagger::tag_vorbis;
|
||||||
|
use tokio::{
|
||||||
|
fs::{read_to_string, try_exists},
|
||||||
|
runtime::Handle,
|
||||||
|
task::spawn_blocking,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod download_stream;
|
||||||
|
mod lookup;
|
||||||
|
mod tagger;
|
||||||
|
|
||||||
|
mod args;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
fn try_download(
|
||||||
|
session: &Session,
|
||||||
|
runtime: &Handle,
|
||||||
|
track: &Track,
|
||||||
|
progress: &ProgressBar,
|
||||||
|
args: &Config,
|
||||||
|
) -> Result<()> {
|
||||||
|
progress.set_message(track.name.clone());
|
||||||
|
let album = runtime
|
||||||
|
.block_on(Album::get(session, track.album))
|
||||||
|
.or(Err(eyre!("Failed to get album for track")))?;
|
||||||
|
let artist = track
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&id| runtime.block_on(Artist::get(session, id)).ok())
|
||||||
|
.map(|artist| artist.name)
|
||||||
|
.join(&args.artist_separator);
|
||||||
|
let path = args
|
||||||
|
.output_template
|
||||||
|
.replace("%artist%", &artist)
|
||||||
|
.replace("%album%", &album.name)
|
||||||
|
.replace("%title%", &track.name)
|
||||||
|
.replace(
|
||||||
|
"%ext%",
|
||||||
|
guess_ext(track, args.quality_preset.into())
|
||||||
|
.ok_or(eyre!("No file matching quality preference for track"))?
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
if let Some(parent) = Path::new(&path).parent() {
|
||||||
|
create_dir_all(parent).wrap_err(eyre!("Failed to create parent directories"))?;
|
||||||
|
}
|
||||||
|
if Path::new(&path).exists() {
|
||||||
|
progress.finish_with_message("Already exists");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let stream = runtime.block_on(new_stream_from_track(
|
||||||
|
session,
|
||||||
|
track,
|
||||||
|
args.quality_preset.into(),
|
||||||
|
))?;
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(format!("{path}-tmp"))
|
||||||
|
.wrap_err(eyre!("Failed to create file"))?;
|
||||||
|
progress.set_length(stream.size as u64);
|
||||||
|
progress.reset_eta();
|
||||||
|
io::copy(&mut progress.wrap_read(stream.stream), &mut file)
|
||||||
|
.wrap_err(eyre!("Failed to download stream"))?;
|
||||||
|
|
||||||
|
if stream.format == OutputFormat::OggOpus {
|
||||||
|
file.rewind()?;
|
||||||
|
tag_vorbis(file, session, track, runtime).wrap_err(eyre!("Failed to add tags"))?;
|
||||||
|
}
|
||||||
|
rename(format!("{path}-tmp"), path).wrap_err(eyre!("Failed to rename to final name"))?;
|
||||||
|
|
||||||
|
progress.finish_and_clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
let mut config_path =
|
||||||
|
dirs::config_dir().ok_or(eyre!("Unable to determine config directory"))?;
|
||||||
|
config_path.push("spotty.toml");
|
||||||
|
|
||||||
|
if !matches!(try_exists(&config_path).await, Ok(true))
|
||||||
|
&& tokio::fs::write(&config_path, toml::to_string_pretty(&Config::default())?)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
println!("Wrote default configuration to {}!", config_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config: config::Config = read_to_string(config_path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|config| toml::from_str(&config).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
macro_rules! monomorphize {
|
||||||
|
($name:ident) => {
|
||||||
|
if let Some($name) = args.$name {
|
||||||
|
config.$name = $name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
($name:ident, $($rest:ident),+) => {
|
||||||
|
monomorphize!($name);
|
||||||
|
monomorphize!($($rest),+)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
monomorphize!(
|
||||||
|
quality_preset,
|
||||||
|
concurrent_downloads,
|
||||||
|
output_template,
|
||||||
|
artist_separator,
|
||||||
|
skip_existing
|
||||||
|
);
|
||||||
|
|
||||||
|
let session = get_session(&config).await?;
|
||||||
|
let tracks = get_tracks(&session, &args.input, &config).await?;
|
||||||
|
let handle = Handle::current();
|
||||||
|
spawn_blocking(move || {
|
||||||
|
let pool = ThreadPoolBuilder::new()
|
||||||
|
.num_threads(config.concurrent_downloads)
|
||||||
|
.build()?;
|
||||||
|
let bars = MultiProgress::new();
|
||||||
|
pool.scope(|ctx| {
|
||||||
|
for track in tracks {
|
||||||
|
let bar = ProgressBar::new(1);
|
||||||
|
bar.set_style(
|
||||||
|
ProgressStyle::with_template(
|
||||||
|
"{msg:16!} {wide_bar:.blue} {bytes:>10}/{total_bytes:<10}",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
bars.add(bar.clone());
|
||||||
|
|
||||||
|
let session_ref = &session;
|
||||||
|
let args_ref = &config;
|
||||||
|
let handle = handle.clone();
|
||||||
|
ctx.spawn(move |_| {
|
||||||
|
if let Err(err) = try_download(session_ref, &handle, &track, &bar, args_ref) {
|
||||||
|
bar.finish_with_message(format!("An error occurred: {err:#}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok::<(), Report>(())
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_session(config: &Config) -> Result<Session> {
|
||||||
|
let session_config = SessionConfig::default();
|
||||||
|
let mut credentials_cache =
|
||||||
|
dirs::cache_dir().ok_or(eyre!("Unable to determine cache directory"))?;
|
||||||
|
credentials_cache.push("spotty");
|
||||||
|
let cache = Cache::new(credentials_cache.into(), None, None, None).unwrap();
|
||||||
|
let (session, _) = Session::connect(
|
||||||
|
session_config,
|
||||||
|
Credentials::with_password(&config.credentials.username, &config.credentials.password),
|
||||||
|
cache.into(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
75
src/tagger.rs
Normal file
75
src/tagger.rs
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
use std::{fs::File, io::Cursor};
|
||||||
|
|
||||||
|
use color_eyre::{eyre::eyre, Result};
|
||||||
|
use futures::{io, AsyncReadExt, TryStreamExt};
|
||||||
|
use librespot::{
|
||||||
|
core::session::Session,
|
||||||
|
metadata::{cover::get as cover_get, Album, Artist, Metadata, Track},
|
||||||
|
};
|
||||||
|
use lofty::{
|
||||||
|
ogg::{OggPictureStorage, VorbisComments, VorbisFile},
|
||||||
|
AudioFile, ParseOptions, Picture,
|
||||||
|
};
|
||||||
|
use tokio::runtime::Handle;
|
||||||
|
|
||||||
|
pub fn tag_vorbis(
|
||||||
|
mut file: File,
|
||||||
|
session: &Session,
|
||||||
|
track: &Track,
|
||||||
|
runtime: &Handle,
|
||||||
|
) -> Result<()> {
|
||||||
|
async fn add_metadata(
|
||||||
|
comments: &mut VorbisComments,
|
||||||
|
session: &Session,
|
||||||
|
track: &Track,
|
||||||
|
) -> Result<()> {
|
||||||
|
comments.insert("TITLE".to_string(), track.name.clone());
|
||||||
|
comments.insert("WEBSITE".to_string(), track.id.to_uri()?);
|
||||||
|
comments.insert("SOURCEMEDIA".to_string(), track.id.to_uri()?);
|
||||||
|
|
||||||
|
let album = Album::get(session, track.album)
|
||||||
|
.await
|
||||||
|
.or(Err(eyre!("Failed to get album for track")))?;
|
||||||
|
|
||||||
|
comments.insert("ALBUM".to_string(), album.name.clone());
|
||||||
|
if let Some(index) = album.tracks.iter().position(|&id| id == track.id) {
|
||||||
|
comments.insert("TRACKNUMBER".to_string(), (index + 1).to_string());
|
||||||
|
comments.insert("TRACKTOTAL".to_string(), album.tracks.len().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
comments.remove("ARTIST").count();
|
||||||
|
for &artist_id in &track.artists {
|
||||||
|
let artist = Artist::get(session, artist_id)
|
||||||
|
.await
|
||||||
|
.or(Err(eyre!("Failed to get artist for track")))?;
|
||||||
|
comments.push("ARTIST".to_string(), artist.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for cover in album.covers {
|
||||||
|
let mut buf = vec![];
|
||||||
|
if cover_get(session, cover)
|
||||||
|
.map_err(|_| io::ErrorKind::Other.into())
|
||||||
|
.into_async_read()
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
if let Ok(mut picture) = Picture::from_reader(&mut Cursor::new(&buf)) {
|
||||||
|
picture.set_pic_type(lofty::PictureType::Icon);
|
||||||
|
comments.insert_picture(picture, None)?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut metadata = VorbisFile::read_from(&mut file, ParseOptions::default())?;
|
||||||
|
let comments = metadata.vorbis_comments_mut();
|
||||||
|
|
||||||
|
runtime.block_on(add_metadata(comments, session, track))?;
|
||||||
|
|
||||||
|
metadata.save_to(&mut file)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Reference in a new issue