please don't sue me

This commit is contained in:
Skye 2024-01-23 21:03:04 +09:00
commit 187a1eeb55
Signed by: me
GPG key ID: 0104BC05F41B77B8
10 changed files with 3611 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2856
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
Cargo.toml Normal file
View 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
View 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
View 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
View 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,
})
}

View 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
View 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
View 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
View 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(())
}