Compare commits
12 commits
4a3b288a3c
...
daf91278ba
Author | SHA1 | Date | |
---|---|---|---|
daf91278ba | |||
3d66d2ee8c | |||
d2aa57a0cf | |||
b2ba8a2fe3 | |||
f38a1702a3 | |||
cc99ff610e | |||
ced1a14636 | |||
4ee9079044 | |||
10faa32495 | |||
0e23258069 | |||
bd7572828b | |||
00454d0524 |
13 changed files with 367 additions and 67 deletions
|
@ -1,6 +1,6 @@
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.3.0
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
[package]
|
[package]
|
||||||
name = "spotifyio-downloader"
|
name = "spotifyio-downloader"
|
||||||
description = "CLI for downloading music from Spotify"
|
description = "CLI for downloading music from Spotify"
|
||||||
version = "0.3.1"
|
version = "0.5.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
|
@ -17,17 +17,24 @@ use aes::cipher::{KeyIvInit, StreamCipher};
|
||||||
use futures_util::{stream, StreamExt, TryStreamExt};
|
use futures_util::{stream, StreamExt, TryStreamExt};
|
||||||
use indicatif::{MultiProgress, ProgressBar};
|
use indicatif::{MultiProgress, ProgressBar};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use lofty::{config::WriteOptions, prelude::*, tag::Tag};
|
use lofty::{
|
||||||
|
config::WriteOptions,
|
||||||
|
prelude::*,
|
||||||
|
tag::{ItemValue, Tag, TagItem},
|
||||||
|
};
|
||||||
use model::{
|
use model::{
|
||||||
album_type_str, convert_date, date_to_string, get_covers, parse_country_codes,
|
album_type_str, convert_date, date_to_string, get_covers, parse_country_codes,
|
||||||
AlternativeTrack, ArtistItem, ArtistWithRole, AudioFiles, AudioItem, EpisodeUniqueFields,
|
AlternativeTrack, ArtistItem, ArtistRole, ArtistWithRole, AudioFiles, AudioItem, DlAudioItem,
|
||||||
TrackUniqueFields, UnavailabilityReason, UniqueFields,
|
EpisodeUniqueFields, TrackUniqueFields, UnavailabilityReason, UniqueFields,
|
||||||
};
|
};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use path_macro::path;
|
use path_macro::path;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use spotifyio::{
|
use spotifyio::{
|
||||||
model::{AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, TrackId},
|
model::{
|
||||||
|
AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, PlaylistId,
|
||||||
|
TrackId,
|
||||||
|
},
|
||||||
pb::AudioFileFormat,
|
pb::AudioFileFormat,
|
||||||
AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig,
|
AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig,
|
||||||
PoolError, Quota, Session, SessionConfig, SpotifyIoPool,
|
PoolError, Quota, Session, SessionConfig, SpotifyIoPool,
|
||||||
|
@ -43,6 +50,8 @@ pub mod model;
|
||||||
|
|
||||||
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
|
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
|
||||||
|
|
||||||
|
const DOT_SEPARATOR: &str = " • ";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error("Spotify: {0}")]
|
#[error("Spotify: {0}")]
|
||||||
|
@ -645,8 +654,7 @@ impl SpotifyDownloader {
|
||||||
|
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
let mut cipher: Aes128Ctr =
|
let mut cipher: Aes128Ctr =
|
||||||
Aes128Ctr::new_from_slices(&audio_key.0, &spotifyio::util::AUDIO_AESIV)
|
Aes128Ctr::new(&audio_key.0.into(), &spotifyio::util::AUDIO_AESIV.into());
|
||||||
.unwrap();
|
|
||||||
let mut spotify_header = None;
|
let mut spotify_header = None;
|
||||||
|
|
||||||
while let Some(item) = stream.next().await {
|
while let Some(item) = stream.next().await {
|
||||||
|
@ -662,8 +670,8 @@ impl SpotifyDownloader {
|
||||||
drop(file);
|
drop(file);
|
||||||
std::fs::rename(&tpath_tmp, dest)?;
|
std::fs::rename(&tpath_tmp, dest)?;
|
||||||
|
|
||||||
norm_data = spotify_header
|
let h = spotify_header.ok_or(SpotifyError::failed_precondition("no header"))?;
|
||||||
.and_then(|h| NormalisationData::parse_from_ogg(Cursor::new(&h)).ok());
|
norm_data = Some(NormalisationData::parse_from_ogg(&mut Cursor::new(&h))?);
|
||||||
}
|
}
|
||||||
AudioKeyVariant::Widevine(key) => {
|
AudioKeyVariant::Widevine(key) => {
|
||||||
let decryptor = self
|
let decryptor = self
|
||||||
|
@ -690,7 +698,7 @@ impl SpotifyDownloader {
|
||||||
&self,
|
&self,
|
||||||
track_id: TrackId<'_>,
|
track_id: TrackId<'_>,
|
||||||
force: bool,
|
force: bool,
|
||||||
) -> Result<Option<AudioItem>, Error> {
|
) -> Result<Option<DlAudioItem>, Error> {
|
||||||
let pid = PlayableId::Track(track_id.as_ref());
|
let pid = PlayableId::Track(track_id.as_ref());
|
||||||
let input_id_str = track_id.id().to_owned();
|
let input_id_str = track_id.id().to_owned();
|
||||||
// Check if track was already downloaded
|
// Check if track was already downloaded
|
||||||
|
@ -816,7 +824,7 @@ impl SpotifyDownloader {
|
||||||
&track_fields.album_name,
|
&track_fields.album_name,
|
||||||
track_fields.number,
|
track_fields.number,
|
||||||
track_fields.disc_number,
|
track_fields.disc_number,
|
||||||
&artist.name,
|
&track_fields.artists,
|
||||||
&artists_json,
|
&artists_json,
|
||||||
album_id_str,
|
album_id_str,
|
||||||
Some(&album_artist.id),
|
Some(&album_artist.id),
|
||||||
|
@ -902,7 +910,11 @@ impl SpotifyDownloader {
|
||||||
.execute(&self.i.pool)
|
.execute(&self.i.pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(Some(sp_item))
|
Ok(Some(DlAudioItem {
|
||||||
|
item: sp_item,
|
||||||
|
subdir_path: subpath,
|
||||||
|
dl_path: tpath,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a Spotify track and log the error if one occurs
|
/// Download a Spotify track and log the error if one occurs
|
||||||
|
@ -911,15 +923,19 @@ impl SpotifyDownloader {
|
||||||
/// This is the case if the download is either
|
/// This is the case if the download is either
|
||||||
/// - successful
|
/// - successful
|
||||||
/// - unavailable
|
/// - unavailable
|
||||||
pub async fn download_track_log(&self, track_id: TrackId<'_>, force: bool) -> bool {
|
pub async fn download_track_log(
|
||||||
|
&self,
|
||||||
|
track_id: TrackId<'_>,
|
||||||
|
force: bool,
|
||||||
|
) -> (bool, Option<DlAudioItem>) {
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
match self.download_track(track_id.as_ref(), force).await {
|
match self.download_track(track_id.as_ref(), force).await {
|
||||||
Ok(sp_item) => {
|
Ok(sp_item) => {
|
||||||
if let Some(sp_item) = &sp_item {
|
if let Some(sp_item) = &sp_item {
|
||||||
if let UniqueFields::Track(sp_track) = &sp_item.unique_fields {
|
if let UniqueFields::Track(sp_track) = &sp_item.item.unique_fields {
|
||||||
self.i.dl_tracks.write().unwrap().tracks.push(format!(
|
self.i.dl_tracks.write().unwrap().tracks.push(format!(
|
||||||
"{} - {}",
|
"{} - {}",
|
||||||
sp_item.name,
|
sp_item.item.name,
|
||||||
sp_track
|
sp_track
|
||||||
.artists
|
.artists
|
||||||
.first()
|
.first()
|
||||||
|
@ -928,7 +944,7 @@ impl SpotifyDownloader {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return (true, sp_item);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[{track_id}] {e}");
|
tracing::error!("[{track_id}] {e}");
|
||||||
|
@ -940,14 +956,14 @@ impl SpotifyDownloader {
|
||||||
.push(format!("[{track_id}]: {e}"));
|
.push(format!("[{track_id}]: {e}"));
|
||||||
if matches!(e, Error::Unavailable(_)) {
|
if matches!(e, Error::Unavailable(_)) {
|
||||||
self.mark_track_unavailable(track_id).await.unwrap();
|
self.mark_track_unavailable(track_id).await.unwrap();
|
||||||
return true;
|
return (true, None);
|
||||||
} else if !matches!(e, Error::AudioKey(_)) {
|
} else if !matches!(e, Error::AudioKey(_)) {
|
||||||
return false;
|
return (false, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
(false, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_artist(
|
pub async fn download_artist(
|
||||||
|
@ -1048,7 +1064,7 @@ impl SpotifyDownloader {
|
||||||
.for_each_concurrent(4, |id| {
|
.for_each_concurrent(4, |id| {
|
||||||
let success = success.clone();
|
let success = success.clone();
|
||||||
async move {
|
async move {
|
||||||
let ok = self.download_track_log(id.as_ref(), false).await;
|
let ok = self.download_track_log(id.as_ref(), false).await.0;
|
||||||
if !ok {
|
if !ok {
|
||||||
success.store(false, Ordering::SeqCst);
|
success.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
@ -1069,6 +1085,80 @@ impl SpotifyDownloader {
|
||||||
Ok(success)
|
Ok(success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn download_playlist(&self, playlist_id: PlaylistId<'_>) -> Result<(), Error> {
|
||||||
|
let playlist_id_str = playlist_id.id();
|
||||||
|
let playlist = self
|
||||||
|
.i
|
||||||
|
.sp
|
||||||
|
.spclient()?
|
||||||
|
.pb_playlist(playlist_id.as_ref(), None)
|
||||||
|
.await?
|
||||||
|
.data;
|
||||||
|
|
||||||
|
let track_ids = playlist
|
||||||
|
.contents
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|itm| TrackId::from_uri(itm.uri()).ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let success = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let playlist_data = stream::iter(track_ids.iter())
|
||||||
|
.map(|id| {
|
||||||
|
let success = success.clone();
|
||||||
|
let id_str = id.id();
|
||||||
|
async move {
|
||||||
|
let (ok, item) = self.download_track_log(id.as_ref(), false).await;
|
||||||
|
if ok {
|
||||||
|
if let Some(item) = item {
|
||||||
|
return Ok(Some(item.subdir_path));
|
||||||
|
} else {
|
||||||
|
let row = sqlx::query!("select path from tracks where id=$1", id_str)
|
||||||
|
.fetch_optional(&self.i.pool)
|
||||||
|
.await?;
|
||||||
|
if let Some(subdir_path) = row.and_then(|r| r.path) {
|
||||||
|
let audio_path = path!(self.i.base_dir / subdir_path);
|
||||||
|
if audio_path.is_file() {
|
||||||
|
return Ok(Some(path!(subdir_path)));
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
"[{id}] audio file not found: {}",
|
||||||
|
audio_path.to_string_lossy()
|
||||||
|
);
|
||||||
|
success.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::error!("[{id}] audio file not found in db");
|
||||||
|
success.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
success.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Ok::<_, Error>(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.buffered(4)
|
||||||
|
.try_collect::<Vec<_>>()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|x| x.and_then(|x| path!(".." / x).to_str().map(str::to_owned)))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let pl_dir = path!(self.i.base_dir / "__Playlists");
|
||||||
|
std::fs::create_dir_all(&pl_dir)?;
|
||||||
|
let pl_path = path!(
|
||||||
|
pl_dir
|
||||||
|
/ better_filenamify(
|
||||||
|
playlist.attributes.name(),
|
||||||
|
Some(&format!(" [{playlist_id_str}].m3u"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
std::fs::write(pl_path, playlist_data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn download_new(&self, update_age_h: u32) -> Result<(), Error> {
|
pub async fn download_new(&self, update_age_h: u32) -> Result<(), Error> {
|
||||||
let date_thr = (OffsetDateTime::now_utc() - time::Duration::hours(update_age_h.into()))
|
let date_thr = (OffsetDateTime::now_utc() - time::Duration::hours(update_age_h.into()))
|
||||||
.unix_timestamp();
|
.unix_timestamp();
|
||||||
|
@ -1315,7 +1405,7 @@ fn tag_file(
|
||||||
album: &str,
|
album: &str,
|
||||||
track_nr: u32,
|
track_nr: u32,
|
||||||
disc_nr: u32,
|
disc_nr: u32,
|
||||||
artist: &str,
|
artists: &[ArtistWithRole],
|
||||||
artists_json: &str,
|
artists_json: &str,
|
||||||
album_id: &str,
|
album_id: &str,
|
||||||
album_artist_id: Option<&str>,
|
album_artist_id: Option<&str>,
|
||||||
|
@ -1341,7 +1431,35 @@ fn tag_file(
|
||||||
tag.set_title(name.to_owned());
|
tag.set_title(name.to_owned());
|
||||||
tag.set_album(album.to_owned());
|
tag.set_album(album.to_owned());
|
||||||
tag.set_track(track_nr);
|
tag.set_track(track_nr);
|
||||||
tag.set_artist(artist.to_owned());
|
|
||||||
|
let mut artist_names = Vec::new();
|
||||||
|
let mut feat_names = Vec::new();
|
||||||
|
|
||||||
|
for artist in artists {
|
||||||
|
match artist.role {
|
||||||
|
ArtistRole::ArtistRoleFeaturedArtist => feat_names.push(artist.name.to_owned()),
|
||||||
|
ArtistRole::ArtistRoleComposer
|
||||||
|
| ArtistRole::ArtistRoleConductor
|
||||||
|
| ArtistRole::ArtistRoleRemixer => {}
|
||||||
|
_ => artist_names.push(artist.name.to_owned()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let k = match artist.role {
|
||||||
|
ArtistRole::ArtistRoleRemixer => ItemKey::Remixer,
|
||||||
|
ArtistRole::ArtistRoleComposer => ItemKey::Composer,
|
||||||
|
ArtistRole::ArtistRoleConductor => ItemKey::Conductor,
|
||||||
|
_ => ItemKey::TrackArtists,
|
||||||
|
};
|
||||||
|
tag.push(TagItem::new(k, ItemValue::Text(artist.name.to_owned())));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut artist_names_str = artist_names.join(DOT_SEPARATOR);
|
||||||
|
if !feat_names.is_empty() {
|
||||||
|
artist_names_str += " feat. ";
|
||||||
|
artist_names_str += &feat_names.join(DOT_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.set_artist(artist_names_str);
|
||||||
tag.insert_text(ItemKey::AlbumArtist, album_artist.to_owned());
|
tag.insert_text(ItemKey::AlbumArtist, album_artist.to_owned());
|
||||||
if let Some(date) = release_date {
|
if let Some(date) = release_date {
|
||||||
tag.insert_text(ItemKey::RecordingDate, fix_release_date(date));
|
tag.insert_text(ItemKey::RecordingDate, fix_release_date(date));
|
||||||
|
|
|
@ -13,7 +13,7 @@ use clap::{Parser, Subcommand};
|
||||||
use futures_util::{stream, StreamExt, TryStreamExt};
|
use futures_util::{stream, StreamExt, TryStreamExt};
|
||||||
use indicatif::{MultiProgress, ProgressBar};
|
use indicatif::{MultiProgress, ProgressBar};
|
||||||
use spotifyio::{
|
use spotifyio::{
|
||||||
model::{AlbumId, ArtistId, Id, IdConstruct, SearchResult, SearchType, TrackId},
|
model::{AlbumId, ArtistId, Id, IdConstruct, PlaylistId, SearchResult, SearchType, TrackId},
|
||||||
ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig,
|
ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig,
|
||||||
};
|
};
|
||||||
use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig};
|
use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig};
|
||||||
|
@ -113,6 +113,10 @@ enum Commands {
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
},
|
},
|
||||||
|
/// Download a playlist
|
||||||
|
DlPlaylist {
|
||||||
|
playlist: Vec<String>,
|
||||||
|
},
|
||||||
ScrapeAudioFeatures,
|
ScrapeAudioFeatures,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -337,7 +341,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
|
||||||
let dl = dl.clone();
|
let dl = dl.clone();
|
||||||
let success = success.clone();
|
let success = success.clone();
|
||||||
async move {
|
async move {
|
||||||
let ok = dl.download_track_log(id.as_ref(), force).await;
|
let ok = dl.download_track_log(id.as_ref(), force).await.0;
|
||||||
if !ok {
|
if !ok {
|
||||||
success.store(false, Ordering::SeqCst);
|
success.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
@ -350,6 +354,29 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Commands::DlPlaylist { playlist } => {
|
||||||
|
let playlist_ids = parse_url_ids::<PlaylistId>(&playlist)?;
|
||||||
|
let success = Arc::new(AtomicBool::new(true));
|
||||||
|
stream::iter(playlist_ids)
|
||||||
|
.map(Ok::<_, Error>)
|
||||||
|
.try_for_each_concurrent(8, |id| {
|
||||||
|
let dl = dl.clone();
|
||||||
|
let success = success.clone();
|
||||||
|
async move {
|
||||||
|
if let Err(e) = dl.download_playlist(id.as_ref()).await {
|
||||||
|
tracing::error!("Error downloading playlist [{id}]: {e}");
|
||||||
|
success.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if !success.load(Ordering::SeqCst) {
|
||||||
|
return Err(Error::Other(
|
||||||
|
"not all playlists downloaded successfully".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Commands::ScrapeAudioFeatures => {
|
Commands::ScrapeAudioFeatures => {
|
||||||
let n =
|
let n =
|
||||||
sqlx::query_scalar!(r#"select count(*) from tracks where audio_features is null"#)
|
sqlx::query_scalar!(r#"select count(*) from tracks where audio_features is null"#)
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use spotifyio::{
|
use spotifyio::{
|
||||||
model::{AlbumId, ArtistId, FileId, IdConstruct, PlayableId, TrackId},
|
model::{AlbumId, ArtistId, FileId, IdConstruct, PlayableId, TrackId},
|
||||||
|
@ -40,6 +42,12 @@ pub struct AudioItem {
|
||||||
pub unique_fields: UniqueFields,
|
pub unique_fields: UniqueFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct DlAudioItem {
|
||||||
|
pub item: AudioItem,
|
||||||
|
pub subdir_path: PathBuf,
|
||||||
|
pub dl_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TrackUniqueFields {
|
pub struct TrackUniqueFields {
|
||||||
pub artists: Vec<ArtistWithRole>,
|
pub artists: Vec<ArtistWithRole>,
|
||||||
|
|
|
@ -16,7 +16,7 @@ categories.workspace = true
|
||||||
enum_dispatch = "0.3.8"
|
enum_dispatch = "0.3.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
strum = { version = "0.26.1", features = ["derive"] }
|
strum = { version = "0.27", features = ["derive"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
time = { version = "0.3.21", features = ["serde-well-known"] }
|
time = { version = "0.3.21", features = ["serde-well-known"] }
|
||||||
data-encoding = "2.5"
|
data-encoding = "2.5"
|
||||||
|
|
|
@ -843,6 +843,115 @@ impl<'de> Deserialize<'de> for PlayableId<'static> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[enum_dispatch(Id)]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum UserlikeId<'a> {
|
||||||
|
User(UserId<'a>),
|
||||||
|
Artist(ArtistId<'a>),
|
||||||
|
}
|
||||||
|
impl IdBase62 for UserlikeId<'_> {}
|
||||||
|
|
||||||
|
// These don't work with `enum_dispatch`, unfortunately.
|
||||||
|
impl<'a> UserlikeId<'a> {
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_ref(&'a self) -> Self {
|
||||||
|
match self {
|
||||||
|
UserlikeId::User(x) => UserlikeId::User(x.as_ref()),
|
||||||
|
UserlikeId::Artist(x) => UserlikeId::Artist(x.as_ref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_static(self) -> UserlikeId<'static> {
|
||||||
|
match self {
|
||||||
|
UserlikeId::User(x) => UserlikeId::User(x.into_static()),
|
||||||
|
UserlikeId::Artist(x) => UserlikeId::Artist(x.into_static()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn clone_static(&'a self) -> UserlikeId<'static> {
|
||||||
|
match self {
|
||||||
|
UserlikeId::User(x) => UserlikeId::User(x.clone_static()),
|
||||||
|
UserlikeId::Artist(x) => UserlikeId::Artist(x.clone_static()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Spotify URI from string slice
|
||||||
|
///
|
||||||
|
/// Spotify URI must be in one of the following formats:
|
||||||
|
/// `spotify:{type}:{id}` or `spotify/{type}/{id}`.
|
||||||
|
/// Where `{type}` is one of `artist`, `album`, `track`,
|
||||||
|
/// `playlist`, `user`, `show`, or `episode`, and `{id}` is a
|
||||||
|
/// non-empty valid string.
|
||||||
|
///
|
||||||
|
/// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`,
|
||||||
|
/// `spotify/track/4y4VO05kYgUTo2bzbox1an`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - `IdError::InvalidPrefix` - if `uri` is not started with
|
||||||
|
/// `spotify:` or `spotify/`,
|
||||||
|
/// - `IdError::InvalidType` - if type part of an `uri` is not a
|
||||||
|
/// valid Spotify type `T`,
|
||||||
|
/// - `IdError::InvalidId` - if id part of an `uri` is not a
|
||||||
|
/// valid id,
|
||||||
|
/// - `IdError::InvalidFormat` - if it can't be splitted into
|
||||||
|
/// type and id parts.
|
||||||
|
pub fn from_uri(uri: &'a str) -> Result<Self, IdError> {
|
||||||
|
let (tpe, id) = parse_uri(uri)?;
|
||||||
|
match tpe {
|
||||||
|
SpotifyType::User => Ok(Self::User(UserId::from_id(id)?)),
|
||||||
|
SpotifyType::Artist => Ok(Self::Artist(ArtistId::from_id(id)?)),
|
||||||
|
_ => Err(IdError::InvalidType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Displaying the ID shows its URI
|
||||||
|
impl std::fmt::Display for UserlikeId<'_> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.uri())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for UserlikeId<'_> {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.uri())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for UserlikeId<'static> {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct UriVisitor;
|
||||||
|
|
||||||
|
impl serde::de::Visitor<'_> for UriVisitor {
|
||||||
|
type Value = UserlikeId<'static>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("URI for PlayableId")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
UserlikeId::from_uri(v)
|
||||||
|
.map(UserlikeId::into_static)
|
||||||
|
.map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_str(UriVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
@ -62,19 +62,20 @@ rand = "0.8"
|
||||||
rsa = "0.9.2"
|
rsa = "0.9.2"
|
||||||
httparse = "1.7"
|
httparse = "1.7"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
oauth2 = { version = "5.0.0-rc.1", default-features = false, features = [
|
oauth2 = { version = "5.0.0", default-features = false, features = [
|
||||||
"reqwest",
|
"reqwest",
|
||||||
], optional = true }
|
], optional = true }
|
||||||
pin-project-lite = "0.2"
|
pin-project-lite = "0.2"
|
||||||
quick-xml = "0.37"
|
quick-xml = "0.37"
|
||||||
urlencoding = "2.1.0"
|
urlencoding = "2.1.0"
|
||||||
parking_lot = "0.12.0"
|
parking_lot = "0.12.0"
|
||||||
governor = { version = "0.8", default-features = false, features = [
|
governor = { version = "0.10", default-features = false, features = [
|
||||||
"std",
|
"std",
|
||||||
"quanta",
|
"quanta",
|
||||||
"jitter",
|
"jitter",
|
||||||
] }
|
] }
|
||||||
async-stream = "0.3.0"
|
async-stream = "0.3.0"
|
||||||
|
ogg_pager = "0.7.0"
|
||||||
protobuf.workspace = true
|
protobuf.workspace = true
|
||||||
|
|
||||||
spotifyio-protocol.workspace = true
|
spotifyio-protocol.workspace = true
|
||||||
|
|
|
@ -22,7 +22,7 @@ pub async fn proxy_connect<T: AsyncRead + AsyncWrite + Unpin>(
|
||||||
loop {
|
loop {
|
||||||
let bytes_read = proxy_connection.read(&mut buffer[offset..]).await?;
|
let bytes_read = proxy_connection.read(&mut buffer[offset..]).await?;
|
||||||
if bytes_read == 0 {
|
if bytes_read == 0 {
|
||||||
return Err(io::Error::new(io::ErrorKind::Other, "Early EOF from proxy"));
|
return Err(io::Error::other("Early EOF from proxy"));
|
||||||
}
|
}
|
||||||
offset += bytes_read;
|
offset += bytes_read;
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ pub async fn proxy_connect<T: AsyncRead + AsyncWrite + Unpin>(
|
||||||
|
|
||||||
let status = response
|
let status = response
|
||||||
.parse(&buffer[..offset])
|
.parse(&buffer[..offset])
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
|
.map_err(io::Error::other)?;
|
||||||
|
|
||||||
if status.is_complete() {
|
if status.is_complete() {
|
||||||
return match response.code {
|
return match response.code {
|
||||||
|
@ -39,12 +39,9 @@ pub async fn proxy_connect<T: AsyncRead + AsyncWrite + Unpin>(
|
||||||
Some(code) => {
|
Some(code) => {
|
||||||
let reason = response.reason.unwrap_or("no reason");
|
let reason = response.reason.unwrap_or("no reason");
|
||||||
let msg = format!("Proxy responded with {code}: {reason}");
|
let msg = format!("Proxy responded with {code}: {reason}");
|
||||||
Err(io::Error::new(io::ErrorKind::Other, msg))
|
Err(io::Error::other(msg))
|
||||||
}
|
}
|
||||||
None => Err(io::Error::new(
|
None => Err(io::Error::other("Malformed response from proxy")),
|
||||||
io::ErrorKind::Other,
|
|
||||||
"Malformed response from proxy",
|
|
||||||
)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ use time::OffsetDateTime;
|
||||||
|
|
||||||
use spotifyio_model::{
|
use spotifyio_model::{
|
||||||
AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId,
|
AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId,
|
||||||
|
UserlikeId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
@ -526,8 +527,8 @@ pub struct PublicPlaylistItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub(crate) struct UserProfilesWrap {
|
pub(crate) struct UserProfilesWrap<T> {
|
||||||
pub profiles: Vec<FollowerItem>,
|
pub profiles: Vec<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// May be an artist or an user
|
/// May be an artist or an user
|
||||||
|
@ -540,6 +541,16 @@ pub struct FollowerItem {
|
||||||
pub image_url: Option<String>,
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// May be an artist or an user
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub(crate) struct FollowerItemUserlike {
|
||||||
|
pub uri: UserlikeId<'static>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub followers_count: Option<u32>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Seektable for AAC tracks
|
/// Seektable for AAC tracks
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Seektable {
|
pub struct Seektable {
|
||||||
|
@ -621,3 +632,20 @@ impl From<GqlWrap<ConcertOption>> for Option<ConcertGql> {
|
||||||
value.data.into_option()
|
value.data.into_option()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TryFrom<FollowerItemUserlike> for FollowerItem {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn try_from(value: FollowerItemUserlike) -> Result<Self, Self::Error> {
|
||||||
|
if let UserlikeId::User(uri) = value.uri {
|
||||||
|
Ok(Self {
|
||||||
|
uri,
|
||||||
|
name: value.name,
|
||||||
|
followers_count: value.followers_count,
|
||||||
|
image_url: value.image_url,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Cursor, Read, Seek};
|
||||||
|
|
||||||
use byteorder::{LittleEndian, ReadBytesExt};
|
use byteorder::{LittleEndian, ReadBytesExt};
|
||||||
|
|
||||||
|
@ -6,34 +6,34 @@ use crate::Error;
|
||||||
|
|
||||||
/// Audio metadata for volume normalization
|
/// Audio metadata for volume normalization
|
||||||
///
|
///
|
||||||
/// Spotify provides these as `f32`, but audio metadata can contain up to `f64`.
|
|
||||||
/// Also, this negates the need for casting during sample processing.
|
|
||||||
///
|
|
||||||
/// More information about ReplayGain and how to apply this infomation to audio files
|
/// More information about ReplayGain and how to apply this infomation to audio files
|
||||||
/// can be found here: <https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification>.
|
/// can be found here: <https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification>.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct NormalisationData {
|
pub struct NormalisationData {
|
||||||
pub track_gain_db: f64,
|
pub track_gain_db: f32,
|
||||||
pub track_peak: f64,
|
pub track_peak: f32,
|
||||||
pub album_gain_db: f64,
|
pub album_gain_db: f32,
|
||||||
pub album_peak: f64,
|
pub album_peak: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NormalisationData {
|
impl NormalisationData {
|
||||||
pub fn parse_from_ogg<T: Read + Seek>(mut file: T) -> Result<Self, Error> {
|
/// Parse normalisation data from a Spotify OGG header (first 167 bytes)
|
||||||
const SPOTIFY_NORMALIZATION_HEADER_START_OFFSET: u64 = 144;
|
pub fn parse_from_ogg<R: Read + Seek>(file: &mut R) -> Result<Self, Error> {
|
||||||
let newpos = file.seek(SeekFrom::Start(SPOTIFY_NORMALIZATION_HEADER_START_OFFSET))?;
|
let packets = ogg_pager::Packets::read_count(file, 1)
|
||||||
if newpos != SPOTIFY_NORMALIZATION_HEADER_START_OFFSET {
|
.map_err(|e| Error::failed_precondition(format!("invalid ogg file: {e}")))?;
|
||||||
|
let packet = packets.get(0).unwrap();
|
||||||
|
if packet.len() != 139 {
|
||||||
return Err(Error::failed_precondition(format!(
|
return Err(Error::failed_precondition(format!(
|
||||||
"NormalisationData::parse_from_ogg seeking to {} but position is now {}",
|
"ogg header len={}, expected=139",
|
||||||
SPOTIFY_NORMALIZATION_HEADER_START_OFFSET, newpos
|
packet.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let mut rdr = Cursor::new(&packet[116..]);
|
||||||
|
|
||||||
let track_gain_db = file.read_f32::<LittleEndian>()? as f64;
|
let track_gain_db = rdr.read_f32::<LittleEndian>()?;
|
||||||
let track_peak = file.read_f32::<LittleEndian>()? as f64;
|
let track_peak = rdr.read_f32::<LittleEndian>()?;
|
||||||
let album_gain_db = file.read_f32::<LittleEndian>()? as f64;
|
let album_gain_db = rdr.read_f32::<LittleEndian>()?;
|
||||||
let album_peak = file.read_f32::<LittleEndian>()? as f64;
|
let album_peak = rdr.read_f32::<LittleEndian>()?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
track_gain_db,
|
track_gain_db,
|
||||||
|
|
|
@ -86,7 +86,7 @@ impl<T> ClientPool<T> {
|
||||||
fn next_pos(&self, timeout: Option<Duration>) -> Result<(usize, Duration), PoolError> {
|
fn next_pos(&self, timeout: Option<Duration>) -> Result<(usize, Duration), PoolError> {
|
||||||
let mut robin = self.robin.lock();
|
let mut robin = self.robin.lock();
|
||||||
let mut i = *robin;
|
let mut i = *robin;
|
||||||
let mut max_to = Duration::ZERO;
|
let mut min_to = Duration::MAX;
|
||||||
|
|
||||||
for _ in 0..((self.len() - 1).max(1)) {
|
for _ in 0..((self.len() - 1).max(1)) {
|
||||||
let entry = &self.entries[i];
|
let entry = &self.entries[i];
|
||||||
|
@ -100,7 +100,7 @@ impl<T> ClientPool<T> {
|
||||||
Err(not_until) => {
|
Err(not_until) => {
|
||||||
let wait_time = not_until.wait_time_from(entry.limiter.clock().now());
|
let wait_time = not_until.wait_time_from(entry.limiter.clock().now());
|
||||||
if timeout.is_some_and(|to| wait_time > to) {
|
if timeout.is_some_and(|to| wait_time > to) {
|
||||||
max_to = max_to.max(wait_time);
|
min_to = min_to.min(wait_time);
|
||||||
} else {
|
} else {
|
||||||
*robin = self.wrapping_inc(i);
|
*robin = self.wrapping_inc(i);
|
||||||
return Ok((i, wait_time));
|
return Ok((i, wait_time));
|
||||||
|
@ -111,10 +111,10 @@ impl<T> ClientPool<T> {
|
||||||
i = self.wrapping_inc(i);
|
i = self.wrapping_inc(i);
|
||||||
}
|
}
|
||||||
// If the pool is empty or all entries were skipped
|
// If the pool is empty or all entries were skipped
|
||||||
Err(if max_to == Duration::ZERO {
|
Err(if min_to == Duration::MAX {
|
||||||
PoolError::Empty
|
PoolError::Empty
|
||||||
} else {
|
} else {
|
||||||
PoolError::Timeout(max_to)
|
PoolError::Timeout(min_to)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,7 +151,11 @@ impl<T> ClientPool<T> {
|
||||||
let entry = &self.entries[i];
|
let entry = &self.entries[i];
|
||||||
if !wait_time.is_zero() {
|
if !wait_time.is_zero() {
|
||||||
let wait_with_jitter = self.jitter + wait_time;
|
let wait_with_jitter = self.jitter + wait_time;
|
||||||
tracing::debug!("pool: waiting for {wait_with_jitter:?}");
|
if wait_with_jitter > Duration::from_secs(10) {
|
||||||
|
tracing::info!("waiting for {}s", wait_with_jitter.as_secs());
|
||||||
|
} else {
|
||||||
|
tracing::debug!("waiting for {}s", wait_with_jitter.as_secs());
|
||||||
|
};
|
||||||
tokio::time::sleep(wait_with_jitter).await;
|
tokio::time::sleep(wait_with_jitter).await;
|
||||||
}
|
}
|
||||||
Ok(&entry.entry)
|
Ok(&entry.entry)
|
||||||
|
|
|
@ -33,9 +33,9 @@ use crate::{
|
||||||
error::ErrorKind,
|
error::ErrorKind,
|
||||||
gql_model::{
|
gql_model::{
|
||||||
ArtistGql, ArtistGqlWrap, Concert, ConcertGql, ConcertGqlWrap, ConcertOption, FollowerItem,
|
ArtistGql, ArtistGqlWrap, Concert, ConcertGql, ConcertGqlWrap, ConcertOption, FollowerItem,
|
||||||
GqlPlaylistItem, GqlSearchResult, GqlWrap, Lyrics, LyricsWrap, PlaylistWrap,
|
FollowerItemUserlike, GqlPlaylistItem, GqlSearchResult, GqlWrap, Lyrics, LyricsWrap,
|
||||||
PrereleaseItem, PrereleaseLookup, SearchItemType, SearchResultWrap, Seektable,
|
PlaylistWrap, PrereleaseItem, PrereleaseLookup, SearchItemType, SearchResultWrap,
|
||||||
TrackCredits, UserPlaylists, UserProfile, UserProfilesWrap,
|
Seektable, TrackCredits, UserPlaylists, UserProfile, UserProfilesWrap,
|
||||||
},
|
},
|
||||||
model::{
|
model::{
|
||||||
AlbumId, AlbumType, ArtistId, AudioAnalysis, AudioFeatures, AudioFeaturesPayload, Category,
|
AlbumId, AlbumType, ArtistId, AudioAnalysis, AudioFeatures, AudioFeaturesPayload, Category,
|
||||||
|
@ -1200,23 +1200,31 @@ impl SpClient {
|
||||||
pub async fn get_user_followers(&self, id: UserId<'_>) -> Result<Vec<FollowerItem>, Error> {
|
pub async fn get_user_followers(&self, id: UserId<'_>) -> Result<Vec<FollowerItem>, Error> {
|
||||||
debug!("getting user followers {id}");
|
debug!("getting user followers {id}");
|
||||||
let res = self
|
let res = self
|
||||||
.request_get_json::<UserProfilesWrap>(&format!(
|
.request_get_json::<UserProfilesWrap<FollowerItemUserlike>>(&format!(
|
||||||
"/user-profile-view/v3/profile/{}/followers?market=from_token",
|
"/user-profile-view/v3/profile/{}/followers?market=from_token",
|
||||||
id.id()
|
id.id()
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(res.profiles)
|
Ok(res
|
||||||
|
.profiles
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|p| p.try_into().ok())
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_following(&self, id: UserId<'_>) -> Result<Vec<FollowerItem>, Error> {
|
pub async fn get_user_following(&self, id: UserId<'_>) -> Result<Vec<FollowerItem>, Error> {
|
||||||
debug!("getting user following {id}");
|
debug!("getting user following {id}");
|
||||||
let res = self
|
let res = self
|
||||||
.request_get_json::<UserProfilesWrap>(&format!(
|
.request_get_json::<UserProfilesWrap<FollowerItemUserlike>>(&format!(
|
||||||
"/user-profile-view/v3/profile/{}/following?market=from_token",
|
"/user-profile-view/v3/profile/{}/following?market=from_token",
|
||||||
id.id()
|
id.id()
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(res.profiles)
|
Ok(res
|
||||||
|
.profiles
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|p| p.try_into().ok())
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUBLIC SPOTIFY API - FROM RSPOTIFY
|
// PUBLIC SPOTIFY API - FROM RSPOTIFY
|
||||||
|
|
Loading…
Add table
Reference in a new issue