diff --git a/Cargo.toml b/Cargo.toml index 02fcc65..41440d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ resolver = "2" protobuf = "3.5" # WS crates -spotifyio = { path = "crates/spotifyio", version = "0.0.3", registry = "thetadev" } -spotifyioweb = { path = "crates/spotifyioweb", version = "0.0.1", registry = "thetadev" } +spotifyio = { path = "crates/spotifyio", version = "0.0.2", registry = "thetadev" } spotifyio-protocol = { path = "crates/protocol", version = "0.1.0", registry = "thetadev" } spotifyio-model = { path = "crates/model", version = "0.2.0", registry = "thetadev" } diff --git a/crates/downloader/Cargo.toml b/crates/downloader/Cargo.toml index 9fef250..be2c425 100644 --- a/crates/downloader/Cargo.toml +++ b/crates/downloader/Cargo.toml @@ -13,20 +13,20 @@ categories.workspace = true default = ["default-tls"] # Reqwest TLS options -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"] +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"] native-tls-vendored = [ "reqwest/native-tls-vendored", - "spotifyioweb/native-tls-vendored", + "spotifyio/native-tls-vendored", ] rustls-tls-webpki-roots = [ "reqwest/rustls-tls-webpki-roots", - "spotifyioweb/rustls-tls-webpki-roots", + "spotifyio/rustls-tls-webpki-roots", ] rustls-tls-native-roots = [ "reqwest/rustls-tls-native-roots", - "spotifyioweb/rustls-tls-native-roots", + "spotifyio/rustls-tls-native-roots", ] [dependencies] @@ -68,4 +68,5 @@ go-parse-duration = "0.1.1" which = "7" widevine = "0.1" -spotifyioweb = { workspace = true } +spotifyio = { workspace = true, features = ["oauth"] } +spotifyio-protocol.workspace = true diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index a4a1f58..fa1cfda 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -13,6 +13,7 @@ use std::{ time::{Duration, Instant}, }; +use aes::cipher::{KeyIvInit, StreamCipher}; use futures_util::{stream, StreamExt, TryStreamExt}; use indicatif::{MultiProgress, ProgressBar}; use itertools::Itertools; @@ -28,21 +29,17 @@ use model::{ }; use once_cell::sync::Lazy; use path_macro::path; -use reqwest::header; use serde::Serialize; -use spotifyioweb::{ +use spotifyio::{ model::{ AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, PlaylistId, TrackId, }, pb::AudioFileFormat, - Error as SpotifyError, NotModifiedRes, PoolConfig, PoolError, Quota, Session, SessionConfig, - SpotifyIoPool, -}; -use spotifyioweb::{ - pb::metadata::{Album, Availability, Restriction, Track}, - CdnUrl, NormalisationData, + AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig, + PoolError, Quota, Session, SessionConfig, SpotifyIoPool, }; +use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track}; use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool}; use time::OffsetDateTime; @@ -51,7 +48,7 @@ use widevine::{Cdm, Pssh, ServiceCertificate}; pub mod model; -// type Aes128Ctr = ctr::Ctr128BE; +type Aes128Ctr = ctr::Ctr128BE; const DOT_SEPARATOR: &str = " • "; @@ -59,6 +56,8 @@ 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}")] @@ -111,6 +110,7 @@ pub struct SpotifyDownloaderInner { mp4decryptor: Option, cdm: Option, apply_tags: bool, + format_m4a: bool, widevine_cert: RwLock>, } @@ -127,9 +127,9 @@ pub enum Mp4Decryptor { impl Mp4Decryptor { pub fn from_env() -> Option { - // 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 = spotifyioweb::util::audio_format_extension(format).expect("unknown file format"); + let ext = spotifyio::util::audio_format_extension(format).expect("unknown file format"); better_filenamify(&format!("{:02} {}", track_nr, name,), Some(ext)) } @@ -253,14 +253,14 @@ impl From 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,12 +282,13 @@ pub struct SpotifyDownloaderConfig { pub widevine_device: Option, pub mp4decryptor: Option, pub apply_tags: bool, + pub format_m4a: bool, } impl Default for SpotifyDownloaderConfig { fn default() -> Self { Self { - cache_path: path!("spotifyioweb.json"), + cache_path: path!("spotifyio.json"), quota: Quota::per_minute(NonZeroU32::new(1).unwrap()) .allow_burst(NonZeroU32::new(3).unwrap()), db_path: path!("tracks.db"), @@ -296,6 +297,7 @@ impl Default for SpotifyDownloaderConfig { widevine_device: None, mp4decryptor: Mp4Decryptor::from_env(), apply_tags: true, + format_m4a: false, } } } @@ -332,6 +334,7 @@ impl SpotifyDownloader { cdm, mp4decryptor: cfg.mp4decryptor, apply_tags: cfg.apply_tags, + format_m4a: cfg.format_m4a, widevine_cert: RwLock::new(None), } .into(), @@ -339,6 +342,7 @@ impl SpotifyDownloader { } pub async fn shutdown(&self) { + self.i.sp.shutdown(); self.i.pool.close().await; } @@ -441,7 +445,24 @@ impl SpotifyDownloader { audio_item.track_id ); - let formats = &[AudioFileFormat::MP4_128]; + 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 (format, file_id) = formats .iter() @@ -454,8 +475,26 @@ impl SpotifyDownloader { Ok((audio_item, file_id, format)) } + async fn get_file_key_clearkey( + &self, + spotify_id: PlayableId<'_>, + file_id: FileId, + ) -> Result { + 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 { - tracing::debug!("getting widevine key for file:{file_id}"); + tracing::debug!("getting widevine key for {file_id}"); let cdm = self .i .cdm @@ -471,11 +510,7 @@ impl SpotifyDownloader { .open() .set_service_certificate(cert)? .get_license_request(pssh, widevine::LicenseType::STREAMING)?; - let license = self - .i - .sp - .get_widevine_license(request.challenge()?, false) - .await?; + let license = sp.get_widevine_license(request.challenge()?, false).await?; let keys = request.get_keys(&license)?; let key = keys .of_type(widevine::KeyType::CONTENT) @@ -486,15 +521,18 @@ impl SpotifyDownloader { async fn get_file_key( &self, + spotify_id: PlayableId<'_>, file_id: FileId, format: AudioFileFormat, ) -> Result { - if spotifyioweb::util::audio_format_widevine(format) { + if matches!(format, AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256) { Ok(AudioKeyVariant::Widevine( self.get_file_key_widevine(file_id).await?, )) } else { - Err(Error::AudioKey("clearkey unsupported".to_owned())) + Ok(AudioKeyVariant::Clearkey( + self.get_file_key_clearkey(spotify_id, file_id).await?, + )) } } @@ -590,7 +628,7 @@ impl SpotifyDownloader { audio_url: &str, audio_key: &AudioKeyVariant, dest: &Path, - ) -> Result { + ) -> Result<(Option, u64), Error> { let tpath_tmp = dest.with_extension("tmp"); let mut res = self @@ -603,13 +641,13 @@ impl SpotifyDownloader { .await? .error_for_status()?; - let file_size = res + let mut 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; @@ -635,7 +673,6 @@ 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 @@ -648,37 +685,12 @@ impl SpotifyDownloader { } drop(file); - tracing::debug!("widevine key: {:?}", key); - decryptor.decrypt(&tpath_tmp, dest, key)?; std::fs::remove_file(&tpath_tmp)?; } } - Ok(file_size) - } - - async fn get_norm_data( - &self, - audio_files: &AudioFiles, - ) -> Result, 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) - } + Ok((norm_data, file_size)) } #[tracing::instrument(level = "error", skip(self, force))] @@ -705,7 +717,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(file_id, audio_format).await?; + let key = self.get_file_key(pid, file_id, audio_format).await?; let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields { f @@ -783,9 +795,8 @@ impl SpotifyDownloader { std::fs::create_dir_all(&album_dir)?; // Download the file - 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?; + let (norm_data, file_size) = self.download_audio_file(audio_url, &key, &tpath).await?; + let file_size = file_size as i64; // Download album cover let cover_path = path!(album_dir / "cover.jpg"); @@ -1550,33 +1561,3 @@ 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); - } -} diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index 7d4578c..274e347 100644 --- a/crates/downloader/src/main.rs +++ b/crates/downloader/src/main.rs @@ -12,11 +12,11 @@ use std::{ use clap::{Parser, Subcommand}; use futures_util::{stream, StreamExt, TryStreamExt}; use indicatif::{MultiProgress, ProgressBar}; -use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig}; -use spotifyioweb::{ +use spotifyio::{ model::{AlbumId, ArtistId, Id, IdConstruct, PlaylistId, SearchResult, SearchType, TrackId}, - ApplicationCache, Quota, Session, SessionConfig, + ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig, }; +use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig}; 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 = "spotifyioweb.json")] + #[clap(long, default_value = "spotifyio.json")] cache: PathBuf, /// Path to music library base directory #[clap(long, default_value = "Downloads")] @@ -76,9 +76,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { /// Add an account - Login { - sp_dc: String, - }, + Login, /// Remove an account Logout { /// Account ID to log out @@ -144,7 +142,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 { @@ -155,9 +153,17 @@ 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 { sp_dc } => { - let cache = spotifyioweb::login::new_session(&app_cache, &sp_dc).await?; + Commands::Login => { + let token = spotifyio::oauth::get_access_token(None).await?; + let cache = spotifyio::oauth::new_session(&app_cache, &token.access_token).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() { @@ -197,6 +203,7 @@ 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() })?; @@ -247,7 +254,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { name: a.name.to_owned(), }] } else { - return Err(Error::Spotify(spotifyioweb::Error::not_found( + return Err(Error::Spotify(spotifyio::Error::not_found( "no artists returned", ))); } @@ -427,7 +434,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(()) diff --git a/crates/downloader/src/model/audio_file.rs b/crates/downloader/src/model/audio_file.rs index b4848d2..3749b80 100644 --- a/crates/downloader/src/model/audio_file.rs +++ b/crates/downloader/src/model/audio_file.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, fmt::Debug}; -use spotifyioweb::pb::metadata::AudioFile as AudioFileMessage; -use spotifyioweb::{model::FileId, pb::AudioFileFormat}; +use spotifyio::{model::FileId, pb::AudioFileFormat}; +use spotifyio_protocol::metadata::AudioFile as AudioFileMessage; #[derive(Debug, Clone, Default)] pub struct AudioFiles(pub HashMap); diff --git a/crates/downloader/src/model/availability.rs b/crates/downloader/src/model/availability.rs index e38ee6c..c0a36c0 100644 --- a/crates/downloader/src/model/availability.rs +++ b/crates/downloader/src/model/availability.rs @@ -1,5 +1,5 @@ -use spotifyioweb::pb::metadata::Availability as AvailabilityMessage; -use spotifyioweb::Error; +use spotifyio::Error; +use spotifyio_protocol::metadata::Availability as AvailabilityMessage; use time::OffsetDateTime; pub type AudioItemAvailability = Result<(), UnavailabilityReason>; diff --git a/crates/downloader/src/model/mod.rs b/crates/downloader/src/model/mod.rs index 3aa83cf..fd08077 100644 --- a/crates/downloader/src/model/mod.rs +++ b/crates/downloader/src/model/mod.rs @@ -1,15 +1,15 @@ use std::path::PathBuf; use serde::Serialize; -use spotifyioweb::pb::metadata::{ - album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage, - Date as DateMessage, Image, -}; -use spotifyioweb::{ +use spotifyio::{ 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; diff --git a/crates/spotifyio/Cargo.toml b/crates/spotifyio/Cargo.toml index f575970..71bdfec 100644 --- a/crates/spotifyio/Cargo.toml +++ b/crates/spotifyio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spotifyio" -version = "0.0.3" +version = "0.0.2" description = "Internal Spotify API Client" edition.workspace = true authors.workspace = true @@ -42,6 +42,8 @@ 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" diff --git a/crates/spotifyio/src/cdn_url.rs b/crates/spotifyio/src/cdn_url.rs index 359a872..f115e08 100644 --- a/crates/spotifyio/src/cdn_url.rs +++ b/crates/spotifyio/src/cdn_url.rs @@ -135,22 +135,17 @@ impl TryFrom 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.find('~') { + if let Some(end) = token.1.find('~') { // this is the only valid invariant for spotifycdn.com - let slice = &token[..end]; + let slice = &token.1[..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('_'); @@ -194,21 +189,20 @@ 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(), 5); - assert!(urls[4].1.is_none()); - + 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()); 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() - ); - } + assert_eq!( + urls[0].1.unwrap().unix_timestamp(), + timestamp_margin.whole_seconds() + ); } } diff --git a/crates/spotifyio/src/error.rs b/crates/spotifyio/src/error.rs index c072d9f..a343dc5 100644 --- a/crates/spotifyio/src/error.rs +++ b/crates/spotifyio/src/error.rs @@ -441,7 +441,7 @@ impl From for Error { impl From for Error { fn from(value: PoolError) -> Self { match &value { - PoolError::Empty => Self::invalid_argument("No spotify clients"), + PoolError::Empty => Self::invalid_argument(value), PoolError::Timeout(_) => Self::resource_exhausted(value), } } diff --git a/crates/spotifyio/src/gql_model.rs b/crates/spotifyio/src/gql_model.rs index 51c6778..699df29 100644 --- a/crates/spotifyio/src/gql_model.rs +++ b/crates/spotifyio/src/gql_model.rs @@ -3,7 +3,7 @@ use std::num::NonZeroU32; use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, DefaultOnError, DisplayFromStr, VecSkipError}; +use serde_with::{serde_as, DefaultOnError, DisplayFromStr}; use time::OffsetDateTime; use spotifyio_model::{ @@ -16,39 +16,18 @@ pub(crate) struct LyricsWrap { pub lyrics: Lyrics, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ItemWrap { + pub item: T, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct GqlWrap { 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)] @@ -396,52 +375,20 @@ 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, - #[serde(default)] - #[serde_as(as = "VecSkipError<_>")] - pub matched_fields: Vec, -} - -#[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChipOrder { - #[serde_as(as = "VecSkipError<_>")] - pub items: Vec, -} - -#[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>, + pub artists: GqlPagination>, #[serde(default)] pub albums_v2: GqlPagination, #[serde(default)] - pub tracks_v2: GqlPagination, + pub tracks_v2: GqlPagination>>, #[serde(default)] pub playlists: GqlPagination>, #[serde(default)] pub users: GqlPagination>, - pub chip_order: ChipOrder, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -535,8 +482,7 @@ pub struct ConcertOfferDates { pub end_date_iso_string: OffsetDateTime, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SearchItemType { Artists, Albums, @@ -612,8 +558,8 @@ pub struct Seektable { pub encoder_delay_samples: u32, pub pssh: String, pub timescale: u32, - pub init_range: Option<(u32, u32)>, - pub index_range: Option<(u32, u32)>, + #[serde(alias = "init_range")] + pub index_range: (u32, u32), pub segments: Vec<(u32, u32)>, pub offset: usize, } @@ -657,24 +603,6 @@ pub struct PlaylistWrap { pub playlist_v2: PlaylistOption, } -impl ArtistOption { - pub fn into_option(self) -> Option { - match self { - ArtistOption::Artist(artist_item) => Some(artist_item), - ArtistOption::NotFound => None, - } - } -} - -impl TrackOption { - pub fn into_option(self) -> Option { - match self { - TrackOption::Track(track_item) => Some(track_item), - TrackOption::NotFound => None, - } - } -} - impl PlaylistOption { pub fn into_option(self) -> Option { match self { @@ -693,18 +621,6 @@ impl ConcertOption { } } -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} - -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} - impl From> for Option { fn from(value: GqlWrap) -> Self { value.data.into_option() diff --git a/crates/spotifyio/src/lib.rs b/crates/spotifyio/src/lib.rs index 102a6ac..f573bcb 100644 --- a/crates/spotifyio/src/lib.rs +++ b/crates/spotifyio/src/lib.rs @@ -70,9 +70,6 @@ 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, diff --git a/crates/spotifyio/src/session.rs b/crates/spotifyio/src/session.rs index 5953f2d..04589e7 100644 --- a/crates/spotifyio/src/session.rs +++ b/crates/spotifyio/src/session.rs @@ -391,12 +391,8 @@ impl Session { 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()) + pub fn audio_preview_url(&self, preview_id: &FileId) -> String { + format!("https://p.scdn.co/mp3-preview/{}", &preview_id.base16()) } pub fn shutdown(&self) { diff --git a/crates/spotifyio/src/spclient.rs b/crates/spotifyio/src/spclient.rs index 81967eb..14b226d 100644 --- a/crates/spotifyio/src/spclient.rs +++ b/crates/spotifyio/src/spclient.rs @@ -197,9 +197,8 @@ impl SpClient { } pub async fn base_url(&self) -> Result { - // let ap = self.get_accesspoint().await?; - // Ok(format!("https://{}:{}", ap.0, ap.1)) - Ok("https://spclient.wg.spotify.com".to_owned()) + let ap = self.get_accesspoint().await?; + Ok(format!("https://{}:{}", ap.0, ap.1)) } pub async fn client_token(&self) -> Result { @@ -499,7 +498,10 @@ impl SpClient { } #[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))] - async fn _request_generic(&self, p: RequestParams<'_>) -> Result, Error> { + pub async fn _request_generic( + &self, + p: RequestParams<'_>, + ) -> Result, Error> { let mut tries: usize = 0; let mut last_error: Error; let mut retry_after: Option = None; @@ -530,7 +532,6 @@ 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 { @@ -619,7 +620,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.is_multiple_of(3) { + if tries % 3 == 0 { self.flush_accesspoint().await } } @@ -643,7 +644,7 @@ impl SpClient { Err(last_error) } - async fn request_pb( + pub async fn request_pb( &self, method: Method, endpoint: &str, @@ -661,7 +662,7 @@ impl SpClient { Ok(O::parse_from_bytes(&res.data)?) } - async fn request_get_pb( + pub async fn request_get_pb( &self, endpoint: &str, if_none_match: Option<&str>, @@ -953,9 +954,9 @@ impl SpClient { Ok(resp) } - pub async fn get_audio_preview(&self, file_id: &FileId) -> Result { - debug!("getting audio preview for file:{file_id}"); - let mut url = self.session().audio_preview_url(file_id); + pub async fn get_audio_preview(&self, preview_id: &FileId) -> Result { + debug!("getting audio preview {preview_id}"); + let mut url = self.session().audio_preview_url(preview_id); let separator = match url.find('?') { Some(_) => "&", None => "?", @@ -972,22 +973,9 @@ impl SpClient { Ok(resp) } - pub async fn get_head_file(&self, file_id: &FileId) -> Result { - 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 { - debug!("getting seektable for file:{file_id}"); + debug!("getting seektable {file_id}"); let url = format!( "https://seektables.scdn.co/seektable/{}.json", file_id.base16() @@ -2313,7 +2301,6 @@ mod tests { } #[tokio::test] - #[tracing_test::traced_test] async fn search() { let s = conn().await; let search = s diff --git a/crates/spotifyio/src/spotify_id.rs b/crates/spotifyio/src/spotify_id.rs new file mode 100644 index 0000000..d4b6794 --- /dev/null +++ b/crates/spotifyio/src/spotify_id.rs @@ -0,0 +1,617 @@ +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 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 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 { + 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 { + 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 { + 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 { + // 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 { + 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, 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 { + // 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 { + 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(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_uri().map_err(serde::ser::Error::custom)?) + } +} + +impl<'de> Deserialize<'de> for SpotifyId { + fn deserialize(deserializer: D) -> Result + 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(self, v: &str) -> Result + 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 { + 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::(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); + } +} diff --git a/crates/spotifyioweb/Cargo.toml b/crates/spotifyioweb/Cargo.toml deleted file mode 100644 index 76f6ee7..0000000 --- a/crates/spotifyioweb/Cargo.toml +++ /dev/null @@ -1,68 +0,0 @@ -[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" diff --git a/crates/spotifyioweb/src/apresolve.rs b/crates/spotifyioweb/src/apresolve.rs deleted file mode 100644 index 1ce4f08..0000000 --- a/crates/spotifyioweb/src/apresolve.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::collections::VecDeque; - -use serde::Deserialize; -use tracing::warn; - -use crate::{util::SocketAddress, Error}; - -#[derive(Default)] -pub struct AccessPoints { - accesspoint: VecDeque, - dealer: VecDeque, - spclient: VecDeque, -} - -#[derive(Deserialize, Default)] -pub struct ApResolveData { - accesspoint: Vec, - dealer: Vec, - spclient: Vec, -} - -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) -> VecDeque { - 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 { - let data = self - .session() - .http_client() - .get("https://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient") - .send() - .await? - .error_for_status()? - .json::() - .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 { - 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) - }) - } -} diff --git a/crates/spotifyioweb/src/cache.rs b/crates/spotifyioweb/src/cache.rs deleted file mode 100644 index 04f957f..0000000 --- a/crates/spotifyioweb/src/cache.rs +++ /dev/null @@ -1,329 +0,0 @@ -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; - -#[derive(Clone)] -pub struct ApplicationCache { - inner: Arc, -} - -struct CacheInner { - path: PathBuf, - data: RwLock, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CachedSession { - device_id: String, - email: Option, - country: Option, - is_premium: bool, - sp_dc: String, - auth_token: Option, - client_token: Option, - #[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, - /// Username as cache key - user_id: String, -} - -pub struct AccountMetadata { - pub user_id: String, - pub email: Option, - 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 { - let data = self.inner.data.read().unwrap(); - data.keys().cloned().collect() - } - - pub fn first_session(&self) -> Result { - 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 { - 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 { - 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::>(); - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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) { - 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()); - } -} diff --git a/crates/spotifyioweb/src/cdn_url.rs b/crates/spotifyioweb/src/cdn_url.rs deleted file mode 100644 index 359a872..0000000 --- a/crates/spotifyioweb/src/cdn_url.rs +++ /dev/null @@ -1,214 +0,0 @@ -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); - -const CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60); - -#[derive(Debug, Clone)] -pub struct MaybeExpiringUrls(pub Vec); - -impl Deref for MaybeExpiringUrls { - type Target = Vec; - 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 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 { - 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 for MaybeExpiringUrls { - type Error = crate::Error; - fn try_from(msg: CdnUrlMessage) -> Result { - 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 = None; - - if is_expiring { - let mut expiry_str: Option = 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::() { - 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::, 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() - ); - } - } -} diff --git a/crates/spotifyioweb/src/component.rs b/crates/spotifyioweb/src/component.rs deleted file mode 100644 index 9546a4a..0000000 --- a/crates/spotifyioweb/src/component.rs +++ /dev/null @@ -1,31 +0,0 @@ -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 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,)* - } - } -} diff --git a/crates/spotifyioweb/src/error.rs b/crates/spotifyioweb/src/error.rs deleted file mode 100644 index 9ef160e..0000000 --- a/crates/spotifyioweb/src/error.rs +++ /dev/null @@ -1,460 +0,0 @@ -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, -} - -#[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(kind: ErrorKind, error: E) -> Error - where - E: Into>, - { - Self { - kind, - error: error.into(), - } - } - - pub fn aborted(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::Aborted, - error: error.into(), - } - } - - pub fn already_exists(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::AlreadyExists, - error: error.into(), - } - } - - pub fn cancelled(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::Cancelled, - error: error.into(), - } - } - - pub fn not_modified(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::NotModified, - error: error.into(), - } - } - - pub fn deadline_exceeded(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::DeadlineExceeded, - error: error.into(), - } - } - - pub fn do_not_use(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::DoNotUse, - error: error.into(), - } - } - - pub fn failed_precondition(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::FailedPrecondition, - error: error.into(), - } - } - - pub fn internal(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::Internal, - error: error.into(), - } - } - - pub fn invalid_argument(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::InvalidArgument, - error: error.into(), - } - } - - pub fn not_found(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::NotFound, - error: error.into(), - } - } - - pub fn out_of_range(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::OutOfRange, - error: error.into(), - } - } - - pub fn permission_denied(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::PermissionDenied, - error: error.into(), - } - } - - pub fn resource_exhausted(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::ResourceExhausted, - error: error.into(), - } - } - - pub fn unauthenticated(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::Unauthenticated, - error: error.into(), - } - } - - pub fn unavailable(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::Unavailable, - error: error.into(), - } - } - - pub fn unimplemented(error: E) -> Error - where - E: Into>, - { - Self { - kind: ErrorKind::Unimplemented, - error: error.into(), - } - } - - pub fn unknown(error: E) -> Error - where - E: Into>, - { - 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 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 for Error { - fn from(err: time::error::Parse) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From for Error { - fn from(err: time::error::ComponentRange) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From for Error { - fn from(err: serde_json::Error) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From 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 for Error { - fn from(err: FromUtf8Error) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From for Error { - fn from(err: ParseError) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From for Error { - fn from(err: ParseIntError) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From for Error { - fn from(err: TryFromIntError) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From for Error { - fn from(err: ProtobufError) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From for Error { - fn from(err: RecvError) -> Self { - Self::new(ErrorKind::Internal, err) - } -} - -impl From> for Error { - fn from(err: SendError) -> Self { - Self { - kind: ErrorKind::Internal, - error: ErrorMessage(err.to_string()).into(), - } - } -} - -impl From for Error { - fn from(err: AcquireError) -> Self { - Self { - kind: ErrorKind::ResourceExhausted, - error: ErrorMessage(err.to_string()).into(), - } - } -} - -impl From for Error { - fn from(err: TryAcquireError) -> Self { - Self { - kind: ErrorKind::ResourceExhausted, - error: ErrorMessage(err.to_string()).into(), - } - } -} - -impl From for Error { - fn from(err: Utf8Error) -> Self { - Self::new(ErrorKind::FailedPrecondition, err) - } -} - -impl From 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 for Error { - fn from(value: IdError) -> Self { - Self::invalid_argument(value) - } -} - -pub trait NotModifiedRes { - fn into_option(self) -> Result, Error>; -} - -impl NotModifiedRes for Result { - fn into_option(self) -> Result, Error> { - match self { - Ok(res) => Ok(Some(res)), - Err(e) => { - if matches!(e.kind, ErrorKind::NotModified) { - Ok(None) - } else { - Err(e) - } - } - } - } -} diff --git a/crates/spotifyioweb/src/gql_model.rs b/crates/spotifyioweb/src/gql_model.rs deleted file mode 100644 index 51c6778..0000000 --- a/crates/spotifyioweb/src/gql_model.rs +++ /dev/null @@ -1,735 +0,0 @@ -//! 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 { - 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 { - pub items: Vec, - pub total_count: Option, -} - -impl Default for GqlPagination { - 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, - pub lines: Vec, - pub provider: Option, - pub provider_lyrics_id: Option, - pub provider_display_name: Option, - pub language: Option, -} - -#[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")] - pub start_time_ms: Option, - 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, - pub stats: Option, - #[serde(default)] - pub visuals: Visuals, - pub pre_release: Option, - #[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, - #[serde(default)] - pub playlists_v2: GqlPagination>, -} - -#[derive(Default, Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct Biography { - pub text: Option, -} - -#[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, -} - -#[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, - pub gallery: GqlPagination, - pub header_image: Option, -} - -#[derive(Default, Debug, Clone, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -#[non_exhaustive] -pub struct Goods { - pub events: Events, - pub merch: GqlPagination, -} - -#[derive(Default, Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct Events { - pub concerts: GqlPagination, -} - -#[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>, - 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, - pub coordinates: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct Location { - pub name: String, - pub city: Option, - pub coordinates: Option, - // ISO-3166 country code - pub country: Option, -} - -#[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, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct ImageSource { - pub url: String, - pub height: Option, - pub width: Option, -} - -#[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, - pub owner_v2: GqlWrap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct UserItem { - pub uri: Option>, - #[serde(alias = "displayName")] - pub name: Option, - pub avatar: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct ArtistItem { - pub uri: ArtistId<'static>, - pub profile: Name, - pub visuals: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct RelatedContent { - #[serde(default)] - pub discovered_on_v2: GqlPagination>, - #[serde(default)] - pub featuring_v2: GqlPagination>, - #[serde(default)] - pub related_artists: GqlPagination, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct PrereleaseLookup { - pub lookup: Vec>, -} - -#[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>, - pub name: String, - pub cover_art: Option, - pub artists: Option>>, - pub tracks: Option>, - pub copyright: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct PrereleaseTrackItem { - pub uri: TrackId<'static>, - pub name: String, - pub duration: Option, - pub artists: GqlPagination>, -} - -#[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, -} - -#[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, - #[serde(default)] - #[serde_as(as = "VecSkipError<_>")] - pub matched_fields: Vec, -} - -#[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChipOrder { - #[serde_as(as = "VecSkipError<_>")] - pub items: Vec, -} - -#[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>, - #[serde(default)] - pub albums_v2: GqlPagination, - #[serde(default)] - pub tracks_v2: GqlPagination, - #[serde(default)] - pub playlists: GqlPagination>, - #[serde(default)] - pub users: GqlPagination>, - 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, - pub cover_art: Option, - pub artists: Option>, -} - -#[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, - 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>, - #[serde(default, with = "time::serde::iso8601::option")] - pub start_date_iso_string: Option, - #[serde(default, with = "time::serde::iso8601::option")] - pub doors_open_time_iso_string: Option, - #[serde(default)] - pub festival: bool, - pub html_description: Option, - pub location: Option, - #[serde(default)] - pub offers: GqlPagination, - #[serde(default)] - pub related_concerts: GqlPagination>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct ConcertOffer { - pub access_code: String, - pub currency: Option, - pub dates: Option, - #[serde(default)] - pub first_party: bool, - pub max_price: Option, - pub min_price: Option, - pub provider_image_url: Option, - pub provider_name: Option, - pub sale_type: Option, - pub sold_out: Option, - pub url: Option, -} - -#[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, - pub image_url: Option, - pub followers_count: Option, - pub following_count: Option, - #[serde(default)] - pub public_playlists: Vec, - pub total_public_playlists_count: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct UserPlaylists { - #[serde(default)] - pub public_playlists: Vec, - pub total_public_playlists_count: Option, -} - -#[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, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct UserProfilesWrap { - pub profiles: Vec, -} - -/// May be an artist or an user -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct FollowerItem { - pub uri: UserId<'static>, - pub name: Option, - pub followers_count: Option, - pub image_url: Option, -} - -/// 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, - pub followers_count: Option, - pub image_url: Option, -} - -/// 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, - // pub extended_credits: (), - #[serde(default)] - pub source_names: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RoleCredits { - pub role_title: String, - pub artists: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreditedArtist { - pub name: String, - pub uri: Option>, - pub creator_uri: Option>, - pub external_url: Option, - /// Image URL - pub image_uri: Option, - pub subroles: Vec, - 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 { - match self { - ArtistOption::Artist(artist_item) => Some(artist_item), - ArtistOption::NotFound => None, - } - } -} - -impl TrackOption { - pub fn into_option(self) -> Option { - match self { - TrackOption::Track(track_item) => Some(track_item), - TrackOption::NotFound => None, - } - } -} - -impl PlaylistOption { - pub fn into_option(self) -> Option { - match self { - PlaylistOption::Playlist(playlist_item) => Some(playlist_item), - PlaylistOption::NotFound => None, - } - } -} - -impl ConcertOption { - pub fn into_option(self) -> Option { - match self { - ConcertOption::ConcertV2(concert) => Some(concert), - ConcertOption::NotFound => None, - } - } -} - -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} - -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} - -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} - -impl From> for Option { - fn from(value: GqlWrap) -> Self { - value.data.into_option() - } -} - -impl TryFrom for FollowerItem { - type Error = (); - - fn try_from(value: FollowerItemUserlike) -> Result { - 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(()) - } - } -} diff --git a/crates/spotifyioweb/src/lib.rs b/crates/spotifyioweb/src/lib.rs deleted file mode 100644 index 59315be..0000000 --- a/crates/spotifyioweb/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -#[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"); diff --git a/crates/spotifyioweb/src/login.rs b/crates/spotifyioweb/src/login.rs deleted file mode 100644 index f572fe2..0000000 --- a/crates/spotifyioweb/src/login.rs +++ /dev/null @@ -1,96 +0,0 @@ -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 { - let resp = http - .get("https://open.spotify.com/api/server-time") - .send() - .await? - .error_for_status()?; - - let time = resp.json::().await?; - Ok(time.server_time) -} - -pub(crate) async fn get_auth_token( - http: &Client, - totp_gen: &Totp, - sp_dc: &str, -) -> Result { - 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::().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 { - 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 { - 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) -} diff --git a/crates/spotifyioweb/src/normalisation.rs b/crates/spotifyioweb/src/normalisation.rs deleted file mode 100644 index f8744ca..0000000 --- a/crates/spotifyioweb/src/normalisation.rs +++ /dev/null @@ -1,45 +0,0 @@ -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: . -#[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(file: &mut R) -> Result { - 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::()?; - let track_peak = rdr.read_f32::()?; - let album_gain_db = rdr.read_f32::()?; - let album_peak = rdr.read_f32::()?; - - Ok(Self { - track_gain_db, - track_peak, - album_gain_db, - album_peak, - }) - } -} diff --git a/crates/spotifyioweb/src/pagination.rs b/crates/spotifyioweb/src/pagination.rs deleted file mode 100644 index e6356f9..0000000 --- a/crates/spotifyioweb/src/pagination.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! 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`, since async mode is enabled. -pub type Paginator<'a, T> = Pin + 'a + Send>>; - -pub type RequestFuture<'a, T> = Pin, 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> -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> -where - T: 'a + Unpin + Send, - Fut: Future, 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()); - } - } -} diff --git a/crates/spotifyioweb/src/pool.rs b/crates/spotifyioweb/src/pool.rs deleted file mode 100644 index 8793975..0000000 --- a/crates/spotifyioweb/src/pool.rs +++ /dev/null @@ -1,275 +0,0 @@ -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 { - robin: Mutex, - entries: Vec>, - is_invalid: Box bool + Sync + Send>, - jitter: Jitter, -} - -struct ClientPoolEntry { - entry: T, - limiter: DefaultDirectRateLimiter, -} - -#[allow(dead_code)] -impl ClientPool { - pub fn new( - quota: Quota, - jitter: Option, - entries: E, - is_invalid: F, - ) -> Result - where - E: IntoIterator, - F: Fn(&T) -> bool + Sync + Send + 'static, - { - let entries = entries - .into_iter() - .map(|entry| ClientPoolEntry { - entry, - limiter: DefaultDirectRateLimiter::direct(quota), - }) - .collect::>(); - 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 { - 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) -> 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) -> 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()); - } -} diff --git a/crates/spotifyioweb/src/session.rs b/crates/spotifyioweb/src/session.rs deleted file mode 100644 index 250f045..0000000 --- a/crates/spotifyioweb/src/session.rs +++ /dev/null @@ -1,142 +0,0 @@ -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, - spclient: OnceCell, -} - -#[derive(Clone)] -pub struct Session(Arc); - -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 { - self.cache().read_email() - } - - pub fn country(&self) -> Option { - 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); - -impl SessionWeak { - fn try_upgrade(&self) -> Option { - self.0.upgrade().map(Session) - } - - pub(crate) fn upgrade(&self) -> Session { - self.try_upgrade() - .expect("session was dropped and so should have this component") - } -} diff --git a/crates/spotifyioweb/src/spclient.rs b/crates/spotifyioweb/src/spclient.rs deleted file mode 100644 index 976b610..0000000 --- a/crates/spotifyioweb/src/spclient.rs +++ /dev/null @@ -1,2248 +0,0 @@ -use std::{collections::HashMap, fmt::Write, ops::Not, sync::Arc}; - -use byteorder::{BigEndian, ByteOrder}; -use bytes::Bytes; -use data_encoding::HEXLOWER_PERMISSIVE; -use parking_lot::Mutex; -use protobuf::{EnumOrUnknown, Message}; -use reqwest::{header, Method, Response, StatusCode}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use time::{Duration, OffsetDateTime}; -use tracing::{debug, error, info, trace, warn}; - -use spotifyio_protocol::{ - canvaz::{entity_canvaz_response::Canvaz, EntityCanvazRequest, EntityCanvazResponse}, - extended_metadata::{ - BatchedEntityRequest, BatchedExtensionResponse, EntityRequest, ExtensionQuery, - }, - extension_kind::ExtensionKind, - metadata::{Album, Artist, Episode, Show, Track}, - playlist4_external::SelectedListContent, - playplay::{PlayPlayLicenseRequest, PlayPlayLicenseResponse}, - storage_resolve::StorageResolveResponse, -}; - -use crate::{ - error::ErrorKind, - gql_model::{ - ArtistGql, ArtistGqlWrap, Concert, ConcertGql, ConcertGqlWrap, ConcertOption, FollowerItem, - FollowerItemUserlike, GqlPlaylistItem, GqlSearchResult, GqlWrap, Lyrics, LyricsWrap, - PlaylistWrap, PrereleaseItem, PrereleaseLookup, SearchItemType, SearchResultWrap, - Seektable, TrackCredits, UserPlaylists, UserProfile, UserProfilesWrap, - }, - model::{ - AlbumId, AlbumType, ArtistId, AudioAnalysis, AudioFeatures, AudioFeaturesPayload, Category, - CategoryPlaylists, ConcertId, EpisodeId, EpisodesPayload, FeaturedPlaylists, FileId, - FullAlbum, FullAlbums, FullArtist, FullArtists, FullEpisode, FullPlaylist, FullShow, - FullTrack, FullTracks, Id, IdBase62, IdConstruct, IncludeExternal, Market, MetadataItemId, - Page, PageCategory, PageSimplifiedAlbums, PlaylistId, PlaylistItem, PrereleaseId, - PrivateUser, PublicUser, Recommendations, RecommendationsAttribute, SearchMultipleResult, - SearchResult, SearchType, ShowId, SimplifiedAlbum, SimplifiedEpisode, SimplifiedPlaylist, - SimplifiedShow, SimplifiedShows, SimplifiedTrack, SpotifyType, TrackId, UserId, - }, - pagination::{paginate, paginate_with_ctx, Paginator}, - session::{Session, SessionWeak}, - totp::Totp, - util::SocketAddress, - web_model::{ClientTokenRequest, ClientTokenResponse}, - Error, -}; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct TokenData { - access_token: String, - expires_in: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Token { - pub access_token: String, - pub expires_in: u32, - #[serde(with = "time::serde::rfc3339")] - pub timestamp: OffsetDateTime, -} - -impl Token { - const EXPIRY_THRESHOLD: Duration = Duration::seconds(10); - - pub fn from_json(body: String) -> Result { - let data: TokenData = serde_json::from_slice(body.as_ref())?; - Ok(Self { - access_token: data.access_token, - expires_in: data.expires_in, - timestamp: OffsetDateTime::now_utc(), - }) - } - - pub fn is_expired(&self) -> bool { - self.timestamp - + (Duration::seconds(self.expires_in.into()).saturating_sub(Self::EXPIRY_THRESHOLD)) - < OffsetDateTime::now_utc() - } -} - -#[derive(Clone)] -pub struct SpClient { - i: Arc, -} - -struct SpClientInner { - session: SessionWeak, - data: Mutex, - lock_client_token: tokio::sync::Mutex<()>, - lock_auth_token: tokio::sync::Mutex<()>, -} - -#[derive(Default)] -struct SpClientData { - accesspoint: Option, - totp: Option, -} - -#[derive(Copy, Clone, Debug)] -pub enum RequestStrategy { - TryTimes(usize), - Infinitely, -} - -impl Default for RequestStrategy { - fn default() -> Self { - RequestStrategy::TryTimes(10) - } -} - -#[derive(Default)] -struct RequestParams<'a> { - method: Method, - endpoint: &'a str, - params: Option<&'a Query<'a>>, - public_api: bool, - content_type: Option, - body: Vec, - if_none_match: Option<&'a str>, -} - -#[derive(Debug)] -pub struct EtagResponse { - pub data: T, - pub etag: Option, -} - -#[derive(Debug, Clone, Copy)] -enum ContentType { - Protobuf, - Json, -} - -impl ContentType { - fn mime(self) -> &'static str { - match self { - ContentType::Protobuf => "application/x-protobuf", - ContentType::Json => "application/json", - } - } -} - -type Query<'a> = HashMap<&'a str, &'a str>; - -impl SpClient { - pub(crate) fn new(session: SessionWeak) -> Self { - Self { - i: SpClientInner { - session, - data: Mutex::default(), - lock_client_token: tokio::sync::Mutex::default(), - lock_auth_token: tokio::sync::Mutex::default(), - } - .into(), - } - } - - fn session(&self) -> Session { - self.i.session.upgrade() - } - - pub async fn flush_accesspoint(&self) { - let mut data = self.i.data.lock(); - data.accesspoint = None; - } - - pub async fn get_accesspoint(&self) -> Result { - // Memoize the current access point. - let ap = { - let data = self.i.data.lock(); - data.accesspoint.clone() - }; - let tuple = match ap { - Some(tuple) => tuple, - None => { - let tuple = self.session().apresolver().resolve("spclient").await?; - { - let mut data = self.i.data.lock(); - data.accesspoint = Some(tuple.clone()); - } - info!( - "Resolved \"{}:{}\" as spclient access point", - tuple.0, tuple.1 - ); - tuple - } - }; - Ok(tuple) - } - - pub async fn base_url(&self) -> Result { - // 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 { - let _lock = self.i.lock_client_token.lock().await; - let client_token = self.session().cache().read_client_token(); - if let Some(client_token) = client_token { - return Ok(client_token.access_token); - } - - debug!("Client token unavailable or expired, requesting new token."); - - let request = ClientTokenRequest::default(); - let granted_token = self.client_token_request(&request).await?.granted_token; - - let client_token = Token { - access_token: granted_token.token.clone(), - expires_in: granted_token - .expires_after_seconds - .try_into() - .unwrap_or(7200), - timestamp: OffsetDateTime::now_utc(), - }; - self.session().cache().write_client_token(client_token); - trace!("Got client token"); - - Ok(granted_token.token) - } - - async fn client_token_request( - &self, - request: &ClientTokenRequest<'_>, - ) -> Result { - let resp = self - .session() - .http_client() - .post("https://clienttoken.spotify.com/v1/clienttoken") - .header(header::ACCEPT, ContentType::Json.mime()) - .json(request) - .send() - .await? - .error_for_status()?; - resp.json().await.map_err(Error::failed_precondition) - } - - async fn get_totp(&self) -> Result { - { - let data = self.i.data.lock(); - if let Some(totp) = &data.totp { - if totp.is_fresh() { - return Ok(totp.clone()); - } - } - } - - let totp = Totp::new(self.session().http_client()).await?; - let mut data = self.i.data.lock(); - data.totp = Some(totp.clone()); - Ok(totp) - } - - pub async fn auth_token(&self) -> Result { - let _lock = self.i.lock_auth_token.lock().await; - let auth_token = self.session().cache().read_auth_token(); - - if let Some(auth_token) = auth_token { - return Ok(auth_token.access_token); - } - - debug!("Auth token unavailable or expired, requesting new token."); - - let sp_dc = self - .session() - .cache() - .read_sp_dc() - .ok_or_else(|| Error::unauthenticated("no sp_dc cookie"))?; - - let totp_gen = self.get_totp().await?; - let auth_token = - crate::login::get_auth_token(self.session().http_client(), &totp_gen, &sp_dc).await?; - let auth_token_str = auth_token.access_token.to_owned(); - self.session().cache().write_auth_token(auth_token); - Ok(auth_token_str) - } - - #[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))] - async fn _request_generic(&self, p: RequestParams<'_>) -> Result, Error> { - let mut tries: usize = 0; - let mut last_error: Error; - let mut retry_after: Option = None; - - loop { - tries += 1; - - let url = if p.endpoint.starts_with('/') { - if p.public_api { - format!("https://api.spotify.com/v1{}", p.endpoint) - } else { - // Reconnection logic: retrieve the endpoint every iteration, so we can try - // another access point when we are experiencing network issues (see below). - - let mut url = self.base_url().await?; - url.push_str(p.endpoint); - url - } - } else { - p.endpoint.to_string() - }; - - // Reconnection logic: keep getting (cached) tokens because they might have expired. - let auth_token = self.auth_token().await?; - - let mut request = self - .session() - .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 { - request = request.header(header::ACCEPT, content_type.mime()); - } - - if !p.body.is_empty() { - request = request.body(p.body.clone()); - if let Some(content_type) = p.content_type { - request = request.header(header::CONTENT_TYPE, content_type.mime()); - } - } - - if !p.public_api { - match self.client_token().await { - Ok(client_token) => { - request = request.header("client-token", client_token); - } - Err(e) => { - // currently these endpoints seem to work fine without it - warn!("Unable to get client token: {e} Trying to continue without...") - } - } - } - - if let Some(t) = p.if_none_match { - request = request.header(header::IF_NONE_MATCH, t); - } - - if let Some(params) = p.params { - request = request.query(params); - } - - let resp = request.send().await.map_err(Error::from); - - last_error = match resp { - Ok(resp) => { - if matches!(resp.status(), StatusCode::NOT_MODIFIED) { - return Err(Error::not_modified("")); - } - - let estatus = resp.error_for_status_ref().err(); - let etag = resp - .headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - retry_after = resp - .headers() - .get(header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()); - - match estatus { - Some(e) => { - // Protobuf API returns no textual error messages - if !matches!(p.content_type, Some(ContentType::Protobuf)) { - if let Ok(emsg) = resp.text().await { - debug!("error response:\n{emsg}"); - } - } - e.into() - } - None => match resp.bytes().await { - Ok(data) => { - return Ok(EtagResponse { data, etag }); - } - Err(e) => e.into(), - }, - } - } - Err(e) => e, - }; - - // Break before the reconnection logic below, so that the current access point - // is retained when max_tries == 1. Leave it up to the caller when to flush. - if let RequestStrategy::TryTimes(max_tries) = self.session().config().request_strategy { - if tries >= max_tries { - break; - } - } - - // Reconnection logic: drop the current access point if we are experiencing issues. - // This will cause the next call to base_url() to resolve a new one. - match last_error.kind { - ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => { - warn!("API error: {last_error} (retrying)"); - // Keep trying the current access point three times before dropping it. - if tries.is_multiple_of(3) { - self.flush_accesspoint().await - } - } - ErrorKind::ResourceExhausted => { - let wait_time = retry_after.unwrap_or(15); - if wait_time > 30 { - error!("API error 429: need to wait {wait_time}s"); - break; - } else { - warn!("API error 429: waiting for {wait_time}s"); - tokio::time::sleep(std::time::Duration::from_secs(wait_time)).await; - } - } - _ => { - // if we can't build the request now, then we won't ever - error!("API error: {last_error}"); - break; - } - } - } - Err(last_error) - } - - async fn request_pb( - &self, - method: Method, - endpoint: &str, - message: &impl Message, - ) -> Result { - let res = self - ._request_generic(RequestParams { - method, - endpoint, - content_type: Some(ContentType::Protobuf), - body: message.write_to_bytes()?, - ..Default::default() - }) - .await?; - Ok(O::parse_from_bytes(&res.data)?) - } - - async fn request_get_pb( - &self, - endpoint: &str, - if_none_match: Option<&str>, - ) -> Result, Error> { - let res = self - ._request_generic(RequestParams { - endpoint, - content_type: Some(ContentType::Protobuf), - if_none_match, - ..Default::default() - }) - .await?; - Ok(EtagResponse { - data: O::parse_from_bytes(&res.data)?, - etag: res.etag, - }) - } - - async fn request_get_json(&self, endpoint: &str) -> Result { - let res = self - ._request_generic(RequestParams { - endpoint, - content_type: Some(ContentType::Json), - ..Default::default() - }) - .await?; - match serde_json::from_slice(&res.data) { - Ok(res) => Ok(res), - Err(e) => { - debug!("JSON response:\n{}", String::from_utf8_lossy(&res.data)); - Err(e.into()) - } - } - } - - async fn public_get( - &self, - endpoint: &str, - params: Option<&Query<'_>>, - ) -> Result { - let res = self - ._request_generic(RequestParams { - endpoint, - params, - public_api: true, - content_type: Some(ContentType::Json), - ..Default::default() - }) - .await?; - Ok(serde_json::from_slice(&res.data)?) - } - - async fn get_metadata( - &self, - id: MetadataItemId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - debug!("getting metadata for {id}"); - self.request_get_pb( - &format!("/metadata/4/{}/{}", id.spotify_type(), id.base16()), - if_none_match, - ) - .await - } - - pub async fn pb_artist( - &self, - id: ArtistId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Artist(id), if_none_match) - .await - } - - pub async fn pb_album( - &self, - id: AlbumId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Album(id), if_none_match) - .await - } - - pub async fn pb_track( - &self, - id: TrackId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Track(id), if_none_match) - .await - } - - pub async fn pb_show( - &self, - id: ShowId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Show(id), if_none_match) - .await - } - - pub async fn pb_episode( - &self, - id: EpisodeId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - self.get_metadata(MetadataItemId::Episode(id), if_none_match) - .await - } - - pub async fn pb_playlist( - &self, - id: PlaylistId<'_>, - if_none_match: Option<&str>, - ) -> Result, Error> { - debug!("getting metadata for playlist {id}"); - self.request_get_pb(&format!("/playlist/v2/playlist/{}", id.id()), if_none_match) - .await - } - - pub async fn pb_playlist_diff( - &self, - id: PlaylistId<'_>, - revision: &[u8], - ) -> Result { - debug!("getting diff for playlist {id}"); - let endpoint = format!( - "/playlist/v2/playlist/{}/diff?revision={},{}", - id.id(), - BigEndian::read_u32(&revision[0..4]), - HEXLOWER_PERMISSIVE.encode(&revision[4..]), - ); - Ok(self.request_get_pb(&endpoint, None).await?.data) - } - - async fn pb_extended_metadata( - &self, - ids: impl IntoIterator + Send, - ) -> Result, Error> { - let mut ids = ids.into_iter().peekable(); - let item_type = if let Some(first) = ids.peek() { - first.spotify_type() - } else { - return Ok(Vec::new()); - }; - let kind = match item_type { - SpotifyType::Album => ExtensionKind::ALBUM_V4, - SpotifyType::Artist => ExtensionKind::ARTIST_V4, - SpotifyType::Episode => ExtensionKind::EPISODE_V4, - SpotifyType::Show => ExtensionKind::SHOW_V4, - SpotifyType::Track => ExtensionKind::TRACK_V4, - _ => return Err(Error::invalid_argument("unsupported item type")), - }; - - let mut request = BatchedEntityRequest::new(); - let header = request.header.mut_or_insert_default(); - header.catalogue = self.session().catalogue().to_owned(); - header.country = self.session().country().unwrap_or_default(); - request.entity_request = ids - .map(|id| { - let mut r = EntityRequest::new(); - r.entity_uri = id.uri(); - - let mut rq1 = ExtensionQuery::new(); - rq1.extension_kind = EnumOrUnknown::new(kind); - r.query.push(rq1); - Ok(r) - }) - .collect::, Error>>()?; - - debug!( - "getting metadata for {} {}s", - request.entity_request.len(), - item_type - ); - - let resp = self - .request_pb::( - Method::POST, - "/extended-metadata/v0/extended-metadata", - &request, - ) - .await?; - - resp.extended_metadata - .into_iter() - .flat_map(|x| { - x.extension_data.into_iter().filter_map(|itm| { - if itm.extension_data.is_some() { - Some(O::parse_from_bytes(&itm.extension_data.value).map_err(Error::from)) - } else { - None - } - }) - }) - .collect() - } - - pub async fn pb_artists( - &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await - } - - pub async fn pb_albums( - &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await - } - - pub async fn pb_tracks( - &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await - } - - pub async fn pb_shows( - &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await - } - - pub async fn pb_episodes( - &self, - ids: impl IntoIterator> + Send, - ) -> Result, Error> { - self.pb_extended_metadata(ids).await - } - - pub async fn pb_audio_storage( - &self, - file_id: &FileId, - ) -> Result { - debug!("getting audio storage for {file_id}"); - let endpoint = format!( - "/storage-resolve/files/audio/interactive/{}", - file_id.base16() - ); - Ok(self.request_get_pb(&endpoint, None).await?.data) - } - - pub async fn get_obfuscated_key( - &self, - file_id: &FileId, - token: &[u8; 16], - is_episode: bool, - ) -> Result<[u8; 16], Error> { - debug!("getting obfuscated key for {file_id}"); - - let mut req = PlayPlayLicenseRequest::new(); - req.set_version(2); - req.set_token(token.to_vec()); - req.set_interactivity(spotifyio_protocol::playplay::Interactivity::INTERACTIVE); - if is_episode { - req.set_content_type(spotifyio_protocol::playplay::ContentType::AUDIO_EPISODE); - } else { - req.set_content_type(spotifyio_protocol::playplay::ContentType::AUDIO_TRACK); - } - req.set_timestamp(OffsetDateTime::now_utc().unix_timestamp()); - - let license = self - .request_pb::( - Method::POST, - &format!("/playplay/v1/key/{}", file_id.base16()), - &req, - ) - .await?; - license - .obfuscated_key() - .try_into() - .map_err(|_| Error::failed_precondition("could not parse obfuscated key")) - } - - pub async fn get_image(&self, image_id: &FileId) -> Result { - debug!("getting image {image_id}"); - let url = self.session().image_url(image_id); - - let resp = self - .session() - .http_client() - .get(url) - .send() - .await? - .error_for_status()?; - Ok(resp) - } - - pub async fn get_audio_preview(&self, file_id: &FileId) -> Result { - 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 => "?", - }; - let _ = write!(url, "{}cid={}", separator, self.session().client_id()); - - let resp = self - .session() - .http_client() - .get(url) - .send() - .await? - .error_for_status()?; - Ok(resp) - } - - pub async fn get_head_file(&self, file_id: &FileId) -> Result { - 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 { - debug!("getting seektable for file:{file_id}"); - let url = format!( - "https://seektables.scdn.co/seektable/{}.json", - file_id.base16() - ); - Ok(self - .session() - .http_client() - .get(url) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn get_widevine_certificate(&self) -> Result { - debug!("getting widevine certificate"); - Ok(self - ._request_generic(RequestParams { - endpoint: "/widevine-license/v1/application-certificate", - ..Default::default() - }) - .await? - .data) - } - - pub async fn get_widevine_license( - &self, - challenge: Vec, - is_video: bool, - ) -> Result { - debug!("getting widevine license"); - let media_type = if is_video { "video" } else { "audio" }; - let url = format!("/widevine-license/v1/{media_type}/license"); - - Ok(self - ._request_generic(RequestParams { - method: Method::POST, - endpoint: &url, - body: challenge, - ..Default::default() - }) - .await? - .data) - } - - /// Get information about a track's artists, writers and producers - pub async fn get_track_credits(&self, track_id: TrackId<'_>) -> Result { - debug!("getting track credits for {track_id}"); - self.request_get_json(&format!( - "/track-credits-view/v0/experimental/{}/credits", - track_id.id() - )) - .await - } - - pub async fn pb_canvases( - &self, - ids: impl IntoIterator> + Send, - ) -> Result, Canvaz>, Error> { - let mut request = EntityCanvazRequest::new(); - request.entities = ids - .into_iter() - .map(|id| { - let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); - entity.entity_uri = id.uri(); - entity - }) - .collect(); - debug!("getting canvases for {} tracks", request.entities.len()); - - let resp = self - .request_pb::(Method::POST, "/canvaz-cache/v0/canvases", &request) - .await?; - - resp.canvases - .into_iter() - .map(|c| Ok((TrackId::from_uri(&c.entity_uri)?.into_static(), c))) - .collect::, Error>>() - } - - pub async fn pb_canvas(&self, id: TrackId<'_>) -> Result { - debug!("getting canvas for {id}"); - let mut request = EntityCanvazRequest::new(); - let mut entity = spotifyio_protocol::canvaz::entity_canvaz_request::Entity::new(); - entity.entity_uri = id.uri(); - request.entities.push(entity); - - let resp = self - .request_pb::(Method::POST, "/canvaz-cache/v0/canvases", &request) - .await?; - resp.canvases - .into_iter() - .next() - .ok_or_else(|| Error::not_found(format!("canvas for {id}"))) - } - - pub async fn get_lyrics(&self, id: TrackId<'_>) -> Result { - debug!("getting lyrics for {id}"); - let res = self - .request_get_json::(&format!("/color-lyrics/v2/track/{}", id.id())) - .await?; - Ok(res.lyrics) - } - - pub async fn gql_artist_overview(&self, id: ArtistId<'_>) -> Result { - debug!("getting artist overview for {id}"); - let url = format!( - "https://api-partner.spotify.com/pathfinder/v1/query?operationName=queryArtistOverview&variables=%7B%22uri%22%3A%22spotify%3Aartist%3A{}%22%2C%22locale%22%3A%22%22%2C%22includePrerelease%22%3Atrue%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22bc0107feab9595387a22ebed6c944c9cf72c81b2f72a3d26ac055e4465173a1f%22%7D%7D", - id.id() - ); - let res = self - .request_get_json::>(&url) - .await?; - Ok(res.data.artist_union) - } - - pub async fn gql_artist_concerts(&self, id: ArtistId<'_>) -> Result, Error> { - debug!("getting artist concerts for {id}"); - let url = format!( - "https://api-partner.spotify.com/pathfinder/v1/query?operationName=artistConcerts&variables=%7B%22artistId%22%3A%22spotify%3Aartist%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22ce78fdb28c5036de6d81bbc45571b62e2201a0c08eb068ab17dd3428396026f5%22%7D%7D", - id.id() - ); - let res = self - .request_get_json::>(&url) - .await?; - Ok(res.data.artist_union.goods.events.concerts.items) - } - - pub async fn gql_concert(&self, id: ConcertId<'_>) -> Result { - debug!("getting concert {id}"); - let cid = id.id(); - let url = format!( - "https://api-partner.spotify.com/pathfinder/v1/query?operationName=concert&variables=%7B%22uri%22%3A%22spotify%3Aconcert%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2252470cde70b165f7dbf74fa9bcdff4eeee7e7eddadfe12bde54a25ef844abb38%22%7D%7D", - cid - ); - let res = self - .request_get_json::>(&url) - .await?; - - match res.data.concert { - ConcertOption::ConcertV2(concert) => Ok(concert), - ConcertOption::NotFound => Err(Error::not_found(format!("concert `{cid}`"))), - } - } - - pub async fn gql_prerelease(&self, id: PrereleaseId<'_>) -> Result { - debug!("getting prerelease {id}"); - let url = format!( - "https://api-partner.spotify.com/pathfinder/v1/query?operationName=albumPreRelease&variables=%7B%22uri%22%3A%22spotify%3Aprerelease%3A{}%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22cb7e121ae0c2d105ea9a8a5c8a003e520f333e0e94073032dcdbd548dd205d66%22%7D%7D", - id.id() - ); - let res = self - .request_get_json::>(&url) - .await?; - Ok(res - .data - .lookup - .into_iter() - .next() - .ok_or(Error::not_found("prerelease not found"))? - .data) - } - - pub async fn gql_search( - &self, - query: &str, - offset: u32, - limit: u32, - typ: Option, - ) -> Result { - debug!("searching `{query}` (typ={typ:?},gql)"); - let query = urlencoding::encode(query); - let url = match typ { - Some(SearchItemType::Artists) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchArtists&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%220e6f9020a66fe15b93b3bb5c7e6484d1d8cb3775963996eaede72bac4d97e909%22%7D%7D"), - Some(SearchItemType::Albums) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchAlbums&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22de1046fc459b96b661b2f4e4d821118a9fbe4b563084bf5994e89ce34acc10c0%22%7D%7D"), - Some(SearchItemType::Tracks) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchTracks&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%225307479c18ff24aa1bd70691fdb0e77734bede8cce3bd7d43b6ff7314f52a6b8%22%7D%7D"), - Some(SearchItemType::Playlists) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchPlaylists&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22fc3a690182167dbad20ac7a03f842b97be4e9737710600874cb903f30112ad58%22%7D%7D"), - Some(SearchItemType::Users) => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchUsers&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A20%2C%22includeAudiobooks%22%3Afalse%2C%22includePreReleases%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22d3f7547835dc86a4fdf3997e0f79314e7580eaf4aaf2f4cb1e71e189c5dfcb1f%22%7D%7D"), - None => format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=searchDesktop&variables=%7B%22searchTerm%22%3A%22{query}%22%2C%22offset%22%3A{offset}%2C%22limit%22%3A{limit}%2C%22numberOfTopResults%22%3A1%2C%22includeAudiobooks%22%3Afalse%2C%22includeArtistHasConcertsField%22%3Afalse%2C%22includePreReleases%22%3Afalse%2C%22includeLocalConcertsField%22%3Afalse%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22bd8eb4cb57ae6deeac1a7d2ebe8487b65d52e0b69387a2b51590c2471f5fd57e%22%7D%7D"), - }; - let res = self - .request_get_json::>(&url) - .await?; - Ok(res.data.search_v2) - } - - pub async fn gql_playlist(&self, id: PlaylistId<'_>) -> Result { - debug!("getting playlist {id} (gql)"); - let url = format!("https://api-partner.spotify.com/pathfinder/v1/query?operationName=fetchPlaylist&variables=%7B%22uri%22%3A%22spotify%3Aplaylist%3A{}%22%2C%22offset%22%3A0%2C%22limit%22%3A0%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2219ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d%22%7D%7D", id.id()); - let res = self.request_get_json::>(&url).await?; - - res.data - .playlist_v2 - .into_option() - .ok_or_else(|| Error::not_found(format!("playlist {id}"))) - } - - pub async fn get_user_profile( - &self, - id: UserId<'_>, - playlist_limit: u32, - ) -> Result { - debug!("getting user profile {id}"); - self.request_get_json(&format!( - "/user-profile-view/v3/profile/{}?playlist_limit={playlist_limit}&market=from_token", - id.id() - )) - .await - } - - pub async fn get_user_playlists( - &self, - id: UserId<'_>, - offset: u32, - limit: u32, - ) -> Result { - debug!("getting user playlists {id}"); - self.request_get_json(&format!("/user-profile-view/v3/profile/{}/playlists?offset={offset}&limit={limit}&market=from_token", id.id())).await - } - - pub async fn get_user_followers(&self, id: UserId<'_>) -> Result, Error> { - debug!("getting user followers {id}"); - let res = self - .request_get_json::>(&format!( - "/user-profile-view/v3/profile/{}/followers?market=from_token", - id.id() - )) - .await?; - Ok(res - .profiles - .into_iter() - .filter_map(|p| p.try_into().ok()) - .collect()) - } - - pub async fn get_user_following(&self, id: UserId<'_>) -> Result, Error> { - debug!("getting user following {id}"); - let res = self - .request_get_json::>(&format!( - "/user-profile-view/v3/profile/{}/following?market=from_token", - id.id() - )) - .await?; - Ok(res - .profiles - .into_iter() - .filter_map(|p| p.try_into().ok()) - .collect()) - } - - // PUBLIC SPOTIFY API - FROM RSPOTIFY - - /// Returns a single track given the track's ID, URI or URL. - /// - /// Parameters: - /// - track_id - a spotify URI, URL or ID - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-track) - pub async fn web_track( - &self, - id: TrackId<'_>, - market: Option, - ) -> Result { - debug!("getting track {id} (web)"); - let params = build_map([("market", market.map(Into::into))]); - - let url = format!("/tracks/{}", id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Returns a list of tracks given a list of track IDs, URIs, or URLs. - /// - /// Parameters: - /// - track_ids - a list of spotify URIs, URLs or IDs - /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-tracks) - pub async fn web_tracks( - &self, - track_ids: impl IntoIterator> + Send, - market: Option, - ) -> Result, Error> { - let ids = join_ids(track_ids); - debug!("getting tracks: {ids} (web)"); - let params = build_map([("market", market.map(Into::into))]); - - let url = format!("/tracks/?ids={ids}"); - let result = self.public_get::(&url, Some(¶ms)).await?; - Ok(result.tracks) - } - - /// Returns a single artist given the artist's ID, URI or URL. - /// - /// Parameters: - /// - artist_id - an artist ID, URI or URL - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artist) - pub async fn web_artist(&self, artist_id: ArtistId<'_>) -> Result { - debug!("getting artist {artist_id} (web)"); - let url = format!("/artists/{}", artist_id.id()); - self.public_get(&url, None).await - } - - /// Returns a list of artists given the artist IDs, URIs, or URLs. - /// - /// Parameters: - /// - artist_ids - a list of artist IDs, URIs or URLs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-artists) - pub async fn web_artists( - &self, - artist_ids: impl IntoIterator> + Send, - ) -> Result, Error> { - let ids = join_ids(artist_ids); - debug!("getting artists: {ids} (web)"); - let url = format!("/artists/?ids={ids}"); - let result = self.public_get::(&url, None).await?; - Ok(result.artists) - } - - /// Get Spotify catalog information about an artist's albums. - /// - /// Parameters: - /// - artist_id - the artist ID, URI or URL - /// - include_groups - a list of album type like 'album', 'single' that will be used to filter response. if not supplied, all album types will be returned. - /// - market - limit the response to one particular country. - /// - limit - the number of albums to return - /// - offset - the index of the first album to return - /// - /// See [`Self::artist_albums_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-albums) - pub fn web_artist_albums<'a>( - &'a self, - artist_id: ArtistId<'a>, - include_groups: impl IntoIterator + Send + Copy + 'a, - market: Option, - ) -> Paginator<'a, Result> { - paginate_with_ctx( - (self, artist_id), - move |(slf, artist_id), limit, offset| { - Box::pin(slf.web_artist_albums_manual( - artist_id.as_ref(), - include_groups, - market, - Some(limit), - Some(offset), - )) - }, - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::artist_albums`]. - pub async fn web_artist_albums_manual( - &self, - artist_id: ArtistId<'_>, - include_groups: impl IntoIterator + Send, - market: Option, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting albums of {artist_id} (web)"); - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let include_groups_vec = include_groups - .into_iter() - .map(|t| t.into()) - .collect::>(); - let include_groups_opt = include_groups_vec - .is_empty() - .not() - .then_some(include_groups_vec) - .map(|t| t.join(",")); - - let params = build_map([ - ("include_groups", include_groups_opt.as_deref()), - ("market", market.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - let url = format!("/artists/{}/albums", artist_id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Get Spotify catalog information about an artist's top 10 tracks by - /// country. - /// - /// Parameters: - /// - artist_id - the artist ID, URI or URL - /// - market - limit the response to one particular country. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-top-tracks) - pub async fn web_artist_top_tracks( - &self, - artist_id: ArtistId<'_>, - market: Option, - ) -> Result, Error> { - debug!("getting top tracks of {artist_id} (web)"); - let params = build_map([("market", market.map(Into::into))]); - - let url = format!("/artists/{}/top-tracks", artist_id.id()); - let result = self.public_get::(&url, Some(¶ms)).await?; - Ok(result.tracks) - } - - /// Get Spotify catalog information about artists similar to an identified - /// artist. Similarity is based on analysis of the Spotify community's - /// listening history. - /// - /// Parameters: - /// - artist_id - the artist ID, URI or URL - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-related-artists) - pub async fn web_artist_related_artists( - &self, - artist_id: ArtistId<'_>, - ) -> Result, Error> { - debug!("getting related artists of {artist_id} (web)"); - let url = format!("/artists/{}/related-artists", artist_id.id()); - let result = self.public_get::(&url, None).await?; - Ok(result.artists) - } - - /// Returns a single album given the album's ID, URIs or URL. - /// - /// Parameters: - /// - album_id - the album ID, URI or URL - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-album) - pub async fn web_album( - &self, - album_id: AlbumId<'_>, - market: Option, - ) -> Result { - debug!("getting album {album_id} (web)"); - let params = build_map([("market", market.map(Into::into))]); - - let url = format!("/albums/{}", album_id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Returns a list of albums given the album IDs, URIs, or URLs. - /// - /// Parameters: - /// - albums_ids - a list of album IDs, URIs or URLs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-albums) - pub async fn web_albums( - &self, - album_ids: impl IntoIterator> + Send, - market: Option, - ) -> Result, Error> { - let params = build_map([("market", market.map(Into::into))]); - let ids = join_ids(album_ids); - debug!("getting albums: {ids} (web)"); - - let url = format!("/albums/?ids={ids}"); - let result = self.public_get::(&url, Some(¶ms)).await?; - Ok(result.albums) - } - - /// Search for an Item. Get Spotify catalog information about artists, - /// albums, tracks or playlists that match a keyword string. - /// - /// According to Spotify's doc, if you don't specify a country in the - /// request and your spotify account doesn't set the country, the content - /// might be unavailable for you: - /// > If a valid user access token is specified in the request header, - /// > the country associated with the user account will take priority over this parameter. - /// > Note: If neither market or user country are provided, the content is considered unavailable for the client. - /// > Users can view the country that is associated with their account in the [account settings](https://developer.spotify.com/documentation/web-api/reference/search). - /// - /// Parameters: - /// - q - the search query - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - typ - the type of item to return. One of 'artist', 'album', 'track', - /// 'playlist', 'show' or 'episode' - /// - market - An ISO 3166-1 alpha-2 country code or the string from_token. - /// - include_external: Optional.Possible values: audio. If - /// include_external=audio is specified the response will include any - /// relevant audio content that is hosted externally. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/search) - pub async fn web_search( - &self, - q: &str, - typ: SearchType, - market: Option, - include_external: Option, - limit: Option, - offset: Option, - ) -> Result { - debug!("searching `{q}` (typ={typ:?}, web)"); - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map([ - ("q", Some(q)), - ("type", Some(typ.into())), - ("market", market.map(Into::into)), - ("include_external", include_external.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - self.public_get("/search", Some(¶ms)).await - } - - /// Search for multiple an Item. Get Spotify catalog information about artists, - /// albums, tracks or playlists that match a keyword string. - /// - /// According to Spotify's doc, if you don't specify a country in the - /// request and your spotify account doesn't set the country, the content - /// might be unavailable for you: - /// > If a valid user access token is specified in the request header, - /// > the country associated with the user account will take priority over this parameter. - /// > Note: If neither market or user country are provided, the content is considered unavailable for the client. - /// > Users can view the country that is associated with their account in the [account settings](https://developer.spotify.com/documentation/web-api/reference/search). - /// - /// Parameters: - /// - q - the search query - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - typ - the type of item to return. Multiple of 'artist', 'album', 'track', - /// 'playlist', 'show' or 'episode' - /// - market - An ISO 3166-1 alpha-2 country code or the string from_token. - /// - include_external: Optional.Possible values: audio. If - /// include_external=audio is specified the response will include any - /// relevant audio content that is hosted externally. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/search) - pub async fn web_search_multiple( - &self, - q: &str, - typ: impl IntoIterator + Send, - market: Option, - include_external: Option, - limit: Option, - offset: Option, - ) -> Result { - debug!("searching `{q}` (web)"); - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let mut _type = typ - .into_iter() - .map(|x| Into::<&str>::into(x).to_string() + ",") - .collect::(); - let params = build_map([ - ("q", Some(q)), - ("type", Some(_type.trim_end_matches(","))), - ("market", market.map(Into::into)), - ("include_external", include_external.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - self.public_get("/search", Some(¶ms)).await - } - - /// Get Spotify catalog information about an album's tracks. - /// - /// Parameters: - /// - album_id - the album ID, URI or URL - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - /// See [`Self::album_track_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-albums-tracks) - pub fn web_album_track<'a>( - &'a self, - album_id: AlbumId<'a>, - market: Option, - ) -> Paginator<'a, Result> { - paginate_with_ctx( - (self, album_id), - move |(slf, album_id), limit, offset| { - Box::pin(slf.web_album_track_manual( - album_id.as_ref(), - market, - Some(limit), - Some(offset), - )) - }, - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::album_track`]. - pub async fn web_album_track_manual( - &self, - album_id: AlbumId<'_>, - market: Option, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting album tracks of {album_id} (web)"); - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map([ - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ("market", market.map(Into::into)), - ]); - - let url = format!("/albums/{}/tracks", album_id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Gets basic profile information about a Spotify User. - /// - /// Parameters: - /// - user - the id of the usr - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-users-profile) - pub async fn web_user(&self, user_id: UserId<'_>) -> Result { - debug!("getting user {user_id} (web)"); - let url = format!("/users/{}", user_id.id()); - self.public_get(&url, None).await - } - - /// Get full details about Spotify playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlist) - pub async fn web_playlist( - &self, - playlist_id: PlaylistId<'_>, - fields: Option<&str>, - market: Option, - ) -> Result { - debug!("getting playlist {playlist_id} (web)"); - let params = build_map([("fields", fields), ("market", market.map(Into::into))]); - - let url = format!("/playlists/{}", playlist_id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Gets playlist of a user. - /// - /// Parameters: - /// - user_id - the id of the user - /// - playlist_id - the id of the playlist (None for liked tracks) - /// - fields - which fields to return - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists) - pub async fn web_user_playlist( - &self, - user_id: UserId<'_>, - playlist_id: Option>, - fields: Option<&str>, - ) -> Result { - debug!("getting user playlist from {user_id} ({playlist_id:?},web)"); - let params = build_map([("fields", fields)]); - - let url = match playlist_id { - Some(playlist_id) => format!("/users/{}/playlists/{}", user_id.id(), playlist_id.id()), - None => format!("/users/{}/starred", user_id.id()), - }; - self.public_get(&url, Some(¶ms)).await - } - - /// Check to see if the given users are following the given playlist. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - user_ids - the ids of the users that you want to check to see if they - /// follow the playlist. Maximum: 5 ids. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/check-if-user-follows-playlist) - pub async fn web_playlist_check_follow( - &self, - playlist_id: PlaylistId<'_>, - user_ids: impl IntoIterator> + Send, - ) -> Result, Error> { - let ids = join_ids(user_ids); - debug!("checking followers of playlist {playlist_id}: {ids} (web)"); - let url = format!( - "/playlists/{}/followers/contains?ids={}", - playlist_id.id(), - ids, - ); - self.public_get(&url, None).await - } - - /// Get Spotify catalog information for a single show identified by its unique Spotify ID. - /// - /// Path Parameters: - /// - id: The Spotify ID for the show. - /// - /// Query Parameters - /// - market(Optional): An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-show) - pub async fn web_get_a_show( - &self, - id: ShowId<'_>, - market: Option, - ) -> Result { - debug!("getting show {id} (web)"); - let params = build_map([("market", market.map(Into::into))]); - - let url = format!("/shows/{}", id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Get Spotify catalog information for multiple shows based on their - /// Spotify IDs. - /// - /// Query Parameters - /// - ids(Required) A comma-separated list of the Spotify IDs for the shows. Maximum: 50 IDs. - /// - market(Optional) An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-shows) - pub async fn web_get_several_shows( - &self, - ids: impl IntoIterator> + Send, - market: Option, - ) -> Result, Error> { - let ids = join_ids(ids); - debug!("getting shows: {ids} (web)"); - let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]); - - let result = self - .public_get::("/shows", Some(¶ms)) - .await?; - Ok(result.shows) - } - - /// Get Spotify catalog information about an show’s episodes. Optional - /// parameters can be used to limit the number of episodes returned. - /// - /// Path Parameters - /// - id: The Spotify ID for the show. - /// - /// Query Parameters - /// - limit: Optional. The maximum number of episodes to return. Default: 20. Minimum: 1. Maximum: 50. - /// - offset: Optional. The index of the first episode to return. Default: 0 (the first object). Use with limit to get the next set of episodes. - /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// See [`Self::get_shows_episodes_manual`] for a manually paginated version - /// of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-shows-episodes) - pub fn web_get_shows_episodes<'a>( - &'a self, - id: ShowId<'a>, - market: Option, - ) -> Paginator<'a, Result> { - paginate_with_ctx( - (self, id), - move |(slf, id), limit, offset| { - Box::pin(slf.web_get_shows_episodes_manual( - id.as_ref(), - market, - Some(limit), - Some(offset), - )) - }, - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::get_shows_episodes`]. - pub async fn web_get_shows_episodes_manual( - &self, - id: ShowId<'_>, - market: Option, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting episodes of show {id} (web)"); - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map([ - ("market", market.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - let url = format!("/shows/{}/episodes", id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Get Spotify catalog information for a single episode identified by its unique Spotify ID. - /// - /// Path Parameters - /// - id: The Spotify ID for the episode. - /// - /// Query Parameters - /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-episode) - pub async fn web_get_an_episode( - &self, - id: EpisodeId<'_>, - market: Option, - ) -> Result { - debug!("getting episode {id} (web)"); - let params = build_map([("market", market.map(Into::into))]); - let url = format!("/episodes/{}", id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Get Spotify catalog information for multiple episodes based on their Spotify IDs. - /// - /// Query Parameters - /// - ids: Required. A comma-separated list of the Spotify IDs for the episodes. Maximum: 50 IDs. - /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-episodes) - pub async fn web_get_several_episodes( - &self, - ids: impl IntoIterator> + Send, - market: Option, - ) -> Result, Error> { - let ids = join_ids(ids); - debug!("getting episodes: {ids} (web)"); - let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]); - - let result = self - .public_get::("/episodes", Some(¶ms)) - .await?; - Ok(result.episodes) - } - - /// Get audio features for a track - /// - /// Parameters: - /// - track - track URI, URL or ID - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-features) - pub async fn web_track_features(&self, track_id: TrackId<'_>) -> Result { - debug!("getting track features for {track_id} (web)"); - let url = format!("/audio-features/{}", track_id.id()); - self.public_get(&url, None).await - } - - /// Get Audio Features for Several Tracks - /// - /// Parameters: - /// - tracks a list of track URIs, URLs or IDs - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-audio-features) - pub async fn web_tracks_features( - &self, - track_ids: impl IntoIterator> + Send, - ) -> Result, Error> { - let ids = join_ids(track_ids); - debug!("getting track features for {ids} (web)"); - let url = format!("/audio-features/?ids={ids}"); - - let result = self - .public_get::>(&url, None) - .await?; - if let Some(payload) = result { - Ok(payload.audio_features.into_iter().flatten().collect()) - } else { - Ok(Vec::new()) - } - } - - /// Get Audio Analysis for a Track - /// - /// Parameters: - /// - track_id - a track URI, URL or ID - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-analysis) - pub async fn web_track_analysis(&self, track_id: TrackId<'_>) -> Result { - debug!("getting audio analysis for {track_id} (web)"); - let url = format!("/audio-analysis/{}", track_id.id()); - self.public_get(&url, None).await - } - - /// Get a list of new album releases featured in Spotify - /// - /// Parameters: - /// - country - An ISO 3166-1 alpha-2 country code or string from_token. - /// - locale - The desired language, consisting of an ISO 639 language code - /// and an ISO 3166-1 alpha-2 country code, joined by an underscore. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 (the first - /// object). Use with limit to get the next set of items. - /// - /// See [`Self::categories_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-categories) - pub fn web_categories<'a>( - &'a self, - locale: Option<&'a str>, - country: Option, - ) -> Paginator<'a, Result> { - paginate( - move |limit, offset| { - self.web_categories_manual(locale, country, Some(limit), Some(offset)) - }, - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::categories`]. - pub async fn web_categories_manual( - &self, - locale: Option<&str>, - country: Option, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting categories (web)"); - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map([ - ("locale", locale), - ("country", country.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - let result = self - .public_get::("/browse/categories", Some(¶ms)) - .await?; - Ok(result.categories) - } - - /// Get a list of playlists in a category in Spotify - /// - /// Parameters: - /// - category_id - The category id to get playlists from. - /// - country - An ISO 3166-1 alpha-2 country code or the string from_token. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 (the first - /// object). Use with limit to get the next set of items. - /// - /// See [`Self::category_playlists_manual`] for a manually paginated version - /// of this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-categories-playlists) - pub fn web_category_playlists<'a>( - &'a self, - category_id: &'a str, - country: Option, - ) -> Paginator<'a, Result> { - paginate( - move |limit, offset| { - self.web_category_playlists_manual(category_id, country, Some(limit), Some(offset)) - }, - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::category_playlists`]. - pub async fn web_category_playlists_manual( - &self, - category_id: &str, - country: Option, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting playlists of category {category_id} (web)"); - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map([ - ("country", country.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - let url = format!("/browse/categories/{category_id}/playlists"); - let result = self - .public_get::(&url, Some(¶ms)) - .await?; - Ok(result.playlists) - } - - /// Get detailed profile information about the current user. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile) - pub async fn web_current_user(&self) -> Result { - debug!("getting current user (web)"); - self.public_get("/me", None).await - } - - /// Get a list of Spotify featured playlists. - /// - /// Parameters: - /// - locale - The desired language, consisting of a lowercase ISO 639 - /// language code and an uppercase ISO 3166-1 alpha-2 country code, - /// joined by an underscore. - /// - country - An ISO 3166-1 alpha-2 country code or the string from_token. - /// - timestamp - A timestamp in ISO 8601 format: yyyy-MM-ddTHH:mm:ss. Use - /// this parameter to specify the user's local time to get results - /// tailored for that specific date and time in the day - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 - /// (the first object). Use with limit to get the next set of - /// items. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-featured-playlists) - pub async fn web_featured_playlists( - &self, - locale: Option<&str>, - country: Option, - timestamp: Option, - limit: Option, - offset: Option, - ) -> Result { - debug!("getting featured playlists (web)"); - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let timestamp = timestamp.and_then(|x| { - x.format(&time::format_description::well_known::Iso8601::DEFAULT) - .ok() - }); - let params = build_map([ - ("locale", locale), - ("country", country.map(Into::into)), - ("timestamp", timestamp.as_deref()), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - self.public_get("/browse/featured-playlists", Some(¶ms)) - .await - } - - /// Get a list of new album releases featured in Spotify. - /// - /// Parameters: - /// - country - An ISO 3166-1 alpha-2 country code or string from_token. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 50 - /// - offset - The index of the first item to return. Default: 0 (the first - /// object). Use with limit to get the next set of items. - /// - /// See [`Self::new_releases_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-new-releases) - pub fn web_new_releases( - &self, - country: Option, - ) -> Paginator<'_, Result> { - paginate( - move |limit, offset| self.web_new_releases_manual(country, Some(limit), Some(offset)), - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::new_releases`]. - pub async fn web_new_releases_manual( - &self, - country: Option, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting new releases (web)"); - let limit = limit.map(|x| x.to_string()); - let offset = offset.map(|x| x.to_string()); - let params = build_map([ - ("country", country.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - let result = self - .public_get::("/browse/new-releases", Some(¶ms)) - .await?; - Ok(result.albums) - } - - /// Get Recommendations Based on Seeds - /// - /// Parameters: - /// - attributes - restrictions on attributes for the selected tracks, such - /// as `min_acousticness` or `target_duration_ms`. - /// - seed_artists - a list of artist IDs, URIs or URLs - /// - seed_tracks - a list of artist IDs, URIs or URLs - /// - seed_genres - a list of genre names. Available genres for - /// - market - An ISO 3166-1 alpha-2 country code or the string from_token. - /// If provided, all results will be playable in this country. - /// - limit - The maximum number of items to return. Default: 20. - /// Minimum: 1. Maximum: 100 - /// - `min/max/target_` - For the tuneable track attributes - /// listed in the documentation, these values provide filters and - /// targeting on results. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-recommendations) - pub async fn web_recommendations( - &self, - attributes: impl IntoIterator + Send, - seed_artists: Option> + Send>, - seed_genres: Option + Send>, - seed_tracks: Option> + Send>, - market: Option, - limit: Option, - ) -> Result { - let seed_artists = seed_artists.map(|a| join_ids(a)); - let seed_genres = seed_genres.map(|x| x.into_iter().collect::>().join(",")); - let seed_tracks = seed_tracks.map(|t| join_ids(t)); - let limit = limit.map(|x| x.to_string()); - let mut params = build_map([ - ("seed_artists", seed_artists.as_deref()), - ("seed_genres", seed_genres.as_deref()), - ("seed_tracks", seed_tracks.as_deref()), - ("market", market.map(Into::into)), - ("limit", limit.as_deref()), - ]); - debug!("getting recommendations (artists={seed_artists:?},genres={seed_genres:?},tracks={seed_tracks:?},web)"); - - // First converting the attributes into owned `String`s - let owned_attributes = attributes - .into_iter() - .map(|attr| (<&str>::from(attr).to_owned(), attr.value_string())) - .collect::>(); - // Afterwards converting the values into `&str`s; otherwise they - // wouldn't live long enough - let borrowed_attributes = owned_attributes - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())); - // And finally adding all of them to the payload - params.extend(borrowed_attributes); - - self.public_get("/recommendations", Some(¶ms)).await - } - - /// Get full details of the items of a playlist owned by a user. - /// - /// Parameters: - /// - playlist_id - the id of the playlist - /// - fields - which fields to return - /// - limit - the maximum number of tracks to return - /// - offset - the index of the first track to return - /// - market - an ISO 3166-1 alpha-2 country code or the string from_token. - /// - /// See [`Self::playlist_items_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlists-tracks) - pub async fn web_playlist_items<'a>( - &'a self, - playlist_id: PlaylistId<'a>, - fields: Option<&'a str>, - market: Option, - ) -> Paginator<'a, Result> { - paginate_with_ctx( - (self, playlist_id, fields), - move |(slf, playlist_id, fields), limit, offset| { - Box::pin(slf.web_playlist_items_manual( - playlist_id.as_ref(), - *fields, - market, - Some(limit), - Some(offset), - )) - }, - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::playlist_items`]. - pub async fn web_playlist_items_manual( - &self, - playlist_id: PlaylistId<'_>, - fields: Option<&str>, - market: Option, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting items of playlist {playlist_id} (web)"); - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map([ - ("fields", fields), - ("market", market.map(Into::into)), - ("limit", limit.as_deref()), - ("offset", offset.as_deref()), - ]); - - let url = format!("/playlists/{}/tracks", playlist_id.id()); - self.public_get(&url, Some(¶ms)).await - } - - /// Gets playlists of a user. - /// - /// Parameters: - /// - user_id - the id of the usr - /// - limit - the number of items to return - /// - offset - the index of the first item to return - /// - /// See [`Self::user_playlists_manual`] for a manually paginated version of - /// this. - /// - /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists) - pub fn web_user_playlists<'a>( - &'a self, - user_id: UserId<'a>, - ) -> Paginator<'a, Result> { - paginate_with_ctx( - (self, user_id), - move |(slf, user_id), limit, offset| { - Box::pin(slf.web_user_playlists_manual(user_id.as_ref(), Some(limit), Some(offset))) - }, - self.session().config().pagination_chunks, - ) - } - - /// The manually paginated version of [`Self::user_playlists`]. - pub async fn web_user_playlists_manual( - &self, - user_id: UserId<'_>, - limit: Option, - offset: Option, - ) -> Result, Error> { - debug!("getting playlists of user {user_id} (web)"); - let limit = limit.map(|s| s.to_string()); - let offset = offset.map(|s| s.to_string()); - let params = build_map([("limit", limit.as_deref()), ("offset", offset.as_deref())]); - - let url = format!("/users/{}/playlists", user_id.id()); - self.public_get(&url, Some(¶ms)).await - } -} - -fn build_map<'key, 'value, const N: usize>( - array: [(&'key str, Option<&'value str>); N], -) -> HashMap<&'key str, &'value str> { - // Use a manual for loop instead of iterators so we can call `with_capacity` - // and avoid reallocating. - let mut map = HashMap::with_capacity(N); - for (key, value) in array { - if let Some(value) = value { - map.insert(key, value); - } - } - map -} - -fn join_ids(ids: impl IntoIterator) -> String { - ids.into_iter() - .map(|id| id.id().to_owned()) - .collect::>() - .join(",") -} - -#[cfg(test)] -mod tests { - use futures_util::TryStreamExt; - use spotifyio_model::{ - ArtistId, FileId, IdConstruct, PlaylistId, PrereleaseId, TrackId, UserId, - }; - - use crate::{cache::SessionCache, gql_model::SearchItemType, Session, SessionConfig}; - - async fn conn() -> Session { - Session::new(SessionConfig::default(), SessionCache::testing()) - } - - #[tokio::test] - async fn get_artist() { - let s = conn().await; - let artist = s - .spclient() - .pb_artist( - ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), - // Some("\"MC-EvTsdg==\""), - None, - ) - .await - .unwrap(); - dbg!(&artist); - } - - #[tokio::test] - async fn get_artists() { - let s = conn().await; - let artists = s - .spclient() - .pb_artists([ - ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), - ArtistId::from_id("5RJFJWYgtgWktosLrUDzxx").unwrap(), // does not exist - ArtistId::from_id("2NpPlwwDVYR5dIj0F31EcC").unwrap(), - ]) - .await - .unwrap(); - dbg!(&artists); - } - - #[tokio::test] - async fn get_artist_overview() { - let s = conn().await; - let artist = s - .spclient() - .gql_artist_overview(ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap()) - .await - .unwrap(); - dbg!(&artist); - } - - #[tokio::test] - async fn get_prerelease() { - let s = conn().await; - let search = s - .spclient() - .gql_prerelease( - PrereleaseId::from_uri("spotify:prerelease:44eAZjMWAxN7AaRHqLHWO5").unwrap(), - ) - .await - .unwrap(); - dbg!(&search); - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn search() { - let s = conn().await; - let search = s - .spclient() - .gql_search("this is me", 0, 10, None) - .await - .unwrap(); - dbg!(&search); - } - - #[tokio::test] - async fn search_tracks() { - let s = conn().await; - let n = 10; - let search = s - .spclient() - .gql_search("this is me", 0, n, Some(SearchItemType::Tracks)) - .await - .unwrap(); - assert_eq!(search.tracks_v2.items.len(), n as usize); - dbg!(&search); - } - - #[tokio::test] - async fn search_users() { - let s = conn().await; - let n = 10; - let search = s - .spclient() - .gql_search("test", 0, n, Some(SearchItemType::Users)) - .await - .unwrap(); - assert_eq!(search.users.items.len(), n as usize); - dbg!(&search); - } - - #[tokio::test] - async fn user_profile() { - let s = conn().await; - let user = s - .spclient() - .get_user_profile(UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(), 20) - .await - .unwrap(); - dbg!(&user); - } - - #[tokio::test] - async fn user_playlists() { - let s = conn().await; - let playlists = s - .spclient() - .get_user_playlists( - UserId::from_id("ustz0fgnbb2pjpjhu3num7b91").unwrap(), - 0, - 200, - ) - .await - .unwrap(); - dbg!(&playlists); - } - - #[tokio::test] - async fn user_followers() { - let s = conn().await; - let followers = s - .spclient() - .get_user_followers(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap()) - .await - .unwrap(); - dbg!(&followers); - } - - #[tokio::test] - async fn user_following() { - let s = conn().await; - let followers = s - .spclient() - .get_user_following(UserId::from_id("c4vns19o05omhfw0p4txxvya7").unwrap()) - .await - .unwrap(); - dbg!(&followers); - } - - #[tokio::test] - async fn artist_albums() { - let s = conn().await; - let albums = s - .spclient() - .web_artist_albums( - ArtistId::from_id("1EfwyuCzDQpCslZc8C9gkG").unwrap(), - None, - None, - ) - .try_collect::>() - .await - .unwrap(); - dbg!(&albums); - } - - #[tokio::test] - async fn web_track() { - let s = conn().await; - let track = s - .spclient() - .web_track( - TrackId::from_id("4Lzbwc1IKHCDddL17xJjxV").unwrap(), - Some(spotifyio_model::Market::FromToken), - ) - .await - .unwrap(); - dbg!(&track); - } - - #[tokio::test] - async fn seek_table() { - let s = conn().await; - let st = s - .spclient() - .get_seektable( - &FileId::from_base16("ae2ecdf9cf52eb2aa10e0284fc4908b0b08eab04").unwrap(), - ) - .await - .unwrap(); - dbg!(&st); - } - - #[tokio::test] - async fn widevine_license() { - let challenge = hex_lit::hex!("080112b20f0abc0e080112810a0ac10208021220529d02e1d35661973882f274eabf9d38ae315809a3950d4bf59e82c4b660591f189bc89fb106228e023082010a0282010100d49d3b3b3567d35b7695f8149e069a913f9832afa7fe1f22de19b59905df198756b4db905748043436a509b27c26a8cb4614b7aaec742501c2846b2cafb0fd4edf2a3620c1574289d713e9da656b032da1e8db876a636d394cd28697c3f366a7a959daf070770397ca1c7039ee6a3b5b8f256b9d423e56ab733fb7638b5055e325cb95613447c16df9edccff41da8f5f86ae71f0c9d24d2b17774804a5f25a9a59c9e1789c1473d203b7d06b22654364f25df45c9a105c3fa2840e02f73a372c6fb40046a6ef1529c0686ea07d3368783554904e05fd1e4dadfcd89be3439732f09ac9802a3b860d880977a4b3bf9a8d58355e1a8073de7ecdeeddb2a20351a9020301000128f7e1014801128002b7cb9c3e820807b3250be8ae5735b22347916b413589925d1ec93114a5cc10c3114374afed0c793547ff0065e7827f2156b238f0926130445f6d4d4f7d2af32dfb2807718744a76c9864f863a120b81b696851fb56d35ce9df6a6acdfe2133372e84834613c8ed91b75821b871a9c041a3208e25a7042a19004de051aadc89ee78e7f7cef46b68ff1aa6e371dc7416ef0b4d4467d5eaecb41964fae0ff66b77e1543de2bcea82ad9053b994af927daa0b0a0e9aecb1df985cbfcc302622c116bb39c5814c573b9f745efe45d5daacea054d9d6babc81701ec4be38fbfc76438282eba75586eaf88ba383beed6c2c3473dd18a56fe10c9245617f157577e39c0c1ab7050ab102080112101e6c196a3a78ee65768ed08932af535918b8c9ec9e06228e023082010a0282010100cb6cafd5143b87feea5cfc22c740deb0f02efdabd38aa95ee1510f7a6728363704608e46a41df7d5702778b73a74eab4b98fca2a00f620f36b55457c8be4d9d224c5f1b4b9c2dc1688b0c58a1854fbe1eee828a1d423ffc89c26a6dc925dbe511c1c3e60c6e963ab46c5e1da21fc17e7d0aeb76c53bfa028f767704945c890ccb6b02cd6c6c8448f5cc3f1e6d2d2ee0de5eb0477c0f5a52e547e44c6d220f754d3e880c36fea8f5943b0a33210101c5d4f78d20e42348c75d137907123f138e86ef1b0297ab1088dd4ec324492d8ff8221bb6fdfac0bab50f8c9f81ee2b30b12dda04a2f3ac67b557aeb13f518d8a8f076e6949cc827044feacb03d1e503aa7f020301000128f7e101480112800330d07e22ca8b5a281f9147088fa5733026d2b5708f1779ba07dff88bf403b3414b9a04199cc02b401deb112bbad27cd5f1230605c4901778e05930eec00b55677663e6b3c1bfe10a33ab1a895c9da1bdb3b766c0509d84a5dc4121f86b32b3cd9ab2520d06d4f7e45a501a7bf7973833a3f7de98c75a93099b50de677484f99870f22c947ad7afe97887ab7acd5a8510d25a9dd0c1913fe0c0984c82e31d9172b38f9e806346b2d1df246ea88d6e74c84f7336b8b6e21e232e9d84febe9ee7b32df9c0160b3050129ffebddccaa369b6ebf0de0b55b5aeb212a76feffcc7071aec1fe6999131666c04c33ef695c17956634e3a1bca5edc74255b3ff160d06ce7d95aad3071c24c8fd1a63f23a594e3d2e7323055e863d7e0aebc98914e79a83d059986b1089da1fee45ba32aa9c312c65d758b303d1e1590c97d377eae1196db559fc54417a56cea43ddbd04a6cb38bc7a45eb3691279a872938d4ea8977d327cdf1ce16e18e69321dcfd514050b2a682c3d2a40bfdf801c167866f1cac3edc41a260a106170706c69636174696f6e5f6e616d651212636f6d2e616e64726f69642e6368726f6d651a2a0a066f726967696e122034383045394330364144333844463239464242444433314143334234464435441a4e0a1e7061636b6167655f63657274696669636174655f686173685f6279746573122c38503173573045504a63736c7737557a527369584c3634772b4f353045642b52424943746179316732344d3d1a170a0c636f6d70616e795f6e616d65120773616d73756e671a160a0a6d6f64656c5f6e616d651208534d2d41313337461a200a116172636869746563747572655f6e616d65120b61726d656162692d7637611a140a0b6465766963655f6e616d65120561313376651a1a0a0c70726f647563745f6e616d65120a61313376656e736565611a590a0a6275696c645f696e666f124b73616d73756e672f61313376656e736565612f61313376653a31342f555031412e3233313030352e3030372f413133374658585334445843313a757365722f72656c656173652d6b6579731a220a147769646576696e655f63646d5f76657273696f6e120a31362e312e31403030361a240a1f6f656d5f63727970746f5f73656375726974795f70617463685f6c6576656c1201301a500a1c6f656d5f63727970746f5f6275696c645f696e666f726d6174696f6e12304f454d43727970746f204c6576656c3320436f64652032383931392046656220203220323032332030323a35303a32373214080110012000281030004000480050015800600112610a5f0a3908011210d399ea1a03b4cabf075e4c55cb04e9281a0773706f746966792214d399ea1a03b4cabf075e4c55cb04e92818edbef348e3dc959b0610011a2034383639353344343030303030303030303130303030303030303030303030301801208f98fab906301538c1ddccc7061a8002498e55d1ed289f0ff30e552abb9de307146ba11852f930ca61371f9a28da18a1416714675fb6ddd1a158cf722284bf6fb6705d2d5c87c54b8fba8b4c082f7fd95ddfa4ef10f37be59467415cd1be4cd8d259f7812927c8cc422cdab3f3d7a695d3e49af179299429513e0f0655eccb928df20f78d583c2f01053c7cc68870fd7323e8504f91a2a88a19fbb9de656a06d51f25c43604b3a6edb244fee0008e03fb11832f540d01c9dea92e13e1c3feea2f58441c0af87bc07bdbf1778096dd4b9299e77853e80ab8041579b2d5784f3fe9dfafe97ec7ae6a3b9ba9bf157ff64828ec378758a4e7c5158e46d5ca6e70605b32185da9d3a08b10fec1426e72399d6"); - - let s = conn().await; - let license = s - .spclient() - .get_widevine_license(challenge.to_vec(), false) - .await - .unwrap(); - let license_b64 = data_encoding::BASE64.encode(&license); - dbg!(&license_b64); - } - - #[tokio::test] - async fn web_playlist() { - let s = conn().await; - let pl = s - .spclient() - .gql_playlist(PlaylistId::from_id("1Th7TfDCbL8I9v6nRhWObF").unwrap()) - .await - .unwrap(); - dbg!(&pl); - } -} diff --git a/crates/spotifyioweb/src/spotify_pool.rs b/crates/spotifyioweb/src/spotify_pool.rs deleted file mode 100644 index 2fa2dea..0000000 --- a/crates/spotifyioweb/src/spotify_pool.rs +++ /dev/null @@ -1,105 +0,0 @@ -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, -} - -struct SpotifyIoPoolInner { - app_cache: ApplicationCache, - pool: ClientPool, -} - -pub struct PoolConfig { - pub max_sessions: usize, - pub quota_key: Quota, - pub jitter: Option, -} - -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>( - cache_path: P, - session_cfg: &SessionConfig, - pool_cfg: &PoolConfig, - ) -> Result { - 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 { - 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, - is_video: bool, - ) -> Result { - self.inner - .pool - .get() - .await? - .spclient() - .get_widevine_license(challenge, is_video) - .await - } - - pub async fn get_widevine_license_timeout( - &self, - challenge: Vec, - is_video: bool, - timeout: Duration, - ) -> Result { - self.inner - .pool - .get_timeout(timeout) - .await? - .spclient() - .get_widevine_license(challenge, is_video) - .await - } -} diff --git a/crates/spotifyioweb/src/totp.rs b/crates/spotifyioweb/src/totp.rs deleted file mode 100644 index cffdab5..0000000 --- a/crates/spotifyioweb/src/totp.rs +++ /dev/null @@ -1,88 +0,0 @@ -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, - updated_at: OffsetDateTime, -} - -const PERIOD: u64 = 30; -const DIGITS: u32 = 6; - -impl Totp { - pub async fn new(client: &Client) -> Result { - let resp = client - .get(SPOTIFY_SECRETS_JSON) - .send() - .await? - .error_for_status()?; - let mut secrets = resp.json::>>().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 { - 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::::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)); - } -} diff --git a/crates/spotifyioweb/src/util.rs b/crates/spotifyioweb/src/util.rs deleted file mode 100644 index b8c234d..0000000 --- a/crates/spotifyioweb/src/util.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! 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 { - 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 - ) -} diff --git a/crates/spotifyioweb/src/web_model.rs b/crates/spotifyioweb/src/web_model.rs deleted file mode 100644 index 8904df7..0000000 --- a/crates/spotifyioweb/src/web_model.rs +++ /dev/null @@ -1,64 +0,0 @@ -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, -}