Compare commits
5 commits
c38fed0938
...
66b35b8e09
| Author | SHA1 | Date | |
|---|---|---|---|
|
66b35b8e09 |
|||
|
c4973843f6 |
|||
|
ea76444a8a |
|||
|
31cefe154a |
|||
|
f13a12e2a5 |
33 changed files with 5573 additions and 767 deletions
|
|
@ -14,6 +14,7 @@ resolver = "2"
|
|||
protobuf = "3.5"
|
||||
|
||||
# WS crates
|
||||
spotifyio = { path = "crates/spotifyio", version = "0.0.2", registry = "thetadev" }
|
||||
spotifyio = { path = "crates/spotifyio", version = "0.0.3", registry = "thetadev" }
|
||||
spotifyioweb = { path = "crates/spotifyioweb", version = "0.0.1", registry = "thetadev" }
|
||||
spotifyio-protocol = { path = "crates/protocol", version = "0.1.0", registry = "thetadev" }
|
||||
spotifyio-model = { path = "crates/model", version = "0.2.0", registry = "thetadev" }
|
||||
|
|
|
|||
|
|
@ -13,20 +13,20 @@ categories.workspace = true
|
|||
default = ["default-tls"]
|
||||
|
||||
# Reqwest TLS options
|
||||
default-tls = ["reqwest/default-tls", "spotifyio/default-tls"]
|
||||
native-tls = ["reqwest/native-tls", "spotifyio/native-tls"]
|
||||
native-tls-alpn = ["reqwest/native-tls-alpn", "spotifyio/native-tls-alpn"]
|
||||
default-tls = ["reqwest/default-tls", "spotifyioweb/default-tls"]
|
||||
native-tls = ["reqwest/native-tls", "spotifyioweb/native-tls"]
|
||||
native-tls-alpn = ["reqwest/native-tls-alpn", "spotifyioweb/native-tls-alpn"]
|
||||
native-tls-vendored = [
|
||||
"reqwest/native-tls-vendored",
|
||||
"spotifyio/native-tls-vendored",
|
||||
"spotifyioweb/native-tls-vendored",
|
||||
]
|
||||
rustls-tls-webpki-roots = [
|
||||
"reqwest/rustls-tls-webpki-roots",
|
||||
"spotifyio/rustls-tls-webpki-roots",
|
||||
"spotifyioweb/rustls-tls-webpki-roots",
|
||||
]
|
||||
rustls-tls-native-roots = [
|
||||
"reqwest/rustls-tls-native-roots",
|
||||
"spotifyio/rustls-tls-native-roots",
|
||||
"spotifyioweb/rustls-tls-native-roots",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -68,5 +68,4 @@ go-parse-duration = "0.1.1"
|
|||
which = "7"
|
||||
widevine = "0.1"
|
||||
|
||||
spotifyio = { workspace = true, features = ["oauth"] }
|
||||
spotifyio-protocol.workspace = true
|
||||
spotifyioweb = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ use std::{
|
|||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use aes::cipher::{KeyIvInit, StreamCipher};
|
||||
use futures_util::{stream, StreamExt, TryStreamExt};
|
||||
use indicatif::{MultiProgress, ProgressBar};
|
||||
use itertools::Itertools;
|
||||
|
|
@ -29,17 +28,21 @@ use model::{
|
|||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use path_macro::path;
|
||||
use reqwest::header;
|
||||
use serde::Serialize;
|
||||
use spotifyio::{
|
||||
use spotifyioweb::{
|
||||
model::{
|
||||
AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, PlaylistId,
|
||||
TrackId,
|
||||
},
|
||||
pb::AudioFileFormat,
|
||||
AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig,
|
||||
PoolError, Quota, Session, SessionConfig, SpotifyIoPool,
|
||||
Error as SpotifyError, NotModifiedRes, PoolConfig, PoolError, Quota, Session, SessionConfig,
|
||||
SpotifyIoPool,
|
||||
};
|
||||
use spotifyioweb::{
|
||||
pb::metadata::{Album, Availability, Restriction, Track},
|
||||
CdnUrl, NormalisationData,
|
||||
};
|
||||
use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track};
|
||||
use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
|
|
@ -48,7 +51,7 @@ use widevine::{Cdm, Pssh, ServiceCertificate};
|
|||
|
||||
pub mod model;
|
||||
|
||||
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
|
||||
// type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
|
||||
|
||||
const DOT_SEPARATOR: &str = " • ";
|
||||
|
||||
|
|
@ -56,8 +59,6 @@ const DOT_SEPARATOR: &str = " • ";
|
|||
pub enum Error {
|
||||
#[error("Spotify: {0}")]
|
||||
Spotify(#[from] SpotifyError),
|
||||
#[error("OAuth: {0}")]
|
||||
OAuth(#[from] spotifyio::oauth::OAuthError),
|
||||
#[error("DB: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("File IO: {0}")]
|
||||
|
|
@ -110,7 +111,6 @@ pub struct SpotifyDownloaderInner {
|
|||
mp4decryptor: Option<Mp4Decryptor>,
|
||||
cdm: Option<Cdm>,
|
||||
apply_tags: bool,
|
||||
format_m4a: bool,
|
||||
widevine_cert: RwLock<Option<(ServiceCertificate, Instant)>>,
|
||||
}
|
||||
|
||||
|
|
@ -127,9 +127,9 @@ pub enum Mp4Decryptor {
|
|||
|
||||
impl Mp4Decryptor {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
if let Ok(mp4decrypt) = which::which("mp4decrypt") {
|
||||
return Some(Self::Mp4decrypt(mp4decrypt));
|
||||
}
|
||||
// if let Ok(mp4decrypt) = which::which("mp4decrypt") {
|
||||
// return Some(Self::Mp4decrypt(mp4decrypt));
|
||||
// }
|
||||
if let Ok(ffmpeg) = which::which("ffmpeg") {
|
||||
return Some(Self::Ffmpeg(ffmpeg));
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ pub fn album_folder(
|
|||
}
|
||||
|
||||
pub fn audio_fname(name: &str, track_nr: u32, format: AudioFileFormat) -> String {
|
||||
let ext = spotifyio::util::audio_format_extension(format).expect("unknown file format");
|
||||
let ext = spotifyioweb::util::audio_format_extension(format).expect("unknown file format");
|
||||
better_filenamify(&format!("{:02} {}", track_nr, name,), Some(ext))
|
||||
}
|
||||
|
||||
|
|
@ -253,14 +253,14 @@ impl From<PoolError> for Error {
|
|||
}
|
||||
|
||||
enum AudioKeyVariant {
|
||||
Clearkey(AudioKey),
|
||||
// Clearkey(AudioKey),
|
||||
Widevine(widevine::Key),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioKeyVariant {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let key = match self {
|
||||
AudioKeyVariant::Clearkey(audio_key) => audio_key.0.as_slice(),
|
||||
// AudioKeyVariant::Clearkey(audio_key) => audio_key.0.as_slice(),
|
||||
AudioKeyVariant::Widevine(key) => key.key.as_slice(),
|
||||
};
|
||||
data_encoding::HEXLOWER.encode_write(key, f)
|
||||
|
|
@ -282,13 +282,12 @@ pub struct SpotifyDownloaderConfig {
|
|||
pub widevine_device: Option<PathBuf>,
|
||||
pub mp4decryptor: Option<Mp4Decryptor>,
|
||||
pub apply_tags: bool,
|
||||
pub format_m4a: bool,
|
||||
}
|
||||
|
||||
impl Default for SpotifyDownloaderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cache_path: path!("spotifyio.json"),
|
||||
cache_path: path!("spotifyioweb.json"),
|
||||
quota: Quota::per_minute(NonZeroU32::new(1).unwrap())
|
||||
.allow_burst(NonZeroU32::new(3).unwrap()),
|
||||
db_path: path!("tracks.db"),
|
||||
|
|
@ -297,7 +296,6 @@ impl Default for SpotifyDownloaderConfig {
|
|||
widevine_device: None,
|
||||
mp4decryptor: Mp4Decryptor::from_env(),
|
||||
apply_tags: true,
|
||||
format_m4a: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -334,7 +332,6 @@ impl SpotifyDownloader {
|
|||
cdm,
|
||||
mp4decryptor: cfg.mp4decryptor,
|
||||
apply_tags: cfg.apply_tags,
|
||||
format_m4a: cfg.format_m4a,
|
||||
widevine_cert: RwLock::new(None),
|
||||
}
|
||||
.into(),
|
||||
|
|
@ -342,7 +339,6 @@ impl SpotifyDownloader {
|
|||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.i.sp.shutdown();
|
||||
self.i.pool.close().await;
|
||||
}
|
||||
|
||||
|
|
@ -445,24 +441,7 @@ impl SpotifyDownloader {
|
|||
audio_item.track_id
|
||||
);
|
||||
|
||||
let formats_m4a = [
|
||||
AudioFileFormat::MP4_128,
|
||||
AudioFileFormat::OGG_VORBIS_160,
|
||||
AudioFileFormat::MP3_160,
|
||||
AudioFileFormat::OGG_VORBIS_96,
|
||||
AudioFileFormat::MP3_96,
|
||||
];
|
||||
let formats_default = [
|
||||
AudioFileFormat::OGG_VORBIS_160,
|
||||
AudioFileFormat::MP3_160,
|
||||
AudioFileFormat::OGG_VORBIS_96,
|
||||
AudioFileFormat::MP3_96,
|
||||
];
|
||||
let formats = if self.i.format_m4a {
|
||||
formats_m4a.as_slice()
|
||||
} else {
|
||||
formats_default.as_slice()
|
||||
};
|
||||
let formats = &[AudioFileFormat::MP4_128];
|
||||
|
||||
let (format, file_id) = formats
|
||||
.iter()
|
||||
|
|
@ -475,26 +454,8 @@ impl SpotifyDownloader {
|
|||
Ok((audio_item, file_id, format))
|
||||
}
|
||||
|
||||
async fn get_file_key_clearkey(
|
||||
&self,
|
||||
spotify_id: PlayableId<'_>,
|
||||
file_id: FileId,
|
||||
) -> Result<AudioKey, Error> {
|
||||
tracing::debug!("getting clearkey for {file_id}");
|
||||
let key = self
|
||||
.i
|
||||
.sp
|
||||
.audio_key()
|
||||
.await?
|
||||
.request(spotify_id, &file_id)
|
||||
.await
|
||||
.map_err(|e| Error::AudioKey(e.to_string()))?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
async fn get_file_key_widevine(&self, file_id: FileId) -> Result<widevine::Key, Error> {
|
||||
tracing::debug!("getting widevine key for {file_id}");
|
||||
tracing::debug!("getting widevine key for file:{file_id}");
|
||||
let cdm = self
|
||||
.i
|
||||
.cdm
|
||||
|
|
@ -510,7 +471,11 @@ impl SpotifyDownloader {
|
|||
.open()
|
||||
.set_service_certificate(cert)?
|
||||
.get_license_request(pssh, widevine::LicenseType::STREAMING)?;
|
||||
let license = sp.get_widevine_license(request.challenge()?, false).await?;
|
||||
let license = self
|
||||
.i
|
||||
.sp
|
||||
.get_widevine_license(request.challenge()?, false)
|
||||
.await?;
|
||||
let keys = request.get_keys(&license)?;
|
||||
let key = keys
|
||||
.of_type(widevine::KeyType::CONTENT)
|
||||
|
|
@ -521,18 +486,15 @@ impl SpotifyDownloader {
|
|||
|
||||
async fn get_file_key(
|
||||
&self,
|
||||
spotify_id: PlayableId<'_>,
|
||||
file_id: FileId,
|
||||
format: AudioFileFormat,
|
||||
) -> Result<AudioKeyVariant, Error> {
|
||||
if matches!(format, AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256) {
|
||||
if spotifyioweb::util::audio_format_widevine(format) {
|
||||
Ok(AudioKeyVariant::Widevine(
|
||||
self.get_file_key_widevine(file_id).await?,
|
||||
))
|
||||
} else {
|
||||
Ok(AudioKeyVariant::Clearkey(
|
||||
self.get_file_key_clearkey(spotify_id, file_id).await?,
|
||||
))
|
||||
Err(Error::AudioKey("clearkey unsupported".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -628,7 +590,7 @@ impl SpotifyDownloader {
|
|||
audio_url: &str,
|
||||
audio_key: &AudioKeyVariant,
|
||||
dest: &Path,
|
||||
) -> Result<(Option<NormalisationData>, u64), Error> {
|
||||
) -> Result<u64, Error> {
|
||||
let tpath_tmp = dest.with_extension("tmp");
|
||||
|
||||
let mut res = self
|
||||
|
|
@ -641,13 +603,13 @@ impl SpotifyDownloader {
|
|||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let mut file_size = res
|
||||
let file_size = res
|
||||
.content_length()
|
||||
.ok_or_else(|| SpotifyError::failed_precondition("no file size"))?;
|
||||
let mut file = tokio::fs::File::create(&tpath_tmp).await?;
|
||||
let mut norm_data = None;
|
||||
|
||||
match audio_key {
|
||||
/*
|
||||
AudioKeyVariant::Clearkey(audio_key) => {
|
||||
let mut stream = res.bytes_stream();
|
||||
file_size -= 167;
|
||||
|
|
@ -673,6 +635,7 @@ impl SpotifyDownloader {
|
|||
let h = spotify_header.ok_or(SpotifyError::failed_precondition("no header"))?;
|
||||
norm_data = Some(NormalisationData::parse_from_ogg(&mut Cursor::new(&h))?);
|
||||
}
|
||||
*/
|
||||
AudioKeyVariant::Widevine(key) => {
|
||||
let decryptor = self
|
||||
.i
|
||||
|
|
@ -685,12 +648,37 @@ impl SpotifyDownloader {
|
|||
}
|
||||
drop(file);
|
||||
|
||||
tracing::debug!("widevine key: {:?}", key);
|
||||
|
||||
decryptor.decrypt(&tpath_tmp, dest, key)?;
|
||||
std::fs::remove_file(&tpath_tmp)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((norm_data, file_size))
|
||||
Ok(file_size)
|
||||
}
|
||||
|
||||
async fn get_norm_data(
|
||||
&self,
|
||||
audio_files: &AudioFiles,
|
||||
) -> Result<Option<NormalisationData>, Error> {
|
||||
if let Some(file_id) = audio_files.0.get(&AudioFileFormat::OGG_VORBIS_320) {
|
||||
let sess = self.i.sp.session()?;
|
||||
let head_url = sess.head_file_url(file_id);
|
||||
let res = sess
|
||||
.http_client()
|
||||
.get(head_url)
|
||||
.header(header::RANGE, "bytes=0-166")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let head_data = res.bytes().await?;
|
||||
Ok(Some(NormalisationData::parse_from_ogg(&mut Cursor::new(
|
||||
head_data,
|
||||
))?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "error", skip(self, force))]
|
||||
|
|
@ -717,7 +705,7 @@ impl SpotifyDownloader {
|
|||
.resolve_audio(self.i.sp.session()?)
|
||||
.await?;
|
||||
let audio_url = &cdn_url.try_get_url()?.0;
|
||||
let key = self.get_file_key(pid, file_id, audio_format).await?;
|
||||
let key = self.get_file_key(file_id, audio_format).await?;
|
||||
|
||||
let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields {
|
||||
f
|
||||
|
|
@ -795,8 +783,9 @@ impl SpotifyDownloader {
|
|||
std::fs::create_dir_all(&album_dir)?;
|
||||
|
||||
// Download the file
|
||||
let (norm_data, file_size) = self.download_audio_file(audio_url, &key, &tpath).await?;
|
||||
let file_size = file_size as i64;
|
||||
let file_size = self.download_audio_file(audio_url, &key, &tpath).await? as i64;
|
||||
|
||||
let norm_data = self.get_norm_data(&sp_item.files).await?;
|
||||
|
||||
// Download album cover
|
||||
let cover_path = path!(album_dir / "cover.jpg");
|
||||
|
|
@ -1561,3 +1550,33 @@ fn album_n_discs(album: &Album) -> u32 {
|
|||
.max()
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SpotifyDownloader, SpotifyDownloaderConfig};
|
||||
use path_macro::path;
|
||||
use spotifyioweb::model::{IdConstruct, PlayableId, TrackId};
|
||||
|
||||
fn get_dl() -> SpotifyDownloader {
|
||||
SpotifyDownloader::new(SpotifyDownloaderConfig {
|
||||
cache_path: path!(
|
||||
env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyioweb.json"
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn norm_data() {
|
||||
let dl = get_dl();
|
||||
let track = dl
|
||||
.get_audio_item(PlayableId::Track(
|
||||
TrackId::from_id("4sVrgFJLB3PCD9WvIsUH5j").unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let norm_data = dl.get_norm_data(&track.files).await.unwrap().unwrap();
|
||||
dbg!(norm_data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ use std::{
|
|||
use clap::{Parser, Subcommand};
|
||||
use futures_util::{stream, StreamExt, TryStreamExt};
|
||||
use indicatif::{MultiProgress, ProgressBar};
|
||||
use spotifyio::{
|
||||
model::{AlbumId, ArtistId, Id, IdConstruct, PlaylistId, SearchResult, SearchType, TrackId},
|
||||
ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig,
|
||||
};
|
||||
use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig};
|
||||
use spotifyioweb::{
|
||||
model::{AlbumId, ArtistId, Id, IdConstruct, PlaylistId, SearchResult, SearchType, TrackId},
|
||||
ApplicationCache, Quota, Session, SessionConfig,
|
||||
};
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::{fmt::MakeWriter, EnvFilter};
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ struct Cli {
|
|||
#[clap(subcommand)]
|
||||
command: Commands,
|
||||
/// Path to Spotify account cache
|
||||
#[clap(long, default_value = "spotifyio.json")]
|
||||
#[clap(long, default_value = "spotifyioweb.json")]
|
||||
cache: PathBuf,
|
||||
/// Path to music library base directory
|
||||
#[clap(long, default_value = "Downloads")]
|
||||
|
|
@ -76,7 +76,9 @@ struct Cli {
|
|||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Add an account
|
||||
Login,
|
||||
Login {
|
||||
sp_dc: String,
|
||||
},
|
||||
/// Remove an account
|
||||
Logout {
|
||||
/// Account ID to log out
|
||||
|
|
@ -142,7 +144,7 @@ async fn main() -> Result<(), Error> {
|
|||
|
||||
if matches!(
|
||||
&cli.command,
|
||||
Commands::Login | Commands::Logout { .. } | Commands::Accounts
|
||||
Commands::Login { .. } | Commands::Logout { .. } | Commands::Accounts
|
||||
) {
|
||||
account_mgmt(cli).await
|
||||
} else {
|
||||
|
|
@ -153,17 +155,9 @@ async fn main() -> Result<(), Error> {
|
|||
async fn account_mgmt(cli: Cli) -> Result<(), Error> {
|
||||
let app_cache = ApplicationCache::new(cli.cache);
|
||||
match cli.command {
|
||||
Commands::Login => {
|
||||
let token = spotifyio::oauth::get_access_token(None).await?;
|
||||
let cache = spotifyio::oauth::new_session(&app_cache, &token.access_token).await?;
|
||||
Commands::Login { sp_dc } => {
|
||||
let cache = spotifyioweb::login::new_session(&app_cache, &sp_dc).await?;
|
||||
let session = Session::new(SessionConfig::default(), cache);
|
||||
session
|
||||
.connect(AuthCredentials {
|
||||
user_id: None,
|
||||
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
|
||||
auth_data: token.access_token.as_bytes().to_vec(),
|
||||
})
|
||||
.await?;
|
||||
println!("Logged in as <{}>", session.email().unwrap_or_default());
|
||||
println!("User ID: {}", session.user_id());
|
||||
if session.is_premium() {
|
||||
|
|
@ -203,7 +197,6 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
|
|||
base_dir: cli.base_dir,
|
||||
progress: Some(multi.clone()),
|
||||
apply_tags: !cli.no_tags,
|
||||
format_m4a: cli.m4a,
|
||||
widevine_device: cli.wvd,
|
||||
..Default::default()
|
||||
})?;
|
||||
|
|
@ -254,7 +247,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
|
|||
name: a.name.to_owned(),
|
||||
}]
|
||||
} else {
|
||||
return Err(Error::Spotify(spotifyio::Error::not_found(
|
||||
return Err(Error::Spotify(spotifyioweb::Error::not_found(
|
||||
"no artists returned",
|
||||
)));
|
||||
}
|
||||
|
|
@ -434,7 +427,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> {
|
|||
pb.inc(track_ids.len() as u64);
|
||||
}
|
||||
}
|
||||
Commands::Accounts | Commands::Login | Commands::Logout { .. } => unreachable!(),
|
||||
Commands::Accounts | Commands::Login { .. } | Commands::Logout { .. } => unreachable!(),
|
||||
}
|
||||
dl.shutdown().await;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::{collections::HashMap, fmt::Debug};
|
||||
|
||||
use spotifyio::{model::FileId, pb::AudioFileFormat};
|
||||
use spotifyio_protocol::metadata::AudioFile as AudioFileMessage;
|
||||
use spotifyioweb::pb::metadata::AudioFile as AudioFileMessage;
|
||||
use spotifyioweb::{model::FileId, pb::AudioFileFormat};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AudioFiles(pub HashMap<AudioFileFormat, FileId>);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use spotifyio::Error;
|
||||
use spotifyio_protocol::metadata::Availability as AvailabilityMessage;
|
||||
use spotifyioweb::pb::metadata::Availability as AvailabilityMessage;
|
||||
use spotifyioweb::Error;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
pub type AudioItemAvailability = Result<(), UnavailabilityReason>;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
use spotifyio::{
|
||||
use spotifyioweb::pb::metadata::{
|
||||
album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage,
|
||||
Date as DateMessage, Image,
|
||||
};
|
||||
use spotifyioweb::{
|
||||
model::{AlbumId, ArtistId, FileId, IdConstruct, PlayableId, TrackId},
|
||||
pb::{ArtistRole as PbArtistRole, ImageSize},
|
||||
Error,
|
||||
};
|
||||
use spotifyio_protocol::metadata::{
|
||||
album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage,
|
||||
Date as DateMessage, Image,
|
||||
};
|
||||
use time::{Date, OffsetDateTime, PrimitiveDateTime, Time};
|
||||
|
||||
pub use audio_file::AudioFiles;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "spotifyio"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
description = "Internal Spotify API Client"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
|
@ -42,8 +42,6 @@ once_cell = "1.0"
|
|||
thiserror = "2.0"
|
||||
dotenvy = "0.15.7"
|
||||
path_macro = "1.0"
|
||||
aes = "0.8"
|
||||
ctr = "0.9"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
bytes = "1.0"
|
||||
data-encoding = "2.5"
|
||||
|
|
|
|||
|
|
@ -135,17 +135,22 @@ impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(token) = url
|
||||
} else if let Some((_, token)) = url
|
||||
.query_pairs()
|
||||
.into_iter()
|
||||
.find(|(key, _value)| key == "Expires")
|
||||
{
|
||||
//"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=",
|
||||
if let Some(end) = token.1.find('~') {
|
||||
if let Some(end) = token.find('~') {
|
||||
// this is the only valid invariant for spotifycdn.com
|
||||
let slice = &token.1[..end];
|
||||
let slice = &token[..end];
|
||||
expiry_str = Some(String::from(&slice[..end]));
|
||||
}
|
||||
} else if let Some((_, token)) = url.query_pairs().into_iter().find(|(key, _value)| key == "verify") {
|
||||
// https://audio-cf.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?verify=1688165560-0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=
|
||||
if let Some(first) = token.split('-').next() {
|
||||
expiry_str = Some(String::from(first));
|
||||
}
|
||||
} else if let Some(query) = url.query() {
|
||||
//"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=",
|
||||
let mut items = query.split('_');
|
||||
|
|
@ -189,20 +194,21 @@ mod test {
|
|||
format!("https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"),
|
||||
format!("https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="),
|
||||
format!("https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="),
|
||||
format!("https://audio-cf.spotifycdn.com/audio/6a28ff353c2da587bfc1005064bbf4913c7fdc9e?verify={timestamp}-6WJMBXvDIB22xKy9G80aAFHQ2%2F%2BBv4bha1FTcJ4Zuy4%3D"),
|
||||
"https://audio4-fa.scdn.co/foo?baz".to_string(),
|
||||
];
|
||||
msg.fileid = vec![0];
|
||||
|
||||
let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls");
|
||||
assert_eq!(urls.len(), 4);
|
||||
assert!(urls[0].1.is_some());
|
||||
assert!(urls[1].1.is_some());
|
||||
assert!(urls[2].1.is_some());
|
||||
assert!(urls[3].1.is_none());
|
||||
assert_eq!(urls.len(), 5);
|
||||
assert!(urls[4].1.is_none());
|
||||
|
||||
let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN;
|
||||
assert_eq!(
|
||||
urls[0].1.unwrap().unix_timestamp(),
|
||||
timestamp_margin.whole_seconds()
|
||||
);
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
urls[i].1.unwrap().unix_timestamp(),
|
||||
timestamp_margin.whole_seconds()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -441,7 +441,7 @@ impl From<Utf8Error> for Error {
|
|||
impl From<PoolError> for Error {
|
||||
fn from(value: PoolError) -> Self {
|
||||
match &value {
|
||||
PoolError::Empty => Self::invalid_argument(value),
|
||||
PoolError::Empty => Self::invalid_argument("No spotify clients"),
|
||||
PoolError::Timeout(_) => Self::resource_exhausted(value),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use std::num::NonZeroU32;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DefaultOnError, DisplayFromStr};
|
||||
use serde_with::{serde_as, DefaultOnError, DisplayFromStr, VecSkipError};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use spotifyio_model::{
|
||||
|
|
@ -18,14 +18,35 @@ pub(crate) struct LyricsWrap {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ItemWrap<T> {
|
||||
pub item: T,
|
||||
pub struct GqlWrap<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlWrap<T> {
|
||||
pub data: T,
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ArtistOption {
|
||||
Artist(ArtistItem),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum AlbumOption {
|
||||
Album(AlbumItem),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum TrackOption {
|
||||
Track(TrackItem),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -375,20 +396,52 @@ pub(crate) struct SearchResultWrap {
|
|||
pub search_v2: GqlSearchResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[non_exhaustive]
|
||||
pub enum MatchedField {
|
||||
Lyrics,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlSearchTrackWrap {
|
||||
pub item: GqlWrap<TrackOption>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub matched_fields: Vec<MatchedField>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChipOrder {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub items: Vec<Chip>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Chip {
|
||||
type_name: SearchItemType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlSearchResult {
|
||||
#[serde(default)]
|
||||
pub artists: GqlPagination<GqlWrap<ArtistItem>>,
|
||||
pub artists: GqlPagination<GqlWrap<ArtistOption>>,
|
||||
#[serde(default)]
|
||||
pub albums_v2: GqlPagination<AlbumItemWrap>,
|
||||
#[serde(default)]
|
||||
pub tracks_v2: GqlPagination<ItemWrap<GqlWrap<TrackItem>>>,
|
||||
pub tracks_v2: GqlPagination<GqlSearchTrackWrap>,
|
||||
#[serde(default)]
|
||||
pub playlists: GqlPagination<GqlWrap<PlaylistOption>>,
|
||||
#[serde(default)]
|
||||
pub users: GqlPagination<GqlWrap<UserItem>>,
|
||||
pub chip_order: ChipOrder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -482,7 +535,8 @@ pub struct ConcertOfferDates {
|
|||
pub end_date_iso_string: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum SearchItemType {
|
||||
Artists,
|
||||
Albums,
|
||||
|
|
@ -558,8 +612,8 @@ pub struct Seektable {
|
|||
pub encoder_delay_samples: u32,
|
||||
pub pssh: String,
|
||||
pub timescale: u32,
|
||||
#[serde(alias = "init_range")]
|
||||
pub index_range: (u32, u32),
|
||||
pub init_range: Option<(u32, u32)>,
|
||||
pub index_range: Option<(u32, u32)>,
|
||||
pub segments: Vec<(u32, u32)>,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
|
@ -603,6 +657,24 @@ pub struct PlaylistWrap {
|
|||
pub playlist_v2: PlaylistOption,
|
||||
}
|
||||
|
||||
impl ArtistOption {
|
||||
pub fn into_option(self) -> Option<ArtistItem> {
|
||||
match self {
|
||||
ArtistOption::Artist(artist_item) => Some(artist_item),
|
||||
ArtistOption::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackOption {
|
||||
pub fn into_option(self) -> Option<TrackItem> {
|
||||
match self {
|
||||
TrackOption::Track(track_item) => Some(track_item),
|
||||
TrackOption::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistOption {
|
||||
pub fn into_option(self) -> Option<GqlPlaylistItem> {
|
||||
match self {
|
||||
|
|
@ -621,6 +693,18 @@ impl ConcertOption {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<GqlWrap<ArtistOption>> for Option<ArtistItem> {
|
||||
fn from(value: GqlWrap<ArtistOption>) -> Self {
|
||||
value.data.into_option()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GqlWrap<TrackOption>> for Option<TrackItem> {
|
||||
fn from(value: GqlWrap<TrackOption>) -> Self {
|
||||
value.data.into_option()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GqlWrap<PlaylistOption>> for Option<GqlPlaylistItem> {
|
||||
fn from(value: GqlWrap<PlaylistOption>) -> Self {
|
||||
value.data.into_option()
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ pub mod pb {
|
|||
pub use spotifyio_protocol::*;
|
||||
}
|
||||
|
||||
/// Version of the SpotifyIO crate
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpotifyIoPool {
|
||||
inner: Arc<SpotifyIoPoolInner>,
|
||||
|
|
|
|||
|
|
@ -391,8 +391,12 @@ impl Session {
|
|||
format!("https://i.scdn.co/image/{}", &image_id.base16())
|
||||
}
|
||||
|
||||
pub fn audio_preview_url(&self, preview_id: &FileId) -> String {
|
||||
format!("https://p.scdn.co/mp3-preview/{}", &preview_id.base16())
|
||||
pub fn audio_preview_url(&self, file_id: &FileId) -> String {
|
||||
format!("https://p.scdn.co/mp3-preview/{}", &file_id.base16())
|
||||
}
|
||||
|
||||
pub fn head_file_url(&self, file_id: &FileId) -> String {
|
||||
format!("https://heads-fa.scdn.co/head/{}", &file_id.base16())
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
|
|
|
|||
|
|
@ -197,8 +197,9 @@ impl SpClient {
|
|||
}
|
||||
|
||||
pub async fn base_url(&self) -> Result<String, Error> {
|
||||
let ap = self.get_accesspoint().await?;
|
||||
Ok(format!("https://{}:{}", ap.0, ap.1))
|
||||
// let ap = self.get_accesspoint().await?;
|
||||
// Ok(format!("https://{}:{}", ap.0, ap.1))
|
||||
Ok("https://spclient.wg.spotify.com".to_owned())
|
||||
}
|
||||
|
||||
pub async fn client_token(&self) -> Result<String, Error> {
|
||||
|
|
@ -498,10 +499,7 @@ impl SpClient {
|
|||
}
|
||||
|
||||
#[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))]
|
||||
pub async fn _request_generic(
|
||||
&self,
|
||||
p: RequestParams<'_>,
|
||||
) -> Result<EtagResponse<Bytes>, Error> {
|
||||
async fn _request_generic(&self, p: RequestParams<'_>) -> Result<EtagResponse<Bytes>, Error> {
|
||||
let mut tries: usize = 0;
|
||||
let mut last_error: Error;
|
||||
let mut retry_after: Option<u64> = None;
|
||||
|
|
@ -532,6 +530,7 @@ impl SpClient {
|
|||
.http_client()
|
||||
.request(p.method.clone(), url)
|
||||
.bearer_auth(auth_token)
|
||||
// .bearer_auth("BQDdwSxvV2qf5GqQJY4t37YcRePJPkQ2hv_rwXJLsR6NCsCb0CFJaW0Ecs9paIikmbM0VG3k7E6K7ufkYaF_ut4VX0Q6QoC6SaU7YXqBCxn0ZGUG9iTLhjXqIw7n2iomrNb8r_3Vyk3xeYWqZmJymXokEjIjgr0FBKT69djnemIxAtm3YSK54fH1QPw1AwqvXooIqIUPMOwskz_a-gUE4n_YWzzSiHJQ38Kw8dzRLa7wMcoy8PO_tchWcofvdRhGw2IglFr4x2xjaFqozoPTaQsLCo1vdKYPUN8xr8xF7Ls76SU8YEFHP3krH0krNgpolyNbPR1fd4jJg3T7Mpfd08mtxVd4rMCNpa683HWJSHI4rtMJGaaSx2zx-4KrklA_8w")
|
||||
.header(header::ACCEPT_LANGUAGE, &self.session().config().language);
|
||||
|
||||
if let Some(content_type) = p.content_type {
|
||||
|
|
@ -620,7 +619,7 @@ impl SpClient {
|
|||
ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => {
|
||||
warn!("API error: {last_error} (retrying)");
|
||||
// Keep trying the current access point three times before dropping it.
|
||||
if tries % 3 == 0 {
|
||||
if tries.is_multiple_of(3) {
|
||||
self.flush_accesspoint().await
|
||||
}
|
||||
}
|
||||
|
|
@ -644,7 +643,7 @@ impl SpClient {
|
|||
Err(last_error)
|
||||
}
|
||||
|
||||
pub async fn request_pb<O: Message>(
|
||||
async fn request_pb<O: Message>(
|
||||
&self,
|
||||
method: Method,
|
||||
endpoint: &str,
|
||||
|
|
@ -662,7 +661,7 @@ impl SpClient {
|
|||
Ok(O::parse_from_bytes(&res.data)?)
|
||||
}
|
||||
|
||||
pub async fn request_get_pb<O: Message>(
|
||||
async fn request_get_pb<O: Message>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
if_none_match: Option<&str>,
|
||||
|
|
@ -954,9 +953,9 @@ impl SpClient {
|
|||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn get_audio_preview(&self, preview_id: &FileId) -> Result<Response, Error> {
|
||||
debug!("getting audio preview {preview_id}");
|
||||
let mut url = self.session().audio_preview_url(preview_id);
|
||||
pub async fn get_audio_preview(&self, file_id: &FileId) -> Result<Response, Error> {
|
||||
debug!("getting audio preview for file:{file_id}");
|
||||
let mut url = self.session().audio_preview_url(file_id);
|
||||
let separator = match url.find('?') {
|
||||
Some(_) => "&",
|
||||
None => "?",
|
||||
|
|
@ -973,9 +972,22 @@ impl SpClient {
|
|||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn get_head_file(&self, file_id: &FileId) -> Result<Response, Error> {
|
||||
debug!("getting head file for file:{file_id}");
|
||||
|
||||
let resp = self
|
||||
.session()
|
||||
.http_client()
|
||||
.get(self.session().head_file_url(file_id))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Get the seektable required for streaming AAC tracks
|
||||
pub async fn get_seektable(&self, file_id: &FileId) -> Result<Seektable, Error> {
|
||||
debug!("getting seektable {file_id}");
|
||||
debug!("getting seektable for file:{file_id}");
|
||||
let url = format!(
|
||||
"https://seektables.scdn.co/seektable/{}.json",
|
||||
file_id.base16()
|
||||
|
|
@ -2301,6 +2313,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tracing_test::traced_test]
|
||||
async fn search() {
|
||||
let s = conn().await;
|
||||
let search = s
|
||||
|
|
|
|||
|
|
@ -1,617 +0,0 @@
|
|||
use std::{borrow::Cow, fmt};
|
||||
|
||||
use serde::{de::Visitor, Deserialize, Serialize};
|
||||
|
||||
use crate::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SpotifyItemType {
|
||||
Album,
|
||||
Artist,
|
||||
Episode,
|
||||
Playlist,
|
||||
User,
|
||||
Show,
|
||||
Track,
|
||||
Local,
|
||||
Concert,
|
||||
Prerelease,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<&str> for SpotifyItemType {
|
||||
fn from(v: &str) -> Self {
|
||||
match v {
|
||||
"album" => Self::Album,
|
||||
"artist" => Self::Artist,
|
||||
"episode" => Self::Episode,
|
||||
"playlist" => Self::Playlist,
|
||||
"user" => Self::User,
|
||||
"show" => Self::Show,
|
||||
"track" => Self::Track,
|
||||
"concert" => Self::Concert,
|
||||
"prerelease" => Self::Prerelease,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SpotifyItemType> for &str {
|
||||
fn from(item_type: SpotifyItemType) -> &'static str {
|
||||
match item_type {
|
||||
SpotifyItemType::Album => "album",
|
||||
SpotifyItemType::Artist => "artist",
|
||||
SpotifyItemType::Episode => "episode",
|
||||
SpotifyItemType::Playlist => "playlist",
|
||||
SpotifyItemType::User => "user",
|
||||
SpotifyItemType::Show => "show",
|
||||
SpotifyItemType::Track => "track",
|
||||
SpotifyItemType::Local => "local",
|
||||
SpotifyItemType::Concert => "concert",
|
||||
SpotifyItemType::Prerelease => "prerelease",
|
||||
SpotifyItemType::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SpotifyItemType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str((*self).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl SpotifyItemType {
|
||||
fn uses_textual_id(self) -> bool {
|
||||
matches!(self, Self::User | Self::Local)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct SpotifyId {
|
||||
id: SpotifyIdInner,
|
||||
item_type: SpotifyItemType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum SpotifyIdInner {
|
||||
/// Numeric Spotify IDs (either base62 or base16-encoded)
|
||||
Numeric(u128),
|
||||
/// Textual Spotify IDs (used only for user IDs, since they may be the username for older accounts)
|
||||
Textual(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpotifyIdError {
|
||||
#[error("ID cannot be parsed")]
|
||||
InvalidId,
|
||||
#[error("not a valid Spotify URI")]
|
||||
InvalidFormat,
|
||||
#[error("URI does not belong to Spotify")]
|
||||
InvalidRoot,
|
||||
}
|
||||
|
||||
impl From<SpotifyIdError> for Error {
|
||||
fn from(err: SpotifyIdError) -> Self {
|
||||
Error::invalid_argument(err)
|
||||
}
|
||||
}
|
||||
|
||||
const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
|
||||
impl SpotifyId {
|
||||
const SIZE: usize = 16;
|
||||
const SIZE_BASE16: usize = 32;
|
||||
const SIZE_BASE62: usize = 22;
|
||||
|
||||
/// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.
|
||||
///
|
||||
/// `src` is expected to be 32 bytes long and encoded using valid characters.
|
||||
///
|
||||
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
|
||||
pub fn from_base16(src: &str, item_type: SpotifyItemType) -> Result<Self, SpotifyIdError> {
|
||||
if src.len() != 32 {
|
||||
return Err(SpotifyIdError::InvalidId.into());
|
||||
}
|
||||
let mut dst: u128 = 0;
|
||||
|
||||
for c in src.as_bytes() {
|
||||
let p = match c {
|
||||
b'0'..=b'9' => c - b'0',
|
||||
b'a'..=b'f' => c - b'a' + 10,
|
||||
_ => return Err(SpotifyIdError::InvalidId.into()),
|
||||
} as u128;
|
||||
|
||||
dst <<= 4;
|
||||
dst += p;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: SpotifyIdInner::Numeric(dst),
|
||||
item_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a base62 encoded [Spotify ID] into a `u128`.
|
||||
///
|
||||
/// `src` is expected to be 22 bytes long and encoded using valid characters.
|
||||
///
|
||||
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
|
||||
pub fn from_base62(src: &str, item_type: SpotifyItemType) -> Result<Self, SpotifyIdError> {
|
||||
if src.len() != 22 {
|
||||
return Err(SpotifyIdError::InvalidId.into());
|
||||
}
|
||||
let mut dst: u128 = 0;
|
||||
|
||||
for c in src.as_bytes() {
|
||||
let p = match c {
|
||||
b'0'..=b'9' => c - b'0',
|
||||
b'a'..=b'z' => c - b'a' + 10,
|
||||
b'A'..=b'Z' => c - b'A' + 36,
|
||||
_ => return Err(SpotifyIdError::InvalidId.into()),
|
||||
} as u128;
|
||||
|
||||
dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?;
|
||||
dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: SpotifyIdInner::Numeric(dst),
|
||||
item_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
|
||||
///
|
||||
/// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`.
|
||||
pub fn from_raw(src: &[u8], item_type: SpotifyItemType) -> Result<Self, SpotifyIdError> {
|
||||
match src.try_into() {
|
||||
Ok(dst) => Ok(Self {
|
||||
id: SpotifyIdInner::Numeric(u128::from_be_bytes(dst)),
|
||||
item_type,
|
||||
}),
|
||||
Err(_) => Err(SpotifyIdError::InvalidId.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a [Spotify URI] into a `SpotifyId`.
|
||||
///
|
||||
/// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}`
|
||||
/// can be arbitrary while `{id}` is a 22-character long, base62 encoded Spotify ID.
|
||||
///
|
||||
/// Note that this should not be used for playlists, which have the form of
|
||||
/// `spotify:playlist:{id}`.
|
||||
///
|
||||
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
|
||||
pub fn from_uri(src: &str) -> Result<Self, SpotifyIdError> {
|
||||
// Basic: `spotify:{type}:{id}`
|
||||
// Named: `spotify:user:{user}:{type}:{id}`
|
||||
let mut parts = src.splitn(3, ':');
|
||||
|
||||
let scheme = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
|
||||
let item_type = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
|
||||
let id = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
|
||||
|
||||
if scheme != "spotify" {
|
||||
return Err(SpotifyIdError::InvalidRoot.into());
|
||||
}
|
||||
|
||||
let item_type = SpotifyItemType::from(item_type);
|
||||
|
||||
if item_type.uses_textual_id() {
|
||||
if id.is_empty() {
|
||||
return Err(SpotifyIdError::InvalidId.into());
|
||||
}
|
||||
Ok(Self {
|
||||
id: SpotifyIdInner::Textual(id.to_owned()),
|
||||
item_type,
|
||||
})
|
||||
} else {
|
||||
if id.len() != Self::SIZE_BASE62 {
|
||||
return Err(SpotifyIdError::InvalidId.into());
|
||||
}
|
||||
Self::from_base62(id, item_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32)
|
||||
/// character long `String`.
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn to_base16(&self) -> Result<String, Error> {
|
||||
to_base16(&self.to_raw()?, &mut [0u8; Self::SIZE_BASE16])
|
||||
}
|
||||
|
||||
/// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22)
|
||||
/// character long `String`.
|
||||
///
|
||||
/// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn to_base62<'a>(&'a self) -> Result<Cow<'a, str>, Error> {
|
||||
match &self.id {
|
||||
SpotifyIdInner::Numeric(n) => {
|
||||
let mut dst = [0u8; 22];
|
||||
let mut i = 0;
|
||||
|
||||
// The algorithm is based on:
|
||||
// https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c
|
||||
//
|
||||
// We are not using naive division of self.id as it is an u128 and div + mod are software
|
||||
// emulated at runtime (and unoptimized into mul + shift) on non-128bit platforms,
|
||||
// making them very expensive.
|
||||
//
|
||||
// Trezor's algorithm allows us to stick to arithmetic on native registers making this
|
||||
// an order of magnitude faster. Additionally, as our sizes are known, instead of
|
||||
// dealing with the ID on a byte by byte basis, we decompose it into four u32s and
|
||||
// use 64-bit arithmetic on them for an additional speedup.
|
||||
for shift in &[96, 64, 32, 0] {
|
||||
let mut carry = (n >> shift) as u32 as u64;
|
||||
|
||||
for b in &mut dst[..i] {
|
||||
carry += (*b as u64) << 32;
|
||||
*b = (carry % 62) as u8;
|
||||
carry /= 62;
|
||||
}
|
||||
|
||||
while carry > 0 {
|
||||
dst[i] = (carry % 62) as u8;
|
||||
carry /= 62;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for b in &mut dst {
|
||||
*b = BASE62_DIGITS[*b as usize];
|
||||
}
|
||||
|
||||
dst.reverse();
|
||||
|
||||
String::from_utf8(dst.to_vec())
|
||||
.map(Cow::Owned)
|
||||
.map_err(|_| SpotifyIdError::InvalidId.into())
|
||||
}
|
||||
SpotifyIdInner::Textual(id) => Ok(id.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
|
||||
/// big-endian order.
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn to_raw(&self) -> Result<[u8; Self::SIZE], Error> {
|
||||
match &self.id {
|
||||
SpotifyIdInner::Numeric(n) => Ok(n.to_be_bytes()),
|
||||
SpotifyIdInner::Textual(_) => Err(Error::invalid_argument(
|
||||
"textual IDs have no raw representation",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `SpotifyId` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`,
|
||||
/// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded
|
||||
/// Spotify ID.
|
||||
///
|
||||
/// If the `SpotifyId` has an associated type unrecognized by the library, `{type}` will
|
||||
/// be encoded as `unknown`.
|
||||
///
|
||||
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn to_uri(&self) -> Result<String, Error> {
|
||||
// 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31
|
||||
// + unknown size item_type.
|
||||
let item_type: &str = self.item_type.into();
|
||||
let mut dst = String::with_capacity(31 + item_type.len());
|
||||
dst.push_str("spotify:");
|
||||
dst.push_str(item_type);
|
||||
dst.push(':');
|
||||
let base_62 = self.to_base62()?;
|
||||
dst.push_str(&base_62);
|
||||
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
pub(crate) fn to_uri_urlenc(&self) -> Result<String, Error> {
|
||||
let item_type: &str = self.item_type.into();
|
||||
let mut dst = String::with_capacity(31 + item_type.len());
|
||||
dst.push_str("spotify%3A");
|
||||
dst.push_str(item_type);
|
||||
dst.push_str("%3A");
|
||||
let base_62 = self.to_base62()?;
|
||||
dst.push_str(&base_62);
|
||||
|
||||
Ok(dst)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SpotifyId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_tuple("SpotifyId")
|
||||
.field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SpotifyId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SpotifyId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_uri().map_err(serde::ser::Error::custom)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SpotifyId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct SpotifyIdVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for SpotifyIdVisitor {
|
||||
type Value = SpotifyId;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("Spotify URI")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
SpotifyId::from_uri(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`")))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(SpotifyIdVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result<String, Error> {
|
||||
let mut i = 0;
|
||||
for v in src {
|
||||
buf[i] = BASE16_DIGITS[(v >> 4) as usize];
|
||||
buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize];
|
||||
i += 2;
|
||||
}
|
||||
|
||||
String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct ConversionCase {
|
||||
id: SpotifyIdInner,
|
||||
kind: SpotifyItemType,
|
||||
uri: &'static str,
|
||||
base16: &'static str,
|
||||
base62: &'static str,
|
||||
raw: &'static [u8],
|
||||
}
|
||||
|
||||
static CONV_VALID: [ConversionCase; 4] = [
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(238762092608182713602505436543891614649),
|
||||
kind: SpotifyItemType::Track,
|
||||
uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH",
|
||||
base16: "b39fe8081e1f4c54be38e8d6f9f12bb9",
|
||||
base62: "5sWHDYs0csV6RS48xBl0tH",
|
||||
raw: &[
|
||||
179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185,
|
||||
],
|
||||
},
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048),
|
||||
kind: SpotifyItemType::Track,
|
||||
uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4",
|
||||
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
|
||||
base62: "4GNcXTGWmnZ3ySrqvol3o4",
|
||||
raw: &[
|
||||
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
|
||||
],
|
||||
},
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048),
|
||||
kind: SpotifyItemType::Episode,
|
||||
uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4",
|
||||
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
|
||||
base62: "4GNcXTGWmnZ3ySrqvol3o4",
|
||||
raw: &[
|
||||
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
|
||||
],
|
||||
},
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048),
|
||||
kind: SpotifyItemType::Show,
|
||||
uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4",
|
||||
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
|
||||
base62: "4GNcXTGWmnZ3ySrqvol3o4",
|
||||
raw: &[
|
||||
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
static CONV_INVALID: [ConversionCase; 5] = [
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(0),
|
||||
kind: SpotifyItemType::Unknown,
|
||||
// Invalid ID in the URI.
|
||||
uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH",
|
||||
base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9",
|
||||
base62: "!!!!!Ys0csV6RS48xBl0tH",
|
||||
raw: &[
|
||||
// Invalid length.
|
||||
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255,
|
||||
],
|
||||
},
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(0),
|
||||
kind: SpotifyItemType::Unknown,
|
||||
// Missing colon between ID and type.
|
||||
uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
|
||||
base16: "--------------------",
|
||||
base62: "....................",
|
||||
raw: &[
|
||||
// Invalid length.
|
||||
154, 27, 28, 251,
|
||||
],
|
||||
},
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(0),
|
||||
kind: SpotifyItemType::Unknown,
|
||||
// Uri too short
|
||||
uri: "spotify:azb:aRS48xBl0tH",
|
||||
// too long, should return error but not panic overflow
|
||||
base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
// too long, should return error but not panic overflow
|
||||
base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
raw: &[
|
||||
// Invalid length.
|
||||
154, 27, 28, 251,
|
||||
],
|
||||
},
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(0),
|
||||
kind: SpotifyItemType::Unknown,
|
||||
// Uri too short
|
||||
uri: "spotify:azb:aRS48xBl0tH",
|
||||
base16: "--------------------",
|
||||
// too short to encode a 128 bits int
|
||||
base62: "aa",
|
||||
raw: &[
|
||||
// Invalid length.
|
||||
154, 27, 28, 251,
|
||||
],
|
||||
},
|
||||
ConversionCase {
|
||||
id: SpotifyIdInner::Numeric(0),
|
||||
kind: SpotifyItemType::Unknown,
|
||||
uri: "cleary invalid uri",
|
||||
base16: "--------------------",
|
||||
// too high of a value, this would need a 132 bits int
|
||||
base62: "ZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
raw: &[
|
||||
// Invalid length.
|
||||
154, 27, 28, 251,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn from_base62() {
|
||||
for c in &CONV_VALID {
|
||||
assert_eq!(SpotifyId::from_base62(c.base62, c.kind).unwrap().id, c.id);
|
||||
}
|
||||
|
||||
for c in &CONV_INVALID {
|
||||
assert!(SpotifyId::from_base62(c.base62, c.kind).is_err(),);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_base62() {
|
||||
for c in &CONV_VALID {
|
||||
let id = SpotifyId {
|
||||
id: c.id.clone(),
|
||||
item_type: c.kind,
|
||||
};
|
||||
|
||||
assert_eq!(id.to_base62().unwrap(), c.base62);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_base16() {
|
||||
for c in &CONV_VALID {
|
||||
assert_eq!(SpotifyId::from_base16(c.base16, c.kind).unwrap().id, c.id);
|
||||
}
|
||||
|
||||
for c in &CONV_INVALID {
|
||||
assert!(SpotifyId::from_base16(c.base16, c.kind).is_err(),);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_base16() {
|
||||
for c in &CONV_VALID {
|
||||
let id = SpotifyId {
|
||||
id: c.id.clone(),
|
||||
item_type: c.kind,
|
||||
};
|
||||
|
||||
assert_eq!(id.to_base16().unwrap(), c.base16);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_uri() {
|
||||
for c in &CONV_VALID {
|
||||
let actual = SpotifyId::from_uri(c.uri).unwrap();
|
||||
|
||||
assert_eq!(actual.id, c.id);
|
||||
assert_eq!(actual.item_type, c.kind);
|
||||
}
|
||||
|
||||
for c in &CONV_INVALID {
|
||||
assert!(SpotifyId::from_uri(c.uri).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_uri() {
|
||||
for c in &CONV_VALID {
|
||||
let id = SpotifyId {
|
||||
id: c.id.clone(),
|
||||
item_type: c.kind,
|
||||
};
|
||||
|
||||
assert_eq!(id.to_uri().unwrap(), c.uri);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_raw() {
|
||||
for c in &CONV_VALID {
|
||||
assert_eq!(SpotifyId::from_raw(c.raw, c.kind).unwrap().id, c.id);
|
||||
}
|
||||
|
||||
for c in &CONV_INVALID {
|
||||
assert!(SpotifyId::from_raw(c.raw, c.kind).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_id() {
|
||||
let id = SpotifyId::from_uri("spotify:user:fabianakhavan").unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
SpotifyId {
|
||||
id: SpotifyIdInner::Textual("fabianakhavan".to_string()),
|
||||
item_type: SpotifyItemType::User
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_serde() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SpotifyUriS {
|
||||
uri: SpotifyId,
|
||||
}
|
||||
|
||||
let spotify_id = SpotifyId::from_uri("spotify:artist:1EfwyuCzDQpCslZc8C9gkG").unwrap();
|
||||
let json = r#"{"uri":"spotify:artist:1EfwyuCzDQpCslZc8C9gkG"}"#;
|
||||
|
||||
let got_id = serde_json::from_str::<SpotifyUriS>(json).unwrap().uri;
|
||||
assert_eq!(got_id, spotify_id);
|
||||
|
||||
let got_json = serde_json::to_string(&SpotifyUriS { uri: spotify_id }).unwrap();
|
||||
assert_eq!(got_json, json);
|
||||
}
|
||||
}
|
||||
68
crates/spotifyioweb/Cargo.toml
Normal file
68
crates/spotifyioweb/Cargo.toml
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
[package]
|
||||
name = "spotifyioweb"
|
||||
version = "0.0.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["default-tls"]
|
||||
|
||||
# Reqwest TLS options
|
||||
default-tls = ["reqwest/default-tls"]
|
||||
native-tls = ["reqwest/native-tls"]
|
||||
native-tls-alpn = ["reqwest/native-tls-alpn"]
|
||||
native-tls-vendored = ["reqwest/native-tls-vendored"]
|
||||
rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
|
||||
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.20.4", features = ["macros"] }
|
||||
tokio-stream = "0.1"
|
||||
reqwest = { version = "0.12.0", features = ["json"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_with = { version = "3.0.0", default-features = false, features = [
|
||||
"alloc",
|
||||
"macros",
|
||||
] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1.0"
|
||||
time = { version = "0.3.21", features = [
|
||||
"serde-well-known",
|
||||
"formatting",
|
||||
"parsing",
|
||||
] }
|
||||
once_cell = "1.0"
|
||||
thiserror = "2.0"
|
||||
dotenvy = "0.15.7"
|
||||
path_macro = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
bytes = "1.0"
|
||||
data-encoding = "2.5"
|
||||
sha1 = { version = "0.10", features = ["oid"] }
|
||||
hmac = "0.12"
|
||||
byteorder = "1.0"
|
||||
futures-util = { version = "0.3", features = ["sink"] }
|
||||
url = "2"
|
||||
governor = { version = "0.10", default-features = false, features = [
|
||||
"std",
|
||||
"quanta",
|
||||
"jitter",
|
||||
] }
|
||||
rand = "0.9"
|
||||
urlencoding = "2.1.0"
|
||||
parking_lot = "0.12.0"
|
||||
async-stream = "0.3.0"
|
||||
ogg_pager = "0.7.0"
|
||||
protobuf.workspace = true
|
||||
|
||||
spotifyio-protocol.workspace = true
|
||||
spotifyio-model.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-test = "0.2.5"
|
||||
hex_lit = "0.1"
|
||||
protobuf-json-mapping = "3"
|
||||
137
crates/spotifyioweb/src/apresolve.rs
Normal file
137
crates/spotifyioweb/src/apresolve.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{util::SocketAddress, Error};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AccessPoints {
|
||||
accesspoint: VecDeque<SocketAddress>,
|
||||
dealer: VecDeque<SocketAddress>,
|
||||
spclient: VecDeque<SocketAddress>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ApResolveData {
|
||||
accesspoint: Vec<String>,
|
||||
dealer: Vec<String>,
|
||||
spclient: Vec<String>,
|
||||
}
|
||||
|
||||
impl ApResolveData {
|
||||
// These addresses probably do some geo-location based traffic management or at least DNS-based
|
||||
// load balancing. They are known to fail when the normal resolvers are up, so that's why they
|
||||
// should only be used as fallback.
|
||||
fn fallback() -> Self {
|
||||
Self {
|
||||
accesspoint: vec![String::from("ap.spotify.com:443")],
|
||||
dealer: vec![String::from("dealer.spotify.com:443")],
|
||||
spclient: vec![String::from("spclient.wg.spotify.com:443")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessPoints {
|
||||
fn is_any_empty(&self) -> bool {
|
||||
self.accesspoint.is_empty() || self.dealer.is_empty() || self.spclient.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
component! {
|
||||
ApResolver : ApResolverInner {
|
||||
data: AccessPoints = AccessPoints::default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ApResolver {
|
||||
fn process_ap_strings(&self, data: Vec<String>) -> VecDeque<SocketAddress> {
|
||||
data.into_iter()
|
||||
.filter_map(|ap| {
|
||||
let mut split = ap.rsplitn(2, ':');
|
||||
let port = split.next()?;
|
||||
let port: u16 = port.parse().ok()?;
|
||||
let host = split.next()?.to_owned();
|
||||
Some((host, port))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_resolve_to_access_points(&self, resolve: ApResolveData) -> AccessPoints {
|
||||
AccessPoints {
|
||||
accesspoint: self.process_ap_strings(resolve.accesspoint),
|
||||
dealer: self.process_ap_strings(resolve.dealer),
|
||||
spclient: self.process_ap_strings(resolve.spclient),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn try_apresolve(&self) -> Result<ApResolveData, Error> {
|
||||
let data = self
|
||||
.session()
|
||||
.http_client()
|
||||
.get("https://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<ApResolveData>()
|
||||
.await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
async fn apresolve(&self) {
|
||||
let result = self.try_apresolve().await;
|
||||
|
||||
self.lock(|inner| {
|
||||
let (data, error) = match result {
|
||||
Ok(data) => (data, None),
|
||||
Err(e) => (ApResolveData::default(), Some(e)),
|
||||
};
|
||||
|
||||
inner.data = self.parse_resolve_to_access_points(data);
|
||||
|
||||
if inner.data.is_any_empty() {
|
||||
warn!("Failed to resolve all access points, using fallbacks");
|
||||
if let Some(error) = error {
|
||||
warn!("Resolve access points error: {}", error);
|
||||
}
|
||||
|
||||
let fallback = self.parse_resolve_to_access_points(ApResolveData::fallback());
|
||||
inner.data.accesspoint.extend(fallback.accesspoint);
|
||||
inner.data.dealer.extend(fallback.dealer);
|
||||
inner.data.spclient.extend(fallback.spclient);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_any_empty(&self) -> bool {
|
||||
self.lock(|inner| inner.data.is_any_empty())
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, endpoint: &str) -> Result<SocketAddress, Error> {
|
||||
if self.is_any_empty() {
|
||||
self.apresolve().await;
|
||||
}
|
||||
|
||||
self.lock(|inner| {
|
||||
let access_point = match endpoint {
|
||||
// take the first position instead of the last with `pop`, because Spotify returns
|
||||
// access points with ports 4070, 443 and 80 in order of preference from highest
|
||||
// to lowest.
|
||||
"accesspoint" => inner.data.accesspoint.pop_front(),
|
||||
"dealer" => inner.data.dealer.pop_front(),
|
||||
"spclient" => inner.data.spclient.pop_front(),
|
||||
_ => {
|
||||
return Err(Error::unimplemented(format!(
|
||||
"No implementation to resolve access point {endpoint}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let access_point = access_point.ok_or_else(|| {
|
||||
Error::unavailable(format!("No access point available for endpoint {endpoint}"))
|
||||
})?;
|
||||
|
||||
Ok(access_point)
|
||||
})
|
||||
}
|
||||
}
|
||||
329
crates/spotifyioweb/src/cache.rs
Normal file
329
crates/spotifyioweb/src/cache.rs
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::BufReader,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{spclient::Token, Error, Session, SessionConfig};
|
||||
|
||||
type CacheData = HashMap<String, CachedSession>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApplicationCache {
|
||||
inner: Arc<CacheInner>,
|
||||
}
|
||||
|
||||
struct CacheInner {
|
||||
path: PathBuf,
|
||||
data: RwLock<CacheData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CachedSession {
|
||||
device_id: String,
|
||||
email: Option<String>,
|
||||
country: Option<String>,
|
||||
is_premium: bool,
|
||||
sp_dc: String,
|
||||
auth_token: Option<Token>,
|
||||
client_token: Option<Token>,
|
||||
#[serde(with = "time::serde::rfc3339", default = "OffsetDateTime::now_utc")]
|
||||
created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl Default for CachedSession {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_id: Uuid::new_v4().simple().to_string(),
|
||||
email: None,
|
||||
country: None,
|
||||
is_premium: false,
|
||||
sp_dc: String::new(),
|
||||
auth_token: None,
|
||||
client_token: None,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionCache {
|
||||
inner: Arc<CacheInner>,
|
||||
/// Username as cache key
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
pub struct AccountMetadata {
|
||||
pub user_id: String,
|
||||
pub email: Option<String>,
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl ApplicationCache {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
tracing::trace!("cache path: {path:?}");
|
||||
let data = if path.is_file() {
|
||||
match Self::read_file(&path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
tracing::error!("could not read cache file: {e}");
|
||||
CacheData::default()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
CacheData::default()
|
||||
};
|
||||
|
||||
Self {
|
||||
inner: CacheInner {
|
||||
path,
|
||||
data: RwLock::new(data),
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_session(&self, user_id: String) -> SessionCache {
|
||||
SessionCache {
|
||||
inner: self.inner.clone(),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a list of device ids for all registered sessions
|
||||
pub fn device_ids(&self) -> Vec<String> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn first_session(&self) -> Result<SessionCache, Error> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
if let Some(user_id) = data.keys().next() {
|
||||
tracing::debug!("first session: {user_id}");
|
||||
Ok(SessionCache {
|
||||
inner: self.inner.clone(),
|
||||
user_id: user_id.to_owned(),
|
||||
})
|
||||
} else {
|
||||
Err(Error::unauthenticated("no sessions available"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a list of sessions for all registered accounts
|
||||
pub fn sessions(&self, cfg: &SessionConfig, max_sessions: usize) -> Vec<Session> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.keys()
|
||||
.take(max_sessions)
|
||||
.map(|user_id| {
|
||||
let cache = SessionCache {
|
||||
inner: self.inner.clone(),
|
||||
user_id: user_id.to_owned(),
|
||||
};
|
||||
Session::new(cfg.clone(), cache)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get a list of registered Spotify accounts
|
||||
pub fn accounts(&self) -> Vec<AccountMetadata> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
let mut accounts = data
|
||||
.iter()
|
||||
.map(|(user_id, session)| AccountMetadata {
|
||||
user_id: user_id.to_owned(),
|
||||
email: session.email.clone(),
|
||||
created_at: session.created_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
accounts.sort_by_key(|a| a.created_at);
|
||||
accounts
|
||||
}
|
||||
|
||||
pub fn remove_account(&self, user_id: &str) -> Result<(), Error> {
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
if data.remove(user_id).is_none() {
|
||||
Err(Error::not_found("account does not exist"))
|
||||
} else {
|
||||
drop(data);
|
||||
self.inner.write_file();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<CacheData, Error> {
|
||||
let file = File::open(path)?;
|
||||
let data: CacheData = serde_json::from_reader(BufReader::new(file))?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheInner {
|
||||
fn _write_file(&self) -> Result<(), Error> {
|
||||
let entry = {
|
||||
let x = self.data.read().unwrap();
|
||||
x.clone()
|
||||
};
|
||||
let file = File::create(&self.path)?;
|
||||
serde_json::to_writer_pretty(file, &entry)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file(&self) {
|
||||
if let Err(e) = self._write_file() {
|
||||
tracing::error!("could not write cache file: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionCache {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn testing() -> Self {
|
||||
use path_macro::path;
|
||||
|
||||
let cache = ApplicationCache::new(path!(
|
||||
env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyioweb.json"
|
||||
));
|
||||
cache.first_session().unwrap()
|
||||
}
|
||||
|
||||
pub fn get_user_id(&self) -> String {
|
||||
self.user_id.to_owned()
|
||||
}
|
||||
|
||||
pub fn read_device_id(&self) -> String {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
if let Some(session) = data.get(&self.user_id) {
|
||||
return session.device_id.to_owned();
|
||||
}
|
||||
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
let entry = data.entry(self.user_id.to_owned()).or_default();
|
||||
entry.device_id.to_owned()
|
||||
}
|
||||
|
||||
pub fn read_email(&self) -> Option<String> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.get(&self.user_id).and_then(|d| d.email.to_owned())
|
||||
}
|
||||
|
||||
pub fn read_is_premium(&self) -> bool {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.get(&self.user_id)
|
||||
.map(|d| d.is_premium)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn read_country(&self) -> Option<String> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.get(&self.user_id).and_then(|d| d.country.to_owned())
|
||||
}
|
||||
|
||||
pub fn read_sp_dc(&self) -> Option<String> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.get(&self.user_id).map(|d| d.sp_dc.clone())
|
||||
}
|
||||
|
||||
pub fn read_auth_token(&self) -> Option<Token> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.get(&self.user_id).and_then(|d| {
|
||||
d.auth_token
|
||||
.as_ref()
|
||||
.filter(|token| !token.is_expired())
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_client_token(&self) -> Option<Token> {
|
||||
let data = self.inner.data.read().unwrap();
|
||||
data.get(&self.user_id).and_then(|d| {
|
||||
d.client_token
|
||||
.as_ref()
|
||||
.filter(|token| !token.is_expired())
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_email(&self, email: Option<String>) {
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
let entry = data.entry(self.user_id.to_owned()).or_default();
|
||||
|
||||
if entry.email != email {
|
||||
entry.email = email;
|
||||
drop(data);
|
||||
self.inner.write_file();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_country(&self, country: String) {
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
let entry = data.entry(self.user_id.to_owned()).or_default();
|
||||
|
||||
if entry.country.as_deref() != Some(&country) {
|
||||
entry.country = Some(country);
|
||||
drop(data);
|
||||
self.inner.write_file();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_is_premium(&self, is_premium: bool) {
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
let entry = data.entry(self.user_id.to_owned()).or_default();
|
||||
|
||||
if entry.is_premium != is_premium {
|
||||
entry.is_premium = is_premium;
|
||||
drop(data);
|
||||
self.inner.write_file();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_sp_dc(&self, sp_dc: String) {
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
let entry = data.entry(self.user_id.to_owned()).or_default();
|
||||
|
||||
if entry.sp_dc != sp_dc {
|
||||
entry.sp_dc = sp_dc;
|
||||
drop(data);
|
||||
self.inner.write_file();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_auth_token(&self, auth_token: Token) {
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
let entry = data.entry(self.user_id.to_owned()).or_default();
|
||||
let new_token = Some(auth_token);
|
||||
|
||||
if entry.auth_token != new_token {
|
||||
entry.auth_token = new_token;
|
||||
drop(data);
|
||||
self.inner.write_file();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_client_token(&self, client_token: Token) {
|
||||
let mut data = self.inner.data.write().unwrap();
|
||||
let entry = data.entry(self.user_id.to_owned()).or_default();
|
||||
let new_token = Some(client_token);
|
||||
|
||||
if entry.client_token != new_token {
|
||||
entry.client_token = new_token;
|
||||
drop(data);
|
||||
self.inner.write_file();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CachedSession;
|
||||
|
||||
#[test]
|
||||
fn t1() {
|
||||
let session = CachedSession::default();
|
||||
println!("{}", serde_json::to_string_pretty(&session).unwrap());
|
||||
}
|
||||
}
|
||||
214
crates/spotifyioweb/src/cdn_url.rs
Normal file
214
crates/spotifyioweb/src/cdn_url.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use thiserror::Error;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::{trace, warn};
|
||||
use url::Url;
|
||||
|
||||
use crate::{model::FileId, Error, Session};
|
||||
|
||||
use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result;
|
||||
use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;
|
||||
use spotifyio_protocol as protocol;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MaybeExpiringUrl(pub String, pub Option<OffsetDateTime>);
|
||||
|
||||
const CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MaybeExpiringUrls(pub Vec<MaybeExpiringUrl>);
|
||||
|
||||
impl Deref for MaybeExpiringUrls {
|
||||
type Target = Vec<MaybeExpiringUrl>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for MaybeExpiringUrls {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CdnUrlError {
|
||||
#[error("all URLs expired")]
|
||||
Expired,
|
||||
#[error("resolved storage is not for CDN")]
|
||||
Storage,
|
||||
#[error("no URLs resolved")]
|
||||
Unresolved,
|
||||
}
|
||||
|
||||
impl From<CdnUrlError> for Error {
|
||||
fn from(err: CdnUrlError) -> Self {
|
||||
match err {
|
||||
CdnUrlError::Expired => Error::deadline_exceeded(err),
|
||||
CdnUrlError::Storage | CdnUrlError::Unresolved => Error::unavailable(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdnUrl {
|
||||
pub file_id: FileId,
|
||||
urls: MaybeExpiringUrls,
|
||||
}
|
||||
|
||||
impl CdnUrl {
|
||||
pub fn new(file_id: FileId) -> Self {
|
||||
Self {
|
||||
file_id,
|
||||
urls: MaybeExpiringUrls(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_audio(&self, session: &Session) -> Result<Self, Error> {
|
||||
let file_id = self.file_id;
|
||||
let msg = session.spclient().pb_audio_storage(&file_id).await?;
|
||||
let urls = MaybeExpiringUrls::try_from(msg)?;
|
||||
|
||||
let cdn_url = Self { file_id, urls };
|
||||
|
||||
trace!("Resolved CDN storage: {:#?}", cdn_url);
|
||||
|
||||
Ok(cdn_url)
|
||||
}
|
||||
|
||||
pub fn try_get_url(&self) -> Result<&MaybeExpiringUrl, Error> {
|
||||
if self.urls.is_empty() {
|
||||
return Err(CdnUrlError::Unresolved.into());
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let url = self.urls.iter().find(|url| match url.1 {
|
||||
Some(expiry) => now < expiry,
|
||||
None => true,
|
||||
});
|
||||
|
||||
if let Some(url) = url {
|
||||
Ok(url)
|
||||
} else {
|
||||
Err(CdnUrlError::Expired.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {
|
||||
type Error = crate::Error;
|
||||
fn try_from(msg: CdnUrlMessage) -> Result<Self, Self::Error> {
|
||||
if !matches!(
|
||||
msg.result.enum_value_or_default(),
|
||||
StorageResolveResponse_Result::CDN
|
||||
) {
|
||||
return Err(CdnUrlError::Storage.into());
|
||||
}
|
||||
|
||||
let is_expiring = !msg.fileid.is_empty();
|
||||
|
||||
let result = msg
|
||||
.cdnurl
|
||||
.iter()
|
||||
.map(|cdn_url| {
|
||||
let url = Url::parse(cdn_url)?;
|
||||
let mut expiry: Option<OffsetDateTime> = None;
|
||||
|
||||
if is_expiring {
|
||||
let mut expiry_str: Option<String> = None;
|
||||
if let Some(token) = url
|
||||
.query_pairs()
|
||||
.into_iter()
|
||||
.find(|(key, _value)| key == "__token__")
|
||||
{
|
||||
//"https://audio-ak-spotify-com.akamaized.net/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?__token__=exp=1688165560~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41",
|
||||
if let Some(mut start) = token.1.find("exp=") {
|
||||
start += 4;
|
||||
if token.1.len() >= start {
|
||||
let slice = &token.1[start..];
|
||||
if let Some(end) = slice.find('~') {
|
||||
// this is the only valid invariant for akamaized.net
|
||||
expiry_str = Some(String::from(&slice[..end]));
|
||||
} else {
|
||||
expiry_str = Some(String::from(slice));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some((_, token)) = url
|
||||
.query_pairs()
|
||||
.into_iter()
|
||||
.find(|(key, _value)| key == "Expires")
|
||||
{
|
||||
//"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=",
|
||||
if let Some(end) = token.find('~') {
|
||||
// this is the only valid invariant for spotifycdn.com
|
||||
let slice = &token[..end];
|
||||
expiry_str = Some(String::from(&slice[..end]));
|
||||
}
|
||||
} else if let Some((_, token)) = url.query_pairs().into_iter().find(|(key, _value)| key == "verify") {
|
||||
// https://audio-cf.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?verify=1688165560-0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=
|
||||
if let Some(first) = token.split('-').next() {
|
||||
expiry_str = Some(String::from(first));
|
||||
}
|
||||
} else if let Some(query) = url.query() {
|
||||
//"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=",
|
||||
let mut items = query.split('_');
|
||||
if let Some(first) = items.next() {
|
||||
// this is the only valid invariant for scdn.co
|
||||
expiry_str = Some(String::from(first));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(exp_str) = expiry_str {
|
||||
if let Ok(expiry_parsed) = exp_str.parse::<i64>() {
|
||||
if let Ok(expiry_at) = OffsetDateTime::from_unix_timestamp(expiry_parsed) {
|
||||
let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN);
|
||||
expiry = Some(with_margin);
|
||||
}
|
||||
} else {
|
||||
warn!("Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'");
|
||||
}
|
||||
} else {
|
||||
warn!("Unknown CDN URL format: {cdn_url}");
|
||||
}
|
||||
}
|
||||
Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry))
|
||||
})
|
||||
.collect::<Result<Vec<MaybeExpiringUrl>, Error>>()?;
|
||||
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_maybe_expiring_urls() {
|
||||
let timestamp = 1688165560;
|
||||
let mut msg = CdnUrlMessage::new();
|
||||
msg.result = StorageResolveResponse_Result::CDN.into();
|
||||
msg.cdnurl = vec![
|
||||
format!("https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"),
|
||||
format!("https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="),
|
||||
format!("https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="),
|
||||
format!("https://audio-cf.spotifycdn.com/audio/6a28ff353c2da587bfc1005064bbf4913c7fdc9e?verify={timestamp}-6WJMBXvDIB22xKy9G80aAFHQ2%2F%2BBv4bha1FTcJ4Zuy4%3D"),
|
||||
"https://audio4-fa.scdn.co/foo?baz".to_string(),
|
||||
];
|
||||
msg.fileid = vec![0];
|
||||
|
||||
let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls");
|
||||
assert_eq!(urls.len(), 5);
|
||||
assert!(urls[4].1.is_none());
|
||||
|
||||
let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN;
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
urls[i].1.unwrap().unix_timestamp(),
|
||||
timestamp_margin.whole_seconds()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
crates/spotifyioweb/src/component.rs
Normal file
31
crates/spotifyioweb/src/component.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
macro_rules! component {
|
||||
($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => {
|
||||
#[derive(Clone)]
|
||||
pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, parking_lot::Mutex<$inner>)>);
|
||||
impl $name {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new(session: $crate::session::SessionWeak) -> $name {
|
||||
tracing::debug!(target:"librespot::component", "new {}", stringify!($name));
|
||||
|
||||
$name(::std::sync::Arc::new((session, parking_lot::Mutex::new($inner {
|
||||
$($key : $value,)*
|
||||
}))))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn lock<F: FnOnce(&mut $inner) -> R, R>(&self, f: F) -> R {
|
||||
let mut inner = (self.0).1.lock();
|
||||
f(&mut inner)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn session(&self) -> $crate::session::Session {
|
||||
(self.0).0.upgrade()
|
||||
}
|
||||
}
|
||||
|
||||
struct $inner {
|
||||
$($key : $ty,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
460
crates/spotifyioweb/src/error.rs
Normal file
460
crates/spotifyioweb/src/error.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
use std::{
|
||||
error, fmt,
|
||||
num::{ParseIntError, TryFromIntError},
|
||||
str::Utf8Error,
|
||||
string::FromUtf8Error,
|
||||
};
|
||||
|
||||
use protobuf::Error as ProtobufError;
|
||||
use reqwest::StatusCode;
|
||||
use spotifyio_model::IdError;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::{
|
||||
mpsc::error::SendError, oneshot::error::RecvError, AcquireError, TryAcquireError,
|
||||
};
|
||||
use url::ParseError;
|
||||
|
||||
use crate::pool::PoolError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub kind: ErrorKind,
|
||||
pub error: Box<dyn error::Error + Send + Sync>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum ErrorKind {
|
||||
#[error("The operation was cancelled by the caller")]
|
||||
Cancelled = 1,
|
||||
|
||||
#[error("Unknown error")]
|
||||
Unknown = 2,
|
||||
|
||||
#[error("Client specified an invalid argument")]
|
||||
InvalidArgument = 3,
|
||||
|
||||
#[error("Deadline expired before operation could complete")]
|
||||
DeadlineExceeded = 4,
|
||||
|
||||
#[error("Requested entity was not found")]
|
||||
NotFound = 5,
|
||||
|
||||
#[error("Attempt to create entity that already exists")]
|
||||
AlreadyExists = 6,
|
||||
|
||||
#[error("Permission denied")]
|
||||
PermissionDenied = 7,
|
||||
|
||||
#[error("No valid authentication credentials")]
|
||||
Unauthenticated = 16,
|
||||
|
||||
#[error("Resource has been exhausted")]
|
||||
ResourceExhausted = 8,
|
||||
|
||||
#[error("Invalid state")]
|
||||
FailedPrecondition = 9,
|
||||
|
||||
#[error("Operation aborted")]
|
||||
Aborted = 10,
|
||||
|
||||
#[error("Operation attempted past the valid range")]
|
||||
OutOfRange = 11,
|
||||
|
||||
#[error("Not implemented")]
|
||||
Unimplemented = 12,
|
||||
|
||||
#[error("Internal error")]
|
||||
Internal = 13,
|
||||
|
||||
#[error("Service unavailable")]
|
||||
Unavailable = 14,
|
||||
|
||||
#[error("Entity has not been modified since the last request")]
|
||||
NotModified = 15,
|
||||
|
||||
#[error("Operation must not be used")]
|
||||
DoNotUse = -1,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
struct ErrorMessage(String);
|
||||
|
||||
impl fmt::Display for ErrorMessage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn new<E>(kind: ErrorKind, error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aborted<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::Aborted,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn already_exists<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::AlreadyExists,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancelled<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::Cancelled,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_modified<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::NotModified,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deadline_exceeded<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::DeadlineExceeded,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn do_not_use<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::DoNotUse,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed_precondition<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::FailedPrecondition,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::Internal,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_argument<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::InvalidArgument,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn out_of_range<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::OutOfRange,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn permission_denied<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::PermissionDenied,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_exhausted<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::ResourceExhausted,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unauthenticated<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::Unauthenticated,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unavailable<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::Unavailable,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unimplemented<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::Unimplemented,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unknown<E>(error: E) -> Error
|
||||
where
|
||||
E: Into<Box<dyn error::Error + Send + Sync>>,
|
||||
{
|
||||
Self {
|
||||
kind: ErrorKind::Unknown,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
self.error.source()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(fmt, "{} {{ ", self.kind)?;
|
||||
self.error.fmt(fmt)?;
|
||||
write!(fmt, " }}")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if let Some(status) = err.status() {
|
||||
match status {
|
||||
StatusCode::NOT_FOUND => Self::not_found(err),
|
||||
StatusCode::BAD_REQUEST => Self::invalid_argument(err),
|
||||
StatusCode::UNAUTHORIZED => Self::unauthenticated(err),
|
||||
StatusCode::FORBIDDEN => Self::permission_denied(err),
|
||||
StatusCode::TOO_MANY_REQUESTS => Self::resource_exhausted(err),
|
||||
StatusCode::GATEWAY_TIMEOUT => Self::deadline_exceeded(err),
|
||||
_ => Self::unknown(err),
|
||||
}
|
||||
} else if err.is_body() || err.is_request() {
|
||||
Self::invalid_argument(err)
|
||||
} else if err.is_builder() {
|
||||
Self::internal(err)
|
||||
} else if err.is_connect() || err.is_redirect() {
|
||||
Self::unavailable(err)
|
||||
} else if err.is_decode() {
|
||||
Self::failed_precondition(err)
|
||||
} else if err.is_timeout() {
|
||||
Self::deadline_exceeded(err)
|
||||
} else {
|
||||
Self::unknown(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<time::error::Parse> for Error {
|
||||
fn from(err: time::error::Parse) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<time::error::ComponentRange> for Error {
|
||||
fn from(err: time::error::ComponentRange) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
use std::io::ErrorKind as IoErrorKind;
|
||||
match err.kind() {
|
||||
IoErrorKind::NotFound => Self::new(ErrorKind::NotFound, err),
|
||||
IoErrorKind::PermissionDenied => Self::new(ErrorKind::PermissionDenied, err),
|
||||
IoErrorKind::AddrInUse | IoErrorKind::AlreadyExists => {
|
||||
Self::new(ErrorKind::AlreadyExists, err)
|
||||
}
|
||||
IoErrorKind::AddrNotAvailable
|
||||
| IoErrorKind::ConnectionRefused
|
||||
| IoErrorKind::NotConnected => Self::new(ErrorKind::Unavailable, err),
|
||||
IoErrorKind::BrokenPipe
|
||||
| IoErrorKind::ConnectionReset
|
||||
| IoErrorKind::ConnectionAborted => Self::new(ErrorKind::Aborted, err),
|
||||
IoErrorKind::Interrupted | IoErrorKind::WouldBlock => {
|
||||
Self::new(ErrorKind::Cancelled, err)
|
||||
}
|
||||
IoErrorKind::InvalidData | IoErrorKind::UnexpectedEof => {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
IoErrorKind::TimedOut => Self::new(ErrorKind::DeadlineExceeded, err),
|
||||
IoErrorKind::InvalidInput => Self::new(ErrorKind::InvalidArgument, err),
|
||||
IoErrorKind::WriteZero => Self::new(ErrorKind::ResourceExhausted, err),
|
||||
_ => Self::new(ErrorKind::Unknown, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FromUtf8Error> for Error {
|
||||
fn from(err: FromUtf8Error) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseError> for Error {
|
||||
fn from(err: ParseError) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseIntError> for Error {
|
||||
fn from(err: ParseIntError) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TryFromIntError> for Error {
|
||||
fn from(err: TryFromIntError) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProtobufError> for Error {
|
||||
fn from(err: ProtobufError) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecvError> for Error {
|
||||
fn from(err: RecvError) -> Self {
|
||||
Self::new(ErrorKind::Internal, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<SendError<T>> for Error {
|
||||
fn from(err: SendError<T>) -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::Internal,
|
||||
error: ErrorMessage(err.to_string()).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AcquireError> for Error {
|
||||
fn from(err: AcquireError) -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::ResourceExhausted,
|
||||
error: ErrorMessage(err.to_string()).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TryAcquireError> for Error {
|
||||
fn from(err: TryAcquireError) -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::ResourceExhausted,
|
||||
error: ErrorMessage(err.to_string()).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Utf8Error> for Error {
|
||||
fn from(err: Utf8Error) -> Self {
|
||||
Self::new(ErrorKind::FailedPrecondition, err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PoolError> for Error {
|
||||
fn from(value: PoolError) -> Self {
|
||||
match &value {
|
||||
PoolError::Empty => Self::invalid_argument("No spotify clients"),
|
||||
PoolError::Timeout(_) => Self::resource_exhausted(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IdError> for Error {
|
||||
fn from(value: IdError) -> Self {
|
||||
Self::invalid_argument(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NotModifiedRes<T> {
|
||||
fn into_option(self) -> Result<Option<T>, Error>;
|
||||
}
|
||||
|
||||
impl<T> NotModifiedRes<T> for Result<T, Error> {
|
||||
fn into_option(self) -> Result<Option<T>, Error> {
|
||||
match self {
|
||||
Ok(res) => Ok(Some(res)),
|
||||
Err(e) => {
|
||||
if matches!(e.kind, ErrorKind::NotModified) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
735
crates/spotifyioweb/src/gql_model.rs
Normal file
735
crates/spotifyioweb/src/gql_model.rs
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
//! Data model for the Spotify API
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DefaultOnError, DisplayFromStr, VecSkipError};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use spotifyio_model::{
|
||||
AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId,
|
||||
UserlikeId,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct LyricsWrap {
|
||||
pub lyrics: Lyrics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlWrap<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ArtistOption {
|
||||
Artist(ArtistItem),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum AlbumOption {
|
||||
Album(AlbumItem),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum TrackOption {
|
||||
Track(TrackItem),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum PlaylistOption {
|
||||
Playlist(GqlPlaylistItem),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ConcertOption {
|
||||
ConcertV2(ConcertGql),
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlPagination<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total_count: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
impl<T> Default for GqlPagination<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
total_count: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Lyrics {
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub sync_type: Option<SyncType>,
|
||||
pub lines: Vec<LyricsLine>,
|
||||
pub provider: Option<String>,
|
||||
pub provider_lyrics_id: Option<String>,
|
||||
pub provider_display_name: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[non_exhaustive]
|
||||
pub enum SyncType {
|
||||
LineSynced,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct LyricsLine {
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
pub start_time_ms: Option<u32>,
|
||||
pub words: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ArtistGqlWrap {
|
||||
pub artist_union: ArtistGql,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct ArtistGql {
|
||||
pub uri: ArtistId<'static>,
|
||||
pub profile: ArtistProfile,
|
||||
pub related_content: Option<RelatedContent>,
|
||||
pub stats: Option<ArtistStats>,
|
||||
#[serde(default)]
|
||||
pub visuals: Visuals,
|
||||
pub pre_release: Option<PrereleaseItem>,
|
||||
#[serde(default)]
|
||||
pub goods: Goods,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct ArtistProfile {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub verified: bool,
|
||||
#[serde(default)]
|
||||
pub biography: Biography,
|
||||
#[serde(default)]
|
||||
pub external_links: GqlPagination<ExternalLink>,
|
||||
#[serde(default)]
|
||||
pub playlists_v2: GqlPagination<GqlWrap<PlaylistOption>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Biography {
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ExternalLink {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct ArtistStats {
|
||||
pub followers: u32,
|
||||
pub monthly_listeners: u32,
|
||||
pub world_rank: u32,
|
||||
pub top_cities: GqlPagination<City>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct City {
|
||||
pub city: String,
|
||||
pub country: String,
|
||||
pub number_of_listeners: u32,
|
||||
pub region: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Visuals {
|
||||
pub avatar_image: Option<Image>,
|
||||
pub gallery: GqlPagination<Image>,
|
||||
pub header_image: Option<Image>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Goods {
|
||||
pub events: Events,
|
||||
pub merch: GqlPagination<MerchItem>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Events {
|
||||
pub concerts: GqlPagination<Concert>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Concert {
|
||||
pub uri: ConcertId<'static>,
|
||||
pub title: String,
|
||||
pub date: DateWrap,
|
||||
#[serde(default)]
|
||||
pub festival: bool,
|
||||
pub partner_links: Option<GqlPagination<PartnerLink>>,
|
||||
pub venue: Venue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct PartnerLink {
|
||||
pub partner_name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Venue {
|
||||
pub name: String,
|
||||
pub location: Option<Location>,
|
||||
pub coordinates: Option<Coordinates>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Location {
|
||||
pub name: String,
|
||||
pub city: Option<String>,
|
||||
pub coordinates: Option<Coordinates>,
|
||||
// ISO-3166 country code
|
||||
pub country: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Coordinates {
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct MerchItem {
|
||||
pub uri: String,
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
pub price: String,
|
||||
pub description: String,
|
||||
pub image: Image,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct Image {
|
||||
pub sources: Vec<ImageSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ImageSource {
|
||||
pub url: String,
|
||||
pub height: Option<u16>,
|
||||
pub width: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Name {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlPlaylistItem {
|
||||
pub uri: PlaylistId<'static>,
|
||||
pub name: String,
|
||||
pub images: GqlPagination<Image>,
|
||||
pub owner_v2: GqlWrap<UserItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct UserItem {
|
||||
pub uri: Option<UserId<'static>>,
|
||||
#[serde(alias = "displayName")]
|
||||
pub name: Option<String>,
|
||||
pub avatar: Option<Image>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct ArtistItem {
|
||||
pub uri: ArtistId<'static>,
|
||||
pub profile: Name,
|
||||
pub visuals: Option<Visuals>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct RelatedContent {
|
||||
#[serde(default)]
|
||||
pub discovered_on_v2: GqlPagination<GqlWrap<PlaylistOption>>,
|
||||
#[serde(default)]
|
||||
pub featuring_v2: GqlPagination<GqlWrap<PlaylistOption>>,
|
||||
#[serde(default)]
|
||||
pub related_artists: GqlPagination<ArtistItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PrereleaseLookup {
|
||||
pub lookup: Vec<GqlWrap<PrereleaseItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct PrereleaseItem {
|
||||
/// URI of the prerelease
|
||||
pub uri: PrereleaseId<'static>,
|
||||
pub pre_release_content: PrereleaseContent,
|
||||
pub release_date: DateWrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct PrereleaseContent {
|
||||
/// URI of the to-be-released album
|
||||
pub uri: Option<AlbumId<'static>>,
|
||||
pub name: String,
|
||||
pub cover_art: Option<Image>,
|
||||
pub artists: Option<GqlPagination<GqlWrap<ArtistItem>>>,
|
||||
pub tracks: Option<GqlPagination<PrereleaseTrackItem>>,
|
||||
pub copyright: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct PrereleaseTrackItem {
|
||||
pub uri: TrackId<'static>,
|
||||
pub name: String,
|
||||
pub duration: Option<DurationWrap>,
|
||||
pub artists: GqlPagination<GqlWrap<ArtistItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct DateWrap {
|
||||
#[serde(with = "time::serde::iso8601")]
|
||||
pub iso_string: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct DateYear {
|
||||
pub year: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct DurationWrap {
|
||||
pub total_milliseconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SearchResultWrap {
|
||||
pub search_v2: GqlSearchResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[non_exhaustive]
|
||||
pub enum MatchedField {
|
||||
Lyrics,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlSearchTrackWrap {
|
||||
pub item: GqlWrap<TrackOption>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub matched_fields: Vec<MatchedField>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChipOrder {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub items: Vec<Chip>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Chip {
|
||||
type_name: SearchItemType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct GqlSearchResult {
|
||||
#[serde(default)]
|
||||
pub artists: GqlPagination<GqlWrap<ArtistOption>>,
|
||||
#[serde(default)]
|
||||
pub albums_v2: GqlPagination<AlbumItemWrap>,
|
||||
#[serde(default)]
|
||||
pub tracks_v2: GqlPagination<GqlSearchTrackWrap>,
|
||||
#[serde(default)]
|
||||
pub playlists: GqlPagination<GqlWrap<PlaylistOption>>,
|
||||
#[serde(default)]
|
||||
pub users: GqlPagination<GqlWrap<UserItem>>,
|
||||
pub chip_order: ChipOrder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "__typename")]
|
||||
pub enum AlbumItemWrap {
|
||||
AlbumResponseWrapper {
|
||||
data: AlbumItem,
|
||||
},
|
||||
PreReleaseResponseWrapper {
|
||||
data: PrereleaseItem,
|
||||
},
|
||||
#[serde(alias = "GenericError")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct AlbumItem {
|
||||
pub uri: AlbumId<'static>,
|
||||
pub name: String,
|
||||
pub date: Option<DateYear>,
|
||||
pub cover_art: Option<Image>,
|
||||
pub artists: Option<GqlPagination<ArtistItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct TrackItem {
|
||||
pub uri: TrackId<'static>,
|
||||
pub name: String,
|
||||
pub duration: DurationWrap,
|
||||
pub artists: GqlPagination<ArtistItem>,
|
||||
pub album_of_track: AlbumItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ConcertGqlWrap {
|
||||
pub concert: ConcertOption,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct ConcertGql {
|
||||
pub uri: ConcertId<'static>,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artists: GqlPagination<GqlWrap<ArtistItem>>,
|
||||
#[serde(default, with = "time::serde::iso8601::option")]
|
||||
pub start_date_iso_string: Option<OffsetDateTime>,
|
||||
#[serde(default, with = "time::serde::iso8601::option")]
|
||||
pub doors_open_time_iso_string: Option<OffsetDateTime>,
|
||||
#[serde(default)]
|
||||
pub festival: bool,
|
||||
pub html_description: Option<String>,
|
||||
pub location: Option<Location>,
|
||||
#[serde(default)]
|
||||
pub offers: GqlPagination<ConcertOffer>,
|
||||
#[serde(default)]
|
||||
pub related_concerts: GqlPagination<GqlWrap<ConcertOption>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct ConcertOffer {
|
||||
pub access_code: String,
|
||||
pub currency: Option<String>,
|
||||
pub dates: Option<ConcertOfferDates>,
|
||||
#[serde(default)]
|
||||
pub first_party: bool,
|
||||
pub max_price: Option<f32>,
|
||||
pub min_price: Option<f32>,
|
||||
pub provider_image_url: Option<String>,
|
||||
pub provider_name: Option<String>,
|
||||
pub sale_type: Option<String>,
|
||||
pub sold_out: Option<bool>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct ConcertOfferDates {
|
||||
#[serde(with = "time::serde::iso8601")]
|
||||
pub start_date_iso_string: OffsetDateTime,
|
||||
#[serde(with = "time::serde::iso8601")]
|
||||
pub end_date_iso_string: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum SearchItemType {
|
||||
Artists,
|
||||
Albums,
|
||||
Tracks,
|
||||
Playlists,
|
||||
Users,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct UserProfile {
|
||||
pub uri: UserId<'static>,
|
||||
pub name: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub followers_count: Option<u32>,
|
||||
pub following_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub public_playlists: Vec<PublicPlaylistItem>,
|
||||
pub total_public_playlists_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct UserPlaylists {
|
||||
#[serde(default)]
|
||||
pub public_playlists: Vec<PublicPlaylistItem>,
|
||||
pub total_public_playlists_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct PublicPlaylistItem {
|
||||
pub uri: PlaylistId<'static>,
|
||||
pub name: String,
|
||||
pub owner_name: String,
|
||||
pub owner_uri: UserId<'static>,
|
||||
/// UID-based image id
|
||||
///
|
||||
/// - `spotify:image:ab67706c0000da8474fffd106bb7f5be3ba4b758`
|
||||
/// - `spotify:mosaic:ab67616d00001e021c04efd2804b16cf689de7f0:ab67616d00001e0269f63a842ea91ca7c522593a:ab67616d00001e0270dbc9f47669d120ad874ec1:ab67616d00001e027d384516b23347e92a587ed1`
|
||||
pub image_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct UserProfilesWrap<T> {
|
||||
pub profiles: Vec<T>,
|
||||
}
|
||||
|
||||
/// May be an artist or an user
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct FollowerItem {
|
||||
pub uri: UserId<'static>,
|
||||
pub name: Option<String>,
|
||||
pub followers_count: Option<u32>,
|
||||
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
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Seektable {
|
||||
pub padding_samples: u32,
|
||||
pub encoder_delay_samples: u32,
|
||||
pub pssh: String,
|
||||
pub timescale: u32,
|
||||
pub init_range: Option<(u32, u32)>,
|
||||
pub index_range: Option<(u32, u32)>,
|
||||
pub segments: Vec<(u32, u32)>,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Information about a track's artists, writers and producers
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackCredits {
|
||||
pub track_uri: TrackId<'static>,
|
||||
pub track_title: String,
|
||||
#[serde(default)]
|
||||
pub role_credits: Vec<RoleCredits>,
|
||||
// pub extended_credits: (),
|
||||
#[serde(default)]
|
||||
pub source_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoleCredits {
|
||||
pub role_title: String,
|
||||
pub artists: Vec<CreditedArtist>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreditedArtist {
|
||||
pub name: String,
|
||||
pub uri: Option<ArtistId<'static>>,
|
||||
pub creator_uri: Option<SongwriterId<'static>>,
|
||||
pub external_url: Option<String>,
|
||||
/// Image URL
|
||||
pub image_uri: Option<String>,
|
||||
pub subroles: Vec<String>,
|
||||
pub weight: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistWrap {
|
||||
pub playlist_v2: PlaylistOption,
|
||||
}
|
||||
|
||||
impl ArtistOption {
|
||||
pub fn into_option(self) -> Option<ArtistItem> {
|
||||
match self {
|
||||
ArtistOption::Artist(artist_item) => Some(artist_item),
|
||||
ArtistOption::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackOption {
|
||||
pub fn into_option(self) -> Option<TrackItem> {
|
||||
match self {
|
||||
TrackOption::Track(track_item) => Some(track_item),
|
||||
TrackOption::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaylistOption {
|
||||
pub fn into_option(self) -> Option<GqlPlaylistItem> {
|
||||
match self {
|
||||
PlaylistOption::Playlist(playlist_item) => Some(playlist_item),
|
||||
PlaylistOption::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConcertOption {
|
||||
pub fn into_option(self) -> Option<ConcertGql> {
|
||||
match self {
|
||||
ConcertOption::ConcertV2(concert) => Some(concert),
|
||||
ConcertOption::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GqlWrap<ArtistOption>> for Option<ArtistItem> {
|
||||
fn from(value: GqlWrap<ArtistOption>) -> Self {
|
||||
value.data.into_option()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GqlWrap<TrackOption>> for Option<TrackItem> {
|
||||
fn from(value: GqlWrap<TrackOption>) -> Self {
|
||||
value.data.into_option()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GqlWrap<PlaylistOption>> for Option<GqlPlaylistItem> {
|
||||
fn from(value: GqlWrap<PlaylistOption>) -> Self {
|
||||
value.data.into_option()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GqlWrap<ConcertOption>> for Option<ConcertGql> {
|
||||
fn from(value: GqlWrap<ConcertOption>) -> Self {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
56
crates/spotifyioweb/src/lib.rs
Normal file
56
crates/spotifyioweb/src/lib.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#[macro_use]
|
||||
mod component;
|
||||
|
||||
mod apresolve;
|
||||
mod cache;
|
||||
mod cdn_url;
|
||||
mod error;
|
||||
mod normalisation;
|
||||
mod pagination;
|
||||
mod pool;
|
||||
mod session;
|
||||
mod spclient;
|
||||
mod spotify_pool;
|
||||
mod totp;
|
||||
mod web_model;
|
||||
|
||||
pub mod gql_model;
|
||||
pub mod login;
|
||||
pub mod util;
|
||||
|
||||
pub use cache::{ApplicationCache, SessionCache};
|
||||
pub use cdn_url::{CdnUrl, CdnUrlError, MaybeExpiringUrl};
|
||||
pub use error::{Error, ErrorKind, NotModifiedRes};
|
||||
pub use governor::{Jitter, Quota};
|
||||
pub use normalisation::NormalisationData;
|
||||
pub use pool::PoolError;
|
||||
pub use session::{Session, SessionConfig};
|
||||
pub use spclient::{EtagResponse, RequestStrategy};
|
||||
pub use spotify_pool::{PoolConfig, SpotifyIoPool};
|
||||
pub use spotifyio_model as model;
|
||||
|
||||
/// Protobuf enums
|
||||
pub mod pb {
|
||||
pub use spotifyio_protocol::canvaz_meta::Type as CanvazType;
|
||||
pub use spotifyio_protocol::extended_metadata::ExtensionType;
|
||||
pub use spotifyio_protocol::extension_kind::ExtensionKind;
|
||||
pub use spotifyio_protocol::metadata::{
|
||||
album::Type as AlbumType,
|
||||
artist_with_role::ArtistRole,
|
||||
audio_file::Format as AudioFileFormat,
|
||||
copyright::Type as CopyrightType,
|
||||
episode::EpisodeType,
|
||||
image::Size as ImageSize,
|
||||
restriction::{Catalogue as CatalogueRestriction, Type as TypeRestriction},
|
||||
show::{ConsumptionOrder, MediaType},
|
||||
};
|
||||
pub use spotifyio_protocol::playlist4_external::{
|
||||
op::Kind as OpKind, source_info::Client, GeoblockBlockingType, ItemAttributeKind,
|
||||
ListAttributeKind,
|
||||
};
|
||||
pub use spotifyio_protocol::playlist_permission::PermissionLevel;
|
||||
pub use spotifyio_protocol::*;
|
||||
}
|
||||
|
||||
/// Version of the SpotifyIO crate
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
96
crates/spotifyioweb/src/login.rs
Normal file
96
crates/spotifyioweb/src/login.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use reqwest::{header, Client};
|
||||
use spotifyio_model::{Id, PrivateUser};
|
||||
use time::OffsetDateTime;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
spclient::Token,
|
||||
totp::Totp,
|
||||
web_model::{AuthTokenResponse, ServerTimeResponse},
|
||||
ApplicationCache, Error, SessionCache,
|
||||
};
|
||||
|
||||
pub(crate) async fn get_server_time(http: &Client) -> Result<u64, Error> {
|
||||
let resp = http
|
||||
.get("https://open.spotify.com/api/server-time")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let time = resp.json::<ServerTimeResponse>().await?;
|
||||
Ok(time.server_time)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_auth_token(
|
||||
http: &Client,
|
||||
totp_gen: &Totp,
|
||||
sp_dc: &str,
|
||||
) -> Result<Token, Error> {
|
||||
let server_time = get_server_time(http).await?;
|
||||
let totp = totp_gen.generate(server_time);
|
||||
|
||||
let resp = http
|
||||
.get(Url::parse_with_params(
|
||||
"https://open.spotify.com/api/token?reason=init&productType=web-player",
|
||||
[
|
||||
("totp", &totp),
|
||||
("totpVer", &totp_gen.version()),
|
||||
("ts", &server_time.to_string()),
|
||||
],
|
||||
)?)
|
||||
.header(header::COOKIE, format!("sp_dc={sp_dc}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let ok = resp.json::<AuthTokenResponse>().await?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let auth_token = Token {
|
||||
access_token: ok.access_token.clone(),
|
||||
expires_in: (ok.access_token_expiration_timestamp_ms / 1000
|
||||
- now.unix_timestamp() as u64) as u32,
|
||||
timestamp: now,
|
||||
};
|
||||
Ok(auth_token)
|
||||
} else {
|
||||
let error_data = resp.text().await.unwrap_or_default();
|
||||
Err(Error::unauthenticated(format!("auth error: {error_data}")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_own_profile(http: &Client, access_token: &str) -> Result<PrivateUser, Error> {
|
||||
Ok(http
|
||||
.get("https://api.spotify.com/v1/me")
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn new_session(app_cache: &ApplicationCache, sp_dc: &str) -> Result<SessionCache, Error> {
|
||||
let http = Client::builder()
|
||||
.user_agent(crate::util::USER_AGENT)
|
||||
.build()
|
||||
.unwrap();
|
||||
let totp_gen = Totp::new(&http).await.unwrap();
|
||||
let auth_token = get_auth_token(&http, &totp_gen, sp_dc).await?;
|
||||
|
||||
let profile = get_own_profile(&http, &auth_token.access_token).await?;
|
||||
let sc = app_cache.new_session(profile.id.id().to_owned());
|
||||
sc.write_sp_dc(sp_dc.to_owned());
|
||||
sc.write_auth_token(auth_token);
|
||||
sc.write_email(profile.email);
|
||||
if let Some(country) = profile.country {
|
||||
let cstr: &'static str = country.into();
|
||||
sc.write_country(cstr.to_owned());
|
||||
}
|
||||
sc.write_is_premium(
|
||||
profile
|
||||
.product
|
||||
.map(|p| matches!(p, spotifyio_model::SubscriptionLevel::Premium))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
Ok(sc)
|
||||
}
|
||||
45
crates/spotifyioweb/src/normalisation.rs
Normal file
45
crates/spotifyioweb/src/normalisation.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use std::io::{Cursor, Read, Seek};
|
||||
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// Audio metadata for volume normalization
|
||||
///
|
||||
/// 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>.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NormalisationData {
|
||||
pub track_gain_db: f32,
|
||||
pub track_peak: f32,
|
||||
pub album_gain_db: f32,
|
||||
pub album_peak: f32,
|
||||
}
|
||||
|
||||
impl NormalisationData {
|
||||
/// Parse normalisation data from a Spotify OGG header (first 167 bytes)
|
||||
pub fn parse_from_ogg<R: Read + Seek>(file: &mut R) -> Result<Self, Error> {
|
||||
let packets = ogg_pager::Packets::read_count(file, 1)
|
||||
.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!(
|
||||
"ogg header len={}, expected=139",
|
||||
packet.len()
|
||||
)));
|
||||
}
|
||||
let mut rdr = Cursor::new(&packet[116..]);
|
||||
|
||||
let track_gain_db = rdr.read_f32::<LittleEndian>()?;
|
||||
let track_peak = rdr.read_f32::<LittleEndian>()?;
|
||||
let album_gain_db = rdr.read_f32::<LittleEndian>()?;
|
||||
let album_peak = rdr.read_f32::<LittleEndian>()?;
|
||||
|
||||
Ok(Self {
|
||||
track_gain_db,
|
||||
track_peak,
|
||||
album_gain_db,
|
||||
album_peak,
|
||||
})
|
||||
}
|
||||
}
|
||||
99
crates/spotifyioweb/src/pagination.rs
Normal file
99
crates/spotifyioweb/src/pagination.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! Asynchronous implementation of automatic pagination requests.
|
||||
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
use futures_util::Stream;
|
||||
use spotifyio_model::Page;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// Alias for `futures::stream::Stream<Item = T>`, since async mode is enabled.
|
||||
pub type Paginator<'a, T> = Pin<Box<dyn Stream<Item = T> + 'a + Send>>;
|
||||
|
||||
pub type RequestFuture<'a, T> = Pin<Box<dyn 'a + Future<Output = Result<Page<T>, Error>> + Send>>;
|
||||
|
||||
/// This is used to handle paginated requests automatically.
|
||||
pub fn paginate_with_ctx<'a, Ctx: 'a + Send, T, Request>(
|
||||
ctx: Ctx,
|
||||
req: Request,
|
||||
page_size: u32,
|
||||
) -> Paginator<'a, Result<T, Error>>
|
||||
where
|
||||
T: 'a + Unpin + Send,
|
||||
Request: 'a + for<'ctx> Fn(&'ctx Ctx, u32, u32) -> RequestFuture<'ctx, T> + Send,
|
||||
{
|
||||
use async_stream::stream;
|
||||
let mut offset = 0;
|
||||
Box::pin(stream! {
|
||||
loop {
|
||||
let request = req(&ctx, page_size, offset);
|
||||
let page = request.await?;
|
||||
offset += page.items.len() as u32;
|
||||
// Occasionally, the Spotify will return an empty items with non-none next page
|
||||
// So we have to check both conditions
|
||||
// https://github.com/ramsayleung/rspotify/issues/492
|
||||
if page.items.is_empty() {
|
||||
break;
|
||||
}
|
||||
for item in page.items {
|
||||
yield Ok(item);
|
||||
}
|
||||
if page.next.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn paginate<'a, T, Fut, Request>(
|
||||
req: Request,
|
||||
page_size: u32,
|
||||
) -> Paginator<'a, Result<T, Error>>
|
||||
where
|
||||
T: 'a + Unpin + Send,
|
||||
Fut: Future<Output = Result<Page<T>, Error>> + Send,
|
||||
Request: 'a + Fn(u32, u32) -> Fut + Send,
|
||||
{
|
||||
use async_stream::stream;
|
||||
let mut offset = 0;
|
||||
Box::pin(stream! {
|
||||
loop {
|
||||
let request = req(page_size, offset);
|
||||
let page = request.await?;
|
||||
offset += page.items.len() as u32;
|
||||
for item in page.items {
|
||||
yield Ok(item);
|
||||
}
|
||||
if page.next.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use futures_util::{future, StreamExt};
|
||||
use spotifyio_model::Page;
|
||||
|
||||
use super::paginate;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mt_scheduling() {
|
||||
let mut paginator = paginate(
|
||||
|_, offset| {
|
||||
let fake_page = Page {
|
||||
items: vec![offset, offset + 1, offset + 2],
|
||||
..Page::default()
|
||||
};
|
||||
future::ok(fake_page)
|
||||
},
|
||||
32,
|
||||
);
|
||||
|
||||
let mut expected = [0, 1, 2].into_iter();
|
||||
while let Some(item) = paginator.next().await {
|
||||
assert_eq!(expected.next().unwrap(), item.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
275
crates/spotifyioweb/src/pool.rs
Normal file
275
crates/spotifyioweb/src/pool.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use governor::{clock::Clock, DefaultDirectRateLimiter, Jitter, Quota};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use rand::Rng;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PoolError {
|
||||
#[error("no clients available")]
|
||||
Empty,
|
||||
#[error("timeout: {0:?}")]
|
||||
Timeout(Duration),
|
||||
}
|
||||
|
||||
pub struct ClientPool<T> {
|
||||
robin: Mutex<usize>,
|
||||
entries: Vec<ClientPoolEntry<T>>,
|
||||
is_invalid: Box<dyn Fn(&T) -> bool + Sync + Send>,
|
||||
jitter: Jitter,
|
||||
}
|
||||
|
||||
struct ClientPoolEntry<T> {
|
||||
entry: T,
|
||||
limiter: DefaultDirectRateLimiter,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl<T> ClientPool<T> {
|
||||
pub fn new<E, F>(
|
||||
quota: Quota,
|
||||
jitter: Option<Jitter>,
|
||||
entries: E,
|
||||
is_invalid: F,
|
||||
) -> Result<Self, PoolError>
|
||||
where
|
||||
E: IntoIterator<Item = T>,
|
||||
F: Fn(&T) -> bool + Sync + Send + 'static,
|
||||
{
|
||||
let entries = entries
|
||||
.into_iter()
|
||||
.map(|entry| ClientPoolEntry {
|
||||
entry,
|
||||
limiter: DefaultDirectRateLimiter::direct(quota),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if entries.is_empty() {
|
||||
return Err(PoolError::Empty);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
robin: Mutex::default(),
|
||||
entries,
|
||||
is_invalid: Box::new(is_invalid),
|
||||
jitter: jitter.unwrap_or_else(|| Jitter::up_to(quota.replenish_interval() / 2)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||
self.entries.iter().map(|e| &e.entry)
|
||||
}
|
||||
|
||||
pub fn n_available(&self) -> usize {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| !(self.is_invalid)(&entry.entry))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn n_available_errored(&self) -> (usize, usize) {
|
||||
let n_available = self.n_available();
|
||||
(n_available, self.entries.len() - n_available)
|
||||
}
|
||||
|
||||
fn wrapping_inc(&self, mut a: usize) -> usize {
|
||||
a = a.wrapping_add(1);
|
||||
if a >= self.len() {
|
||||
a = 0;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn next_pos(&self, timeout: Option<Duration>) -> Result<(usize, Duration), PoolError> {
|
||||
let mut robin = self.robin.lock();
|
||||
let mut i = *robin;
|
||||
let mut min_to = Duration::MAX;
|
||||
|
||||
for _ in 0..((self.len() - 1).max(1)) {
|
||||
let entry = &self.entries[i];
|
||||
|
||||
if !(self.is_invalid)(&entry.entry) {
|
||||
match entry.limiter.check() {
|
||||
Ok(_) => {
|
||||
*robin = self.wrapping_inc(i);
|
||||
return Ok((i, Duration::ZERO));
|
||||
}
|
||||
Err(not_until) => {
|
||||
let wait_time = not_until.wait_time_from(entry.limiter.clock().now());
|
||||
if timeout.is_some_and(|to| wait_time > to) {
|
||||
min_to = min_to.min(wait_time);
|
||||
} else {
|
||||
*robin = self.wrapping_inc(i);
|
||||
return Ok((i, wait_time));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i = self.wrapping_inc(i);
|
||||
}
|
||||
// If the pool is empty or all entries were skipped
|
||||
Err(if min_to == Duration::MAX {
|
||||
PoolError::Empty
|
||||
} else {
|
||||
PoolError::Timeout(min_to)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a client from the pool, waiting until the rate limit passes
|
||||
pub async fn get(&self) -> Result<&T, PoolError> {
|
||||
self._get_timeout(None).await
|
||||
}
|
||||
|
||||
/// Get a client from the pool, waiting until the rate limit passes
|
||||
///
|
||||
/// If the timeout is longer than the given value, the function returns a [`PoolError::Timeout`].
|
||||
pub async fn get_timeout(&self, timeout: Duration) -> Result<&T, PoolError> {
|
||||
self._get_timeout(Some(timeout)).await
|
||||
}
|
||||
|
||||
/// Get a random, valid client from the pool, skipping the rate limit
|
||||
///
|
||||
/// If no valid clients were found, [`PoolError::Empty`] is returned
|
||||
pub fn random(&self) -> Result<&T, PoolError> {
|
||||
let mut pos = rand::rng().random_range(0..self.len());
|
||||
|
||||
for _ in 0..((self.len() - 1).max(1)) {
|
||||
let entry = &self.entries[pos].entry;
|
||||
if !(self.is_invalid)(entry) {
|
||||
return Ok(entry);
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
Err(PoolError::Empty)
|
||||
}
|
||||
|
||||
async fn _get_timeout(&self, timeout: Option<Duration>) -> Result<&T, PoolError> {
|
||||
let (i, wait_time) = self.next_pos(timeout)?;
|
||||
let entry = &self.entries[i];
|
||||
if !wait_time.is_zero() {
|
||||
let wait_with_jitter = self.jitter + wait_time;
|
||||
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;
|
||||
}
|
||||
Ok(&entry.entry)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{num::NonZeroU32, time::Duration};
|
||||
|
||||
use tracing_test::traced_test;
|
||||
|
||||
use super::*;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn one_client() {
|
||||
let pool = ClientPool::new(
|
||||
Quota::per_second(NonZeroU32::new(2).unwrap()),
|
||||
Some(Jitter::default()),
|
||||
[1],
|
||||
|_| false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Deplete the limiter
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
*tokio::time::timeout(TIMEOUT, pool.get())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert!(tokio::time::timeout(TIMEOUT, pool.get()).await.is_err());
|
||||
if let PoolError::Timeout(to) = pool.get_timeout(TIMEOUT).await.unwrap_err() {
|
||||
assert!(to > TIMEOUT, "got {to:?}");
|
||||
tokio::time::sleep(to).await;
|
||||
} else {
|
||||
panic!("no timeout err");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
*tokio::time::timeout(TIMEOUT, pool.get())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
|
||||
assert_eq!(*pool.random().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn one_client_invalid() {
|
||||
let pool = ClientPool::new(
|
||||
Quota::per_minute(NonZeroU32::new(2).unwrap()),
|
||||
Some(Jitter::default()),
|
||||
[1],
|
||||
|_| true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(pool.get().await.unwrap_err(), PoolError::Empty));
|
||||
assert!(matches!(
|
||||
pool.get_timeout(TIMEOUT).await.unwrap_err(),
|
||||
PoolError::Empty
|
||||
));
|
||||
assert!(matches!(pool.random().unwrap_err(), PoolError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn multiple_clients() {
|
||||
let pool = ClientPool::new(
|
||||
Quota::per_second(NonZeroU32::new(2).unwrap()),
|
||||
Some(Jitter::default()),
|
||||
[1, 2, 3, 4, 5], // active: 1, 3, 5
|
||||
|n| *n % 2 == 0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let is_valid = |n: &i32| assert!(matches!(*n, 1 | 3 | 5));
|
||||
|
||||
// Deplete the limiter
|
||||
for _ in 0..6 {
|
||||
is_valid(
|
||||
tokio::time::timeout(TIMEOUT, pool.get())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(tokio::time::timeout(TIMEOUT, pool.get()).await.is_err());
|
||||
if let PoolError::Timeout(to) = pool.get_timeout(TIMEOUT).await.unwrap_err() {
|
||||
assert!(to > TIMEOUT, "got {to:?}");
|
||||
tokio::time::sleep(to).await;
|
||||
} else {
|
||||
panic!("no timeout err");
|
||||
}
|
||||
|
||||
is_valid(
|
||||
tokio::time::timeout(TIMEOUT, pool.get())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
is_valid(pool.random().unwrap());
|
||||
}
|
||||
}
|
||||
142
crates/spotifyioweb/src/session.rs
Normal file
142
crates/spotifyioweb/src/session.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use std::sync::{Arc, Weak};
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::Client;
|
||||
use spotifyio_model::FileId;
|
||||
|
||||
use crate::{
|
||||
apresolve::ApResolver,
|
||||
cache::SessionCache,
|
||||
spclient::{RequestStrategy, SpClient},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SessionConfig {
|
||||
pub client_id: String,
|
||||
pub language: String,
|
||||
pub request_strategy: RequestStrategy,
|
||||
pub pagination_chunks: u32,
|
||||
}
|
||||
|
||||
impl Default for SessionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client_id: crate::util::SPOTIFY_CLIENT_ID.to_owned(),
|
||||
language: "en".to_owned(),
|
||||
request_strategy: RequestStrategy::TryTimes(10),
|
||||
pagination_chunks: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionInternal {
|
||||
config: SessionConfig,
|
||||
cache: SessionCache,
|
||||
|
||||
http_client: Client,
|
||||
|
||||
apresolver: OnceCell<ApResolver>,
|
||||
spclient: OnceCell<SpClient>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Session(Arc<SessionInternal>);
|
||||
|
||||
impl Session {
|
||||
pub fn new(config: SessionConfig, cache: SessionCache) -> Self {
|
||||
Self(Arc::new(SessionInternal {
|
||||
config,
|
||||
cache,
|
||||
http_client: Client::builder()
|
||||
.user_agent(crate::util::USER_AGENT)
|
||||
.build()
|
||||
.unwrap(),
|
||||
apresolver: OnceCell::new(),
|
||||
spclient: OnceCell::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &SessionConfig {
|
||||
&self.0.config
|
||||
}
|
||||
|
||||
pub fn cache(&self) -> &SessionCache {
|
||||
&self.0.cache
|
||||
}
|
||||
|
||||
pub fn apresolver(&self) -> &ApResolver {
|
||||
self.0
|
||||
.apresolver
|
||||
.get_or_init(|| ApResolver::new(self.weak()))
|
||||
}
|
||||
|
||||
pub fn http_client(&self) -> &Client {
|
||||
&self.0.http_client
|
||||
}
|
||||
|
||||
pub fn spclient(&self) -> &SpClient {
|
||||
self.0.spclient.get_or_init(|| SpClient::new(self.weak()))
|
||||
}
|
||||
|
||||
fn weak(&self) -> SessionWeak {
|
||||
SessionWeak(Arc::downgrade(&self.0))
|
||||
}
|
||||
|
||||
pub fn device_id(&self) -> String {
|
||||
self.cache().read_device_id()
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> &str {
|
||||
&self.0.config.client_id
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> String {
|
||||
self.cache().get_user_id()
|
||||
}
|
||||
|
||||
pub fn email(&self) -> Option<String> {
|
||||
self.cache().read_email()
|
||||
}
|
||||
|
||||
pub fn country(&self) -> Option<String> {
|
||||
self.cache().read_country()
|
||||
}
|
||||
|
||||
pub fn is_premium(&self) -> bool {
|
||||
self.cache().read_is_premium()
|
||||
}
|
||||
|
||||
pub fn catalogue(&self) -> &'static str {
|
||||
if self.cache().read_is_premium() {
|
||||
"premium"
|
||||
} else {
|
||||
"free"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn image_url(&self, image_id: &FileId) -> String {
|
||||
format!("https://i.scdn.co/image/{}", &image_id.base16())
|
||||
}
|
||||
|
||||
pub fn audio_preview_url(&self, file_id: &FileId) -> String {
|
||||
format!("https://p.scdn.co/mp3-preview/{}", &file_id.base16())
|
||||
}
|
||||
|
||||
pub fn head_file_url(&self, file_id: &FileId) -> String {
|
||||
format!("https://heads-fa.scdn.co/head/{}", &file_id.base16())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionWeak(Weak<SessionInternal>);
|
||||
|
||||
impl SessionWeak {
|
||||
fn try_upgrade(&self) -> Option<Session> {
|
||||
self.0.upgrade().map(Session)
|
||||
}
|
||||
|
||||
pub(crate) fn upgrade(&self) -> Session {
|
||||
self.try_upgrade()
|
||||
.expect("session was dropped and so should have this component")
|
||||
}
|
||||
}
|
||||
2248
crates/spotifyioweb/src/spclient.rs
Normal file
2248
crates/spotifyioweb/src/spclient.rs
Normal file
File diff suppressed because it is too large
Load diff
105
crates/spotifyioweb/src/spotify_pool.rs
Normal file
105
crates/spotifyioweb/src/spotify_pool.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use std::{num::NonZeroU32, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use governor::{Jitter, Quota};
|
||||
|
||||
use crate::{
|
||||
pool::{ClientPool, PoolError},
|
||||
spclient::SpClient,
|
||||
ApplicationCache, Error, Session, SessionConfig,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpotifyIoPool {
|
||||
inner: Arc<SpotifyIoPoolInner>,
|
||||
}
|
||||
|
||||
struct SpotifyIoPoolInner {
|
||||
app_cache: ApplicationCache,
|
||||
pool: ClientPool<Session>,
|
||||
}
|
||||
|
||||
pub struct PoolConfig {
|
||||
pub max_sessions: usize,
|
||||
pub quota_key: Quota,
|
||||
pub jitter: Option<Jitter>,
|
||||
}
|
||||
|
||||
impl Default for PoolConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_sessions: usize::MAX,
|
||||
quota_key: Quota::per_minute(NonZeroU32::new(1).unwrap())
|
||||
.allow_burst(NonZeroU32::new(3).unwrap()),
|
||||
jitter: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SpotifyIoPool {
|
||||
pub fn new<P: Into<PathBuf>>(
|
||||
cache_path: P,
|
||||
session_cfg: &SessionConfig,
|
||||
pool_cfg: &PoolConfig,
|
||||
) -> Result<Self, Error> {
|
||||
Self::from_app_cache(
|
||||
ApplicationCache::new(cache_path.into()),
|
||||
session_cfg,
|
||||
pool_cfg,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_app_cache(
|
||||
app_cache: ApplicationCache,
|
||||
session_cfg: &SessionConfig,
|
||||
pool_cfg: &PoolConfig,
|
||||
) -> Result<Self, Error> {
|
||||
let sessions = app_cache.sessions(session_cfg, pool_cfg.max_sessions);
|
||||
let pool = ClientPool::new(pool_cfg.quota_key, pool_cfg.jitter, sessions, |_| false)?;
|
||||
|
||||
Ok(Self {
|
||||
inner: SpotifyIoPoolInner { app_cache, pool }.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn app_cache(&self) -> &ApplicationCache {
|
||||
&self.inner.app_cache
|
||||
}
|
||||
|
||||
pub fn session(&self) -> Result<&Session, PoolError> {
|
||||
self.inner.pool.random()
|
||||
}
|
||||
|
||||
pub fn spclient(&self) -> Result<&SpClient, PoolError> {
|
||||
Ok(self.inner.pool.random()?.spclient())
|
||||
}
|
||||
|
||||
pub async fn get_widevine_license(
|
||||
&self,
|
||||
challenge: Vec<u8>,
|
||||
is_video: bool,
|
||||
) -> Result<Bytes, Error> {
|
||||
self.inner
|
||||
.pool
|
||||
.get()
|
||||
.await?
|
||||
.spclient()
|
||||
.get_widevine_license(challenge, is_video)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_widevine_license_timeout(
|
||||
&self,
|
||||
challenge: Vec<u8>,
|
||||
is_video: bool,
|
||||
timeout: Duration,
|
||||
) -> Result<Bytes, Error> {
|
||||
self.inner
|
||||
.pool
|
||||
.get_timeout(timeout)
|
||||
.await?
|
||||
.spclient()
|
||||
.get_widevine_license(challenge, is_video)
|
||||
.await
|
||||
}
|
||||
}
|
||||
88
crates/spotifyioweb/src/totp.rs
Normal file
88
crates/spotifyioweb/src/totp.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use std::{collections::BTreeMap, io::Write};
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::Client;
|
||||
use sha1::Sha1;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
const SPOTIFY_SECRETS_JSON: &str = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretDict.json";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Totp {
|
||||
version: u32,
|
||||
secret: Vec<u8>,
|
||||
updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
const PERIOD: u64 = 30;
|
||||
const DIGITS: u32 = 6;
|
||||
|
||||
impl Totp {
|
||||
pub async fn new(client: &Client) -> Result<Self, Error> {
|
||||
let resp = client
|
||||
.get(SPOTIFY_SECRETS_JSON)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let mut secrets = resp.json::<BTreeMap<u32, Vec<u8>>>().await?;
|
||||
let (version, latest_secret) = secrets
|
||||
.pop_last()
|
||||
.ok_or_else(|| Error::failed_precondition("no spotify-secrets found"))?;
|
||||
let secret = Self::derive_secret_number(&latest_secret);
|
||||
|
||||
Ok(Self {
|
||||
version,
|
||||
secret,
|
||||
updated_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_secret_number(secret_cipher_bytes: &[u8]) -> Vec<u8> {
|
||||
secret_cipher_bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| b ^ ((i % 33) as u8 + 9))
|
||||
.fold(String::new(), |acc, b| format!("{acc}{b}"))
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
pub fn generate(&self, timestamp: u64) -> String {
|
||||
let counter = timestamp / PERIOD;
|
||||
let counter_bytes = counter.to_be_bytes();
|
||||
let mut h = Hmac::<Sha1>::new_from_slice(&self.secret).unwrap();
|
||||
h.write_all(&counter_bytes).unwrap();
|
||||
let hmac_result = h.finalize().into_bytes();
|
||||
|
||||
let offset = (*hmac_result.last().unwrap() & 0x0F) as usize;
|
||||
let binary = (hmac_result[offset] as u32 & 0x7F) << 24
|
||||
| (hmac_result[offset + 1] as u32 & 0xFF) << 16
|
||||
| (hmac_result[offset + 2] as u32 & 0xFF) << 8
|
||||
| (hmac_result[offset + 3] as u32 & 0xFF);
|
||||
|
||||
let otp = binary % (10u32.pow(DIGITS));
|
||||
format!("{otp:0>6}")
|
||||
}
|
||||
|
||||
pub fn version(&self) -> String {
|
||||
self.version.to_string()
|
||||
}
|
||||
|
||||
pub fn is_fresh(&self) -> bool {
|
||||
self.updated_at + time::Duration::DAY > OffsetDateTime::now_utc()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn totp() {
|
||||
let client = Client::default();
|
||||
let totp = Totp::new(&client).await.unwrap();
|
||||
dbg!(&totp);
|
||||
dbg!(totp.generate(1759088655));
|
||||
}
|
||||
}
|
||||
111
crates/spotifyioweb/src/util.rs
Normal file
111
crates/spotifyioweb/src/util.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Additional utilities for interacting with Spotify
|
||||
|
||||
use crate::pb::AudioFileFormat;
|
||||
|
||||
pub type SocketAddress = (String, u16);
|
||||
|
||||
/// User agent for HTTP requests
|
||||
pub const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
|
||||
|
||||
/// Spotify app version
|
||||
pub const SPOTIFY_VERSION: &str = "1.2.74.275.g610d7cec";
|
||||
|
||||
/// Spotify client ID
|
||||
pub const SPOTIFY_CLIENT_ID: &str = "d8a5ed958d274c2e8ee717e6a4b0971d";
|
||||
|
||||
/// AES128 initialization vector for audio decryption
|
||||
pub const AUDIO_AESIV: [u8; 16] = [
|
||||
0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64, 0x3f, 0x63, 0x0d, 0x93,
|
||||
];
|
||||
|
||||
pub fn audio_format_extension(format: AudioFileFormat) -> Option<&'static str> {
|
||||
match format {
|
||||
AudioFileFormat::OGG_VORBIS_96
|
||||
| AudioFileFormat::OGG_VORBIS_160
|
||||
| AudioFileFormat::OGG_VORBIS_320 => Some(".ogg"),
|
||||
AudioFileFormat::MP3_96
|
||||
| AudioFileFormat::MP3_160
|
||||
| AudioFileFormat::MP3_160_ENC
|
||||
| AudioFileFormat::MP3_256
|
||||
| AudioFileFormat::MP3_320 => Some(".mp3"),
|
||||
AudioFileFormat::AAC_24
|
||||
| AudioFileFormat::AAC_48
|
||||
| AudioFileFormat::AAC_160
|
||||
| AudioFileFormat::AAC_320 => Some(".aac"),
|
||||
AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256 => Some(".m4a"),
|
||||
AudioFileFormat::FLAC_FLAC => Some(".flac"),
|
||||
AudioFileFormat::UNKNOWN_FORMAT => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio_format_mime(format: AudioFileFormat) -> Option<&'static str> {
|
||||
match format {
|
||||
AudioFileFormat::OGG_VORBIS_96
|
||||
| AudioFileFormat::OGG_VORBIS_160
|
||||
| AudioFileFormat::OGG_VORBIS_320 => Some("audio/ogg"),
|
||||
AudioFileFormat::MP3_96
|
||||
| AudioFileFormat::MP3_160
|
||||
| AudioFileFormat::MP3_160_ENC
|
||||
| AudioFileFormat::MP3_256
|
||||
| AudioFileFormat::MP3_320 => Some("audio/mp3"),
|
||||
AudioFileFormat::AAC_24
|
||||
| AudioFileFormat::AAC_48
|
||||
| AudioFileFormat::AAC_160
|
||||
| AudioFileFormat::AAC_320 => Some("audio/aac"),
|
||||
AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256 => Some("audio/mp4"),
|
||||
AudioFileFormat::FLAC_FLAC => Some("audio/flac"),
|
||||
AudioFileFormat::UNKNOWN_FORMAT => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the bitrate of the audio format in kbit/s
|
||||
pub fn audio_format_bitrate(format: AudioFileFormat) -> Option<u32> {
|
||||
match format {
|
||||
AudioFileFormat::OGG_VORBIS_96 | AudioFileFormat::MP3_96 => Some(96000),
|
||||
AudioFileFormat::OGG_VORBIS_160
|
||||
| AudioFileFormat::MP3_160
|
||||
| AudioFileFormat::MP3_160_ENC => Some(160000),
|
||||
AudioFileFormat::OGG_VORBIS_320 | AudioFileFormat::MP3_320 => Some(320000),
|
||||
AudioFileFormat::MP4_128 | AudioFileFormat::AAC_160 => Some(128000),
|
||||
AudioFileFormat::MP3_256 | AudioFileFormat::MP4_256 | AudioFileFormat::AAC_320 => {
|
||||
Some(256000)
|
||||
}
|
||||
AudioFileFormat::AAC_24 => Some(24000),
|
||||
AudioFileFormat::AAC_48 => Some(48000),
|
||||
AudioFileFormat::FLAC_FLAC | AudioFileFormat::UNKNOWN_FORMAT => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio_format_available(format: AudioFileFormat, is_premium: bool) -> bool {
|
||||
match format {
|
||||
AudioFileFormat::OGG_VORBIS_96
|
||||
| AudioFileFormat::OGG_VORBIS_160
|
||||
| AudioFileFormat::MP3_160
|
||||
| AudioFileFormat::MP3_96
|
||||
| AudioFileFormat::MP3_160_ENC
|
||||
| AudioFileFormat::AAC_24
|
||||
| AudioFileFormat::AAC_48
|
||||
| AudioFileFormat::AAC_160
|
||||
| AudioFileFormat::MP4_128 => true,
|
||||
AudioFileFormat::OGG_VORBIS_320
|
||||
| AudioFileFormat::MP3_256
|
||||
| AudioFileFormat::MP3_320
|
||||
| AudioFileFormat::AAC_320
|
||||
| AudioFileFormat::MP4_256
|
||||
| AudioFileFormat::FLAC_FLAC => is_premium,
|
||||
AudioFileFormat::UNKNOWN_FORMAT => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the audio format uses Widevine
|
||||
pub fn audio_format_widevine(format: AudioFileFormat) -> bool {
|
||||
matches!(
|
||||
format,
|
||||
AudioFileFormat::MP4_128
|
||||
| AudioFileFormat::MP4_256
|
||||
| AudioFileFormat::AAC_160
|
||||
| AudioFileFormat::AAC_320
|
||||
| AudioFileFormat::AAC_24
|
||||
| AudioFileFormat::AAC_48
|
||||
)
|
||||
}
|
||||
64
crates/spotifyioweb/src/web_model.rs
Normal file
64
crates/spotifyioweb/src/web_model.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClientTokenRequest<'a> {
|
||||
pub client_data: ClientTokenRequestData<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClientTokenRequestData<'a> {
|
||||
pub client_version: &'a str,
|
||||
pub client_id: &'a str,
|
||||
pub js_sdk_data: ClientTokenRequestJsData<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClientTokenRequestJsData<'a> {
|
||||
pub device_brand: &'a str,
|
||||
pub device_model: &'a str,
|
||||
pub os: &'a str,
|
||||
pub device_id: String,
|
||||
pub device_type: &'a str,
|
||||
}
|
||||
|
||||
impl Default for ClientTokenRequest<'_> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client_data: ClientTokenRequestData {
|
||||
client_version: crate::util::SPOTIFY_VERSION,
|
||||
client_id: crate::util::SPOTIFY_CLIENT_ID,
|
||||
js_sdk_data: ClientTokenRequestJsData {
|
||||
device_brand: "unknown",
|
||||
device_model: "unknown",
|
||||
os: "linux",
|
||||
device_id: uuid::Uuid::new_v4().as_simple().to_string(),
|
||||
device_type: "computer",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ClientTokenResponse {
|
||||
pub granted_token: ClientTokenResponseToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ClientTokenResponseToken {
|
||||
pub token: String,
|
||||
pub expires_after_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerTimeResponse {
|
||||
pub server_time: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthTokenResponse {
|
||||
pub access_token: String,
|
||||
pub access_token_expiration_timestamp_ms: u64,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue