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