diff --git a/Cargo.toml b/Cargo.toml index 41440d9..02fcc65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ resolver = "2" protobuf = "3.5" # WS crates -spotifyio = { path = "crates/spotifyio", version = "0.0.2", registry = "thetadev" } +spotifyio = { path = "crates/spotifyio", version = "0.0.3", registry = "thetadev" } +spotifyioweb = { path = "crates/spotifyioweb", version = "0.0.1", registry = "thetadev" } spotifyio-protocol = { path = "crates/protocol", version = "0.1.0", registry = "thetadev" } spotifyio-model = { path = "crates/model", version = "0.2.0", registry = "thetadev" } diff --git a/crates/downloader/Cargo.toml b/crates/downloader/Cargo.toml index be2c425..9fef250 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", "spotifyio/default-tls"] -native-tls = ["reqwest/native-tls", "spotifyio/native-tls"] -native-tls-alpn = ["reqwest/native-tls-alpn", "spotifyio/native-tls-alpn"] +default-tls = ["reqwest/default-tls", "spotifyioweb/default-tls"] +native-tls = ["reqwest/native-tls", "spotifyioweb/native-tls"] +native-tls-alpn = ["reqwest/native-tls-alpn", "spotifyioweb/native-tls-alpn"] native-tls-vendored = [ "reqwest/native-tls-vendored", - "spotifyio/native-tls-vendored", + "spotifyioweb/native-tls-vendored", ] rustls-tls-webpki-roots = [ "reqwest/rustls-tls-webpki-roots", - "spotifyio/rustls-tls-webpki-roots", + "spotifyioweb/rustls-tls-webpki-roots", ] rustls-tls-native-roots = [ "reqwest/rustls-tls-native-roots", - "spotifyio/rustls-tls-native-roots", + "spotifyioweb/rustls-tls-native-roots", ] [dependencies] @@ -68,5 +68,4 @@ go-parse-duration = "0.1.1" which = "7" widevine = "0.1" -spotifyio = { workspace = true, features = ["oauth"] } -spotifyio-protocol.workspace = true +spotifyioweb = { workspace = true } diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index fa1cfda..a4a1f58 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -13,7 +13,6 @@ use std::{ time::{Duration, Instant}, }; -use aes::cipher::{KeyIvInit, StreamCipher}; use futures_util::{stream, StreamExt, TryStreamExt}; use indicatif::{MultiProgress, ProgressBar}; use itertools::Itertools; @@ -29,17 +28,21 @@ use model::{ }; use once_cell::sync::Lazy; use path_macro::path; +use reqwest::header; use serde::Serialize; -use spotifyio::{ +use spotifyioweb::{ model::{ AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, PlaylistId, TrackId, }, pb::AudioFileFormat, - AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig, - PoolError, Quota, Session, SessionConfig, SpotifyIoPool, + Error as SpotifyError, NotModifiedRes, PoolConfig, PoolError, Quota, Session, SessionConfig, + SpotifyIoPool, +}; +use spotifyioweb::{ + pb::metadata::{Album, Availability, Restriction, Track}, + CdnUrl, NormalisationData, }; -use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track}; use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool}; use time::OffsetDateTime; @@ -48,7 +51,7 @@ use widevine::{Cdm, Pssh, ServiceCertificate}; pub mod model; -type Aes128Ctr = ctr::Ctr128BE; +// type Aes128Ctr = ctr::Ctr128BE; const DOT_SEPARATOR: &str = " • "; @@ -56,8 +59,6 @@ const DOT_SEPARATOR: &str = " • "; pub enum Error { #[error("Spotify: {0}")] Spotify(#[from] SpotifyError), - #[error("OAuth: {0}")] - OAuth(#[from] spotifyio::oauth::OAuthError), #[error("DB: {0}")] Db(#[from] sqlx::Error), #[error("File IO: {0}")] @@ -110,7 +111,6 @@ pub struct SpotifyDownloaderInner { mp4decryptor: Option, 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 = spotifyio::util::audio_format_extension(format).expect("unknown file format"); + let ext = spotifyioweb::util::audio_format_extension(format).expect("unknown file format"); better_filenamify(&format!("{:02} {}", track_nr, name,), Some(ext)) } @@ -253,14 +253,14 @@ impl From for Error { } enum AudioKeyVariant { - Clearkey(AudioKey), + // Clearkey(AudioKey), Widevine(widevine::Key), } impl std::fmt::Display for AudioKeyVariant { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let key = match self { - AudioKeyVariant::Clearkey(audio_key) => audio_key.0.as_slice(), + // AudioKeyVariant::Clearkey(audio_key) => audio_key.0.as_slice(), AudioKeyVariant::Widevine(key) => key.key.as_slice(), }; data_encoding::HEXLOWER.encode_write(key, f) @@ -282,13 +282,12 @@ pub struct SpotifyDownloaderConfig { pub widevine_device: Option, pub mp4decryptor: Option, pub apply_tags: bool, - pub format_m4a: bool, } impl Default for SpotifyDownloaderConfig { fn default() -> Self { Self { - cache_path: path!("spotifyio.json"), + cache_path: path!("spotifyioweb.json"), quota: Quota::per_minute(NonZeroU32::new(1).unwrap()) .allow_burst(NonZeroU32::new(3).unwrap()), db_path: path!("tracks.db"), @@ -297,7 +296,6 @@ impl Default for SpotifyDownloaderConfig { widevine_device: None, mp4decryptor: Mp4Decryptor::from_env(), apply_tags: true, - format_m4a: false, } } } @@ -334,7 +332,6 @@ impl SpotifyDownloader { cdm, mp4decryptor: cfg.mp4decryptor, apply_tags: cfg.apply_tags, - format_m4a: cfg.format_m4a, widevine_cert: RwLock::new(None), } .into(), @@ -342,7 +339,6 @@ impl SpotifyDownloader { } pub async fn shutdown(&self) { - self.i.sp.shutdown(); self.i.pool.close().await; } @@ -445,24 +441,7 @@ impl SpotifyDownloader { audio_item.track_id ); - let formats_m4a = [ - AudioFileFormat::MP4_128, - AudioFileFormat::OGG_VORBIS_160, - AudioFileFormat::MP3_160, - AudioFileFormat::OGG_VORBIS_96, - AudioFileFormat::MP3_96, - ]; - let formats_default = [ - AudioFileFormat::OGG_VORBIS_160, - AudioFileFormat::MP3_160, - AudioFileFormat::OGG_VORBIS_96, - AudioFileFormat::MP3_96, - ]; - let formats = if self.i.format_m4a { - formats_m4a.as_slice() - } else { - formats_default.as_slice() - }; + let formats = &[AudioFileFormat::MP4_128]; let (format, file_id) = formats .iter() @@ -475,26 +454,8 @@ impl SpotifyDownloader { Ok((audio_item, file_id, format)) } - async fn get_file_key_clearkey( - &self, - spotify_id: PlayableId<'_>, - file_id: FileId, - ) -> Result { - 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_id}"); + tracing::debug!("getting widevine key for file:{file_id}"); let cdm = self .i .cdm @@ -510,7 +471,11 @@ impl SpotifyDownloader { .open() .set_service_certificate(cert)? .get_license_request(pssh, widevine::LicenseType::STREAMING)?; - let license = sp.get_widevine_license(request.challenge()?, false).await?; + let license = self + .i + .sp + .get_widevine_license(request.challenge()?, false) + .await?; let keys = request.get_keys(&license)?; let key = keys .of_type(widevine::KeyType::CONTENT) @@ -521,18 +486,15 @@ impl SpotifyDownloader { async fn get_file_key( &self, - spotify_id: PlayableId<'_>, file_id: FileId, format: AudioFileFormat, ) -> Result { - if matches!(format, AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256) { + if spotifyioweb::util::audio_format_widevine(format) { Ok(AudioKeyVariant::Widevine( self.get_file_key_widevine(file_id).await?, )) } else { - Ok(AudioKeyVariant::Clearkey( - self.get_file_key_clearkey(spotify_id, file_id).await?, - )) + Err(Error::AudioKey("clearkey unsupported".to_owned())) } } @@ -628,7 +590,7 @@ impl SpotifyDownloader { audio_url: &str, audio_key: &AudioKeyVariant, dest: &Path, - ) -> Result<(Option, u64), Error> { + ) -> Result { let tpath_tmp = dest.with_extension("tmp"); let mut res = self @@ -641,13 +603,13 @@ impl SpotifyDownloader { .await? .error_for_status()?; - let mut file_size = res + let file_size = res .content_length() .ok_or_else(|| SpotifyError::failed_precondition("no file size"))?; let mut file = tokio::fs::File::create(&tpath_tmp).await?; - let mut norm_data = None; match audio_key { + /* AudioKeyVariant::Clearkey(audio_key) => { let mut stream = res.bytes_stream(); file_size -= 167; @@ -673,6 +635,7 @@ impl SpotifyDownloader { let h = spotify_header.ok_or(SpotifyError::failed_precondition("no header"))?; norm_data = Some(NormalisationData::parse_from_ogg(&mut Cursor::new(&h))?); } + */ AudioKeyVariant::Widevine(key) => { let decryptor = self .i @@ -685,12 +648,37 @@ impl SpotifyDownloader { } drop(file); + tracing::debug!("widevine key: {:?}", key); + decryptor.decrypt(&tpath_tmp, dest, key)?; std::fs::remove_file(&tpath_tmp)?; } } - Ok((norm_data, file_size)) + Ok(file_size) + } + + async fn get_norm_data( + &self, + audio_files: &AudioFiles, + ) -> Result, Error> { + if let Some(file_id) = audio_files.0.get(&AudioFileFormat::OGG_VORBIS_320) { + let sess = self.i.sp.session()?; + let head_url = sess.head_file_url(file_id); + let res = sess + .http_client() + .get(head_url) + .header(header::RANGE, "bytes=0-166") + .send() + .await? + .error_for_status()?; + let head_data = res.bytes().await?; + Ok(Some(NormalisationData::parse_from_ogg(&mut Cursor::new( + head_data, + ))?)) + } else { + Ok(None) + } } #[tracing::instrument(level = "error", skip(self, force))] @@ -717,7 +705,7 @@ impl SpotifyDownloader { .resolve_audio(self.i.sp.session()?) .await?; let audio_url = &cdn_url.try_get_url()?.0; - let key = self.get_file_key(pid, file_id, audio_format).await?; + let key = self.get_file_key(file_id, audio_format).await?; let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields { f @@ -795,8 +783,9 @@ impl SpotifyDownloader { std::fs::create_dir_all(&album_dir)?; // Download the file - let (norm_data, file_size) = self.download_audio_file(audio_url, &key, &tpath).await?; - let file_size = file_size as i64; + let file_size = self.download_audio_file(audio_url, &key, &tpath).await? as i64; + + let norm_data = self.get_norm_data(&sp_item.files).await?; // Download album cover let cover_path = path!(album_dir / "cover.jpg"); @@ -1561,3 +1550,33 @@ fn album_n_discs(album: &Album) -> u32 { .max() .unwrap_or(1) } + +#[cfg(test)] +mod tests { + use super::{SpotifyDownloader, SpotifyDownloaderConfig}; + use path_macro::path; + use spotifyioweb::model::{IdConstruct, PlayableId, TrackId}; + + fn get_dl() -> SpotifyDownloader { + SpotifyDownloader::new(SpotifyDownloaderConfig { + cache_path: path!( + env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyioweb.json" + ), + ..Default::default() + }) + .unwrap() + } + + #[tokio::test] + async fn norm_data() { + let dl = get_dl(); + let track = dl + .get_audio_item(PlayableId::Track( + TrackId::from_id("4sVrgFJLB3PCD9WvIsUH5j").unwrap(), + )) + .await + .unwrap(); + let norm_data = dl.get_norm_data(&track.files).await.unwrap().unwrap(); + dbg!(norm_data); + } +} diff --git a/crates/downloader/src/main.rs b/crates/downloader/src/main.rs index 274e347..7d4578c 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::{ - model::{AlbumId, ArtistId, Id, IdConstruct, PlaylistId, SearchResult, SearchType, TrackId}, - ApplicationCache, AuthCredentials, AuthenticationType, Quota, Session, SessionConfig, -}; use spotifyio_downloader::{Error, SpotifyDownloader, SpotifyDownloaderConfig}; +use spotifyioweb::{ + model::{AlbumId, ArtistId, Id, IdConstruct, PlaylistId, SearchResult, SearchType, TrackId}, + ApplicationCache, Quota, Session, SessionConfig, +}; use tracing::level_filters::LevelFilter; use tracing_subscriber::{fmt::MakeWriter, EnvFilter}; @@ -48,7 +48,7 @@ struct Cli { #[clap(subcommand)] command: Commands, /// Path to Spotify account cache - #[clap(long, default_value = "spotifyio.json")] + #[clap(long, default_value = "spotifyioweb.json")] cache: PathBuf, /// Path to music library base directory #[clap(long, default_value = "Downloads")] @@ -76,7 +76,9 @@ struct Cli { #[derive(Subcommand)] enum Commands { /// Add an account - Login, + Login { + sp_dc: String, + }, /// Remove an account Logout { /// Account ID to log out @@ -142,7 +144,7 @@ async fn main() -> Result<(), Error> { if matches!( &cli.command, - Commands::Login | Commands::Logout { .. } | Commands::Accounts + Commands::Login { .. } | Commands::Logout { .. } | Commands::Accounts ) { account_mgmt(cli).await } else { @@ -153,17 +155,9 @@ async fn main() -> Result<(), Error> { async fn account_mgmt(cli: Cli) -> Result<(), Error> { let app_cache = ApplicationCache::new(cli.cache); match cli.command { - Commands::Login => { - let token = spotifyio::oauth::get_access_token(None).await?; - let cache = spotifyio::oauth::new_session(&app_cache, &token.access_token).await?; + Commands::Login { sp_dc } => { + let cache = spotifyioweb::login::new_session(&app_cache, &sp_dc).await?; let session = Session::new(SessionConfig::default(), cache); - session - .connect(AuthCredentials { - user_id: None, - auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, - auth_data: token.access_token.as_bytes().to_vec(), - }) - .await?; println!("Logged in as <{}>", session.email().unwrap_or_default()); println!("User ID: {}", session.user_id()); if session.is_premium() { @@ -203,7 +197,6 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { base_dir: cli.base_dir, progress: Some(multi.clone()), apply_tags: !cli.no_tags, - format_m4a: cli.m4a, widevine_device: cli.wvd, ..Default::default() })?; @@ -254,7 +247,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { name: a.name.to_owned(), }] } else { - return Err(Error::Spotify(spotifyio::Error::not_found( + return Err(Error::Spotify(spotifyioweb::Error::not_found( "no artists returned", ))); } @@ -434,7 +427,7 @@ async fn download(cli: Cli, multi: MultiProgress) -> Result<(), Error> { pb.inc(track_ids.len() as u64); } } - Commands::Accounts | Commands::Login | Commands::Logout { .. } => unreachable!(), + Commands::Accounts | Commands::Login { .. } | Commands::Logout { .. } => unreachable!(), } dl.shutdown().await; Ok(()) diff --git a/crates/downloader/src/model/audio_file.rs b/crates/downloader/src/model/audio_file.rs index 3749b80..b4848d2 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 spotifyio::{model::FileId, pb::AudioFileFormat}; -use spotifyio_protocol::metadata::AudioFile as AudioFileMessage; +use spotifyioweb::pb::metadata::AudioFile as AudioFileMessage; +use spotifyioweb::{model::FileId, pb::AudioFileFormat}; #[derive(Debug, Clone, Default)] pub struct AudioFiles(pub HashMap); diff --git a/crates/downloader/src/model/availability.rs b/crates/downloader/src/model/availability.rs index c0a36c0..e38ee6c 100644 --- a/crates/downloader/src/model/availability.rs +++ b/crates/downloader/src/model/availability.rs @@ -1,5 +1,5 @@ -use spotifyio::Error; -use spotifyio_protocol::metadata::Availability as AvailabilityMessage; +use spotifyioweb::pb::metadata::Availability as AvailabilityMessage; +use spotifyioweb::Error; use time::OffsetDateTime; pub type AudioItemAvailability = Result<(), UnavailabilityReason>; diff --git a/crates/downloader/src/model/mod.rs b/crates/downloader/src/model/mod.rs index fd08077..3aa83cf 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 spotifyio::{ +use spotifyioweb::pb::metadata::{ + album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage, + Date as DateMessage, Image, +}; +use spotifyioweb::{ model::{AlbumId, ArtistId, FileId, IdConstruct, PlayableId, TrackId}, pb::{ArtistRole as PbArtistRole, ImageSize}, Error, }; -use spotifyio_protocol::metadata::{ - album::Type as AlbumType, Artist as ArtistMessage, ArtistWithRole as ArtistWithRoleMessage, - Date as DateMessage, Image, -}; use time::{Date, OffsetDateTime, PrimitiveDateTime, Time}; pub use audio_file::AudioFiles; diff --git a/crates/spotifyio/Cargo.toml b/crates/spotifyio/Cargo.toml index 71bdfec..f575970 100644 --- a/crates/spotifyio/Cargo.toml +++ b/crates/spotifyio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spotifyio" -version = "0.0.2" +version = "0.0.3" description = "Internal Spotify API Client" edition.workspace = true authors.workspace = true @@ -42,8 +42,6 @@ once_cell = "1.0" thiserror = "2.0" dotenvy = "0.15.7" path_macro = "1.0" -aes = "0.8" -ctr = "0.9" uuid = { version = "1.0", features = ["v4"] } bytes = "1.0" data-encoding = "2.5" diff --git a/crates/spotifyio/src/cdn_url.rs b/crates/spotifyio/src/cdn_url.rs index f115e08..359a872 100644 --- a/crates/spotifyio/src/cdn_url.rs +++ b/crates/spotifyio/src/cdn_url.rs @@ -135,17 +135,22 @@ 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.1.find('~') { + if let Some(end) = token.find('~') { // this is the only valid invariant for spotifycdn.com - let slice = &token.1[..end]; + let slice = &token[..end]; expiry_str = Some(String::from(&slice[..end])); } + } else if let Some((_, token)) = url.query_pairs().into_iter().find(|(key, _value)| key == "verify") { + // https://audio-cf.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?verify=1688165560-0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4= + if let Some(first) = token.split('-').next() { + expiry_str = Some(String::from(first)); + } } else if let Some(query) = url.query() { //"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=", let mut items = query.split('_'); @@ -189,20 +194,21 @@ mod test { format!("https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"), format!("https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="), format!("https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="), + format!("https://audio-cf.spotifycdn.com/audio/6a28ff353c2da587bfc1005064bbf4913c7fdc9e?verify={timestamp}-6WJMBXvDIB22xKy9G80aAFHQ2%2F%2BBv4bha1FTcJ4Zuy4%3D"), "https://audio4-fa.scdn.co/foo?baz".to_string(), ]; msg.fileid = vec![0]; let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls"); - assert_eq!(urls.len(), 4); - assert!(urls[0].1.is_some()); - assert!(urls[1].1.is_some()); - assert!(urls[2].1.is_some()); - assert!(urls[3].1.is_none()); + assert_eq!(urls.len(), 5); + assert!(urls[4].1.is_none()); + let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN; - assert_eq!( - urls[0].1.unwrap().unix_timestamp(), - timestamp_margin.whole_seconds() - ); + for i in 0..4 { + assert_eq!( + urls[i].1.unwrap().unix_timestamp(), + timestamp_margin.whole_seconds() + ); + } } } diff --git a/crates/spotifyio/src/error.rs b/crates/spotifyio/src/error.rs index a343dc5..c072d9f 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(value), + PoolError::Empty => Self::invalid_argument("No spotify clients"), PoolError::Timeout(_) => Self::resource_exhausted(value), } } diff --git a/crates/spotifyio/src/gql_model.rs b/crates/spotifyio/src/gql_model.rs index 699df29..51c6778 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}; +use serde_with::{serde_as, DefaultOnError, DisplayFromStr, VecSkipError}; use time::OffsetDateTime; use spotifyio_model::{ @@ -18,14 +18,35 @@ pub(crate) struct LyricsWrap { #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] -pub struct ItemWrap { - pub item: T, +pub struct GqlWrap { + pub data: T, } #[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GqlWrap { - pub data: T, +#[serde(tag = "__typename")] +#[allow(clippy::large_enum_variant)] +pub enum ArtistOption { + Artist(ArtistItem), + #[serde(alias = "GenericError")] + NotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "__typename")] +#[allow(clippy::large_enum_variant)] +pub enum AlbumOption { + Album(AlbumItem), + #[serde(alias = "GenericError")] + NotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "__typename")] +#[allow(clippy::large_enum_variant)] +pub enum TrackOption { + Track(TrackItem), + #[serde(alias = "GenericError")] + NotFound, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -375,20 +396,52 @@ pub(crate) struct SearchResultWrap { pub search_v2: GqlSearchResult, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[non_exhaustive] +pub enum MatchedField { + Lyrics, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct GqlSearchTrackWrap { + pub item: GqlWrap, + #[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)] @@ -482,7 +535,8 @@ pub struct ConcertOfferDates { pub end_date_iso_string: OffsetDateTime, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum SearchItemType { Artists, Albums, @@ -558,8 +612,8 @@ pub struct Seektable { pub encoder_delay_samples: u32, pub pssh: String, pub timescale: u32, - #[serde(alias = "init_range")] - pub index_range: (u32, u32), + pub init_range: Option<(u32, u32)>, + pub index_range: Option<(u32, u32)>, pub segments: Vec<(u32, u32)>, pub offset: usize, } @@ -603,6 +657,24 @@ pub struct PlaylistWrap { pub playlist_v2: PlaylistOption, } +impl ArtistOption { + pub fn into_option(self) -> Option { + 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 { @@ -621,6 +693,18 @@ 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 f573bcb..102a6ac 100644 --- a/crates/spotifyio/src/lib.rs +++ b/crates/spotifyio/src/lib.rs @@ -70,6 +70,9 @@ pub mod pb { pub use spotifyio_protocol::*; } +/// Version of the SpotifyIO crate +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + #[derive(Clone)] pub struct SpotifyIoPool { inner: Arc, diff --git a/crates/spotifyio/src/session.rs b/crates/spotifyio/src/session.rs index 04589e7..5953f2d 100644 --- a/crates/spotifyio/src/session.rs +++ b/crates/spotifyio/src/session.rs @@ -391,8 +391,12 @@ impl Session { format!("https://i.scdn.co/image/{}", &image_id.base16()) } - pub fn audio_preview_url(&self, preview_id: &FileId) -> String { - format!("https://p.scdn.co/mp3-preview/{}", &preview_id.base16()) + pub fn audio_preview_url(&self, file_id: &FileId) -> String { + format!("https://p.scdn.co/mp3-preview/{}", &file_id.base16()) + } + + pub fn head_file_url(&self, file_id: &FileId) -> String { + format!("https://heads-fa.scdn.co/head/{}", &file_id.base16()) } pub fn shutdown(&self) { diff --git a/crates/spotifyio/src/spclient.rs b/crates/spotifyio/src/spclient.rs index 14b226d..81967eb 100644 --- a/crates/spotifyio/src/spclient.rs +++ b/crates/spotifyio/src/spclient.rs @@ -197,8 +197,9 @@ impl SpClient { } pub async fn base_url(&self) -> Result { - let ap = self.get_accesspoint().await?; - Ok(format!("https://{}:{}", ap.0, ap.1)) + // let ap = self.get_accesspoint().await?; + // Ok(format!("https://{}:{}", ap.0, ap.1)) + Ok("https://spclient.wg.spotify.com".to_owned()) } pub async fn client_token(&self) -> Result { @@ -498,10 +499,7 @@ impl SpClient { } #[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))] - pub async fn _request_generic( - &self, - p: RequestParams<'_>, - ) -> Result, Error> { + 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; @@ -532,6 +530,7 @@ impl SpClient { .http_client() .request(p.method.clone(), url) .bearer_auth(auth_token) + // .bearer_auth("BQDdwSxvV2qf5GqQJY4t37YcRePJPkQ2hv_rwXJLsR6NCsCb0CFJaW0Ecs9paIikmbM0VG3k7E6K7ufkYaF_ut4VX0Q6QoC6SaU7YXqBCxn0ZGUG9iTLhjXqIw7n2iomrNb8r_3Vyk3xeYWqZmJymXokEjIjgr0FBKT69djnemIxAtm3YSK54fH1QPw1AwqvXooIqIUPMOwskz_a-gUE4n_YWzzSiHJQ38Kw8dzRLa7wMcoy8PO_tchWcofvdRhGw2IglFr4x2xjaFqozoPTaQsLCo1vdKYPUN8xr8xF7Ls76SU8YEFHP3krH0krNgpolyNbPR1fd4jJg3T7Mpfd08mtxVd4rMCNpa683HWJSHI4rtMJGaaSx2zx-4KrklA_8w") .header(header::ACCEPT_LANGUAGE, &self.session().config().language); if let Some(content_type) = p.content_type { @@ -620,7 +619,7 @@ impl SpClient { ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => { warn!("API error: {last_error} (retrying)"); // Keep trying the current access point three times before dropping it. - if tries % 3 == 0 { + if tries.is_multiple_of(3) { self.flush_accesspoint().await } } @@ -644,7 +643,7 @@ impl SpClient { Err(last_error) } - pub async fn request_pb( + async fn request_pb( &self, method: Method, endpoint: &str, @@ -662,7 +661,7 @@ impl SpClient { Ok(O::parse_from_bytes(&res.data)?) } - pub async fn request_get_pb( + async fn request_get_pb( &self, endpoint: &str, if_none_match: Option<&str>, @@ -954,9 +953,9 @@ impl SpClient { Ok(resp) } - pub async fn get_audio_preview(&self, preview_id: &FileId) -> Result { - debug!("getting audio preview {preview_id}"); - let mut url = self.session().audio_preview_url(preview_id); + pub async fn get_audio_preview(&self, file_id: &FileId) -> Result { + debug!("getting audio preview for file:{file_id}"); + let mut url = self.session().audio_preview_url(file_id); let separator = match url.find('?') { Some(_) => "&", None => "?", @@ -973,9 +972,22 @@ impl SpClient { Ok(resp) } + pub async fn get_head_file(&self, file_id: &FileId) -> Result { + 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 {file_id}"); + debug!("getting seektable for file:{file_id}"); let url = format!( "https://seektables.scdn.co/seektable/{}.json", file_id.base16() @@ -2301,6 +2313,7 @@ mod tests { } #[tokio::test] + #[tracing_test::traced_test] async fn search() { let s = conn().await; let search = s diff --git a/crates/spotifyio/src/spotify_id.rs b/crates/spotifyio/src/spotify_id.rs deleted file mode 100644 index d4b6794..0000000 --- a/crates/spotifyio/src/spotify_id.rs +++ /dev/null @@ -1,617 +0,0 @@ -use std::{borrow::Cow, fmt}; - -use serde::{de::Visitor, Deserialize, Serialize}; - -use crate::Error; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SpotifyItemType { - Album, - Artist, - Episode, - Playlist, - User, - Show, - Track, - Local, - Concert, - Prerelease, - Unknown, -} - -impl From<&str> for SpotifyItemType { - fn from(v: &str) -> Self { - match v { - "album" => Self::Album, - "artist" => Self::Artist, - "episode" => Self::Episode, - "playlist" => Self::Playlist, - "user" => Self::User, - "show" => Self::Show, - "track" => Self::Track, - "concert" => Self::Concert, - "prerelease" => Self::Prerelease, - _ => Self::Unknown, - } - } -} - -impl From 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 new file mode 100644 index 0000000..76f6ee7 --- /dev/null +++ b/crates/spotifyioweb/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "spotifyioweb" +version = "0.0.1" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true + +[features] +default = ["default-tls"] + +# Reqwest TLS options +default-tls = ["reqwest/default-tls"] +native-tls = ["reqwest/native-tls"] +native-tls-alpn = ["reqwest/native-tls-alpn"] +native-tls-vendored = ["reqwest/native-tls-vendored"] +rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] +rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] + +[dependencies] +tokio = { version = "1.20.4", features = ["macros"] } +tokio-stream = "0.1" +reqwest = { version = "0.12.0", features = ["json"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_with = { version = "3.0.0", default-features = false, features = [ + "alloc", + "macros", +] } +serde_json = "1" +tracing = "0.1.0" +time = { version = "0.3.21", features = [ + "serde-well-known", + "formatting", + "parsing", +] } +once_cell = "1.0" +thiserror = "2.0" +dotenvy = "0.15.7" +path_macro = "1.0" +uuid = { version = "1.0", features = ["v4"] } +bytes = "1.0" +data-encoding = "2.5" +sha1 = { version = "0.10", features = ["oid"] } +hmac = "0.12" +byteorder = "1.0" +futures-util = { version = "0.3", features = ["sink"] } +url = "2" +governor = { version = "0.10", default-features = false, features = [ + "std", + "quanta", + "jitter", +] } +rand = "0.9" +urlencoding = "2.1.0" +parking_lot = "0.12.0" +async-stream = "0.3.0" +ogg_pager = "0.7.0" +protobuf.workspace = true + +spotifyio-protocol.workspace = true +spotifyio-model.workspace = true + +[dev-dependencies] +tracing-test = "0.2.5" +hex_lit = "0.1" +protobuf-json-mapping = "3" diff --git a/crates/spotifyioweb/src/apresolve.rs b/crates/spotifyioweb/src/apresolve.rs new file mode 100644 index 0000000..1ce4f08 --- /dev/null +++ b/crates/spotifyioweb/src/apresolve.rs @@ -0,0 +1,137 @@ +use std::collections::VecDeque; + +use serde::Deserialize; +use tracing::warn; + +use crate::{util::SocketAddress, Error}; + +#[derive(Default)] +pub struct AccessPoints { + accesspoint: VecDeque, + 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 new file mode 100644 index 0000000..04f957f --- /dev/null +++ b/crates/spotifyioweb/src/cache.rs @@ -0,0 +1,329 @@ +use std::{ + collections::HashMap, + fs::File, + io::BufReader, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{spclient::Token, Error, Session, SessionConfig}; + +type CacheData = HashMap; + +#[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 new file mode 100644 index 0000000..359a872 --- /dev/null +++ b/crates/spotifyioweb/src/cdn_url.rs @@ -0,0 +1,214 @@ +use std::ops::{Deref, DerefMut}; + +use thiserror::Error; +use time::{Duration, OffsetDateTime}; +use tracing::{trace, warn}; +use url::Url; + +use crate::{model::FileId, Error, Session}; + +use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result; +use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; +use spotifyio_protocol as protocol; + +#[derive(Debug, Clone)] +pub struct MaybeExpiringUrl(pub String, pub Option); + +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 new file mode 100644 index 0000000..9546a4a --- /dev/null +++ b/crates/spotifyioweb/src/component.rs @@ -0,0 +1,31 @@ +macro_rules! component { + ($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => { + #[derive(Clone)] + pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, parking_lot::Mutex<$inner>)>); + impl $name { + #[allow(dead_code)] + pub(crate) fn new(session: $crate::session::SessionWeak) -> $name { + tracing::debug!(target:"librespot::component", "new {}", stringify!($name)); + + $name(::std::sync::Arc::new((session, parking_lot::Mutex::new($inner { + $($key : $value,)* + })))) + } + + #[allow(dead_code)] + fn lock 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 new file mode 100644 index 0000000..9ef160e --- /dev/null +++ b/crates/spotifyioweb/src/error.rs @@ -0,0 +1,460 @@ +use std::{ + error, fmt, + num::{ParseIntError, TryFromIntError}, + str::Utf8Error, + string::FromUtf8Error, +}; + +use protobuf::Error as ProtobufError; +use reqwest::StatusCode; +use spotifyio_model::IdError; +use thiserror::Error; +use tokio::sync::{ + mpsc::error::SendError, oneshot::error::RecvError, AcquireError, TryAcquireError, +}; +use url::ParseError; + +use crate::pool::PoolError; + +#[derive(Debug)] +pub struct Error { + pub kind: ErrorKind, + pub error: Box, +} + +#[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 new file mode 100644 index 0000000..51c6778 --- /dev/null +++ b/crates/spotifyioweb/src/gql_model.rs @@ -0,0 +1,735 @@ +//! Data model for the Spotify API + +use std::num::NonZeroU32; + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DefaultOnError, DisplayFromStr, VecSkipError}; +use time::OffsetDateTime; + +use spotifyio_model::{ + AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId, + UserlikeId, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LyricsWrap { + pub lyrics: Lyrics, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct GqlWrap { + 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 new file mode 100644 index 0000000..59315be --- /dev/null +++ b/crates/spotifyioweb/src/lib.rs @@ -0,0 +1,56 @@ +#[macro_use] +mod component; + +mod apresolve; +mod cache; +mod cdn_url; +mod error; +mod normalisation; +mod pagination; +mod pool; +mod session; +mod spclient; +mod spotify_pool; +mod totp; +mod web_model; + +pub mod gql_model; +pub mod login; +pub mod util; + +pub use cache::{ApplicationCache, SessionCache}; +pub use cdn_url::{CdnUrl, CdnUrlError, MaybeExpiringUrl}; +pub use error::{Error, ErrorKind, NotModifiedRes}; +pub use governor::{Jitter, Quota}; +pub use normalisation::NormalisationData; +pub use pool::PoolError; +pub use session::{Session, SessionConfig}; +pub use spclient::{EtagResponse, RequestStrategy}; +pub use spotify_pool::{PoolConfig, SpotifyIoPool}; +pub use spotifyio_model as model; + +/// Protobuf enums +pub mod pb { + pub use spotifyio_protocol::canvaz_meta::Type as CanvazType; + pub use spotifyio_protocol::extended_metadata::ExtensionType; + pub use spotifyio_protocol::extension_kind::ExtensionKind; + pub use spotifyio_protocol::metadata::{ + album::Type as AlbumType, + artist_with_role::ArtistRole, + audio_file::Format as AudioFileFormat, + copyright::Type as CopyrightType, + episode::EpisodeType, + image::Size as ImageSize, + restriction::{Catalogue as CatalogueRestriction, Type as TypeRestriction}, + show::{ConsumptionOrder, MediaType}, + }; + pub use spotifyio_protocol::playlist4_external::{ + op::Kind as OpKind, source_info::Client, GeoblockBlockingType, ItemAttributeKind, + ListAttributeKind, + }; + pub use spotifyio_protocol::playlist_permission::PermissionLevel; + pub use spotifyio_protocol::*; +} + +/// Version of the SpotifyIO crate +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/spotifyioweb/src/login.rs b/crates/spotifyioweb/src/login.rs new file mode 100644 index 0000000..f572fe2 --- /dev/null +++ b/crates/spotifyioweb/src/login.rs @@ -0,0 +1,96 @@ +use reqwest::{header, Client}; +use spotifyio_model::{Id, PrivateUser}; +use time::OffsetDateTime; +use url::Url; + +use crate::{ + spclient::Token, + totp::Totp, + web_model::{AuthTokenResponse, ServerTimeResponse}, + ApplicationCache, Error, SessionCache, +}; + +pub(crate) async fn get_server_time(http: &Client) -> Result { + 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 new file mode 100644 index 0000000..f8744ca --- /dev/null +++ b/crates/spotifyioweb/src/normalisation.rs @@ -0,0 +1,45 @@ +use std::io::{Cursor, Read, Seek}; + +use byteorder::{LittleEndian, ReadBytesExt}; + +use crate::Error; + +/// Audio metadata for volume normalization +/// +/// More information about ReplayGain and how to apply this infomation to audio files +/// can be found here: . +#[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 new file mode 100644 index 0000000..e6356f9 --- /dev/null +++ b/crates/spotifyioweb/src/pagination.rs @@ -0,0 +1,99 @@ +//! Asynchronous implementation of automatic pagination requests. + +use std::{future::Future, pin::Pin}; + +use futures_util::Stream; +use spotifyio_model::Page; + +use crate::Error; + +/// Alias for `futures::stream::Stream`, 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 new file mode 100644 index 0000000..8793975 --- /dev/null +++ b/crates/spotifyioweb/src/pool.rs @@ -0,0 +1,275 @@ +use std::time::Duration; + +use governor::{clock::Clock, DefaultDirectRateLimiter, Jitter, Quota}; + +use parking_lot::Mutex; +use rand::Rng; + +#[derive(Debug, thiserror::Error)] +pub enum PoolError { + #[error("no clients available")] + Empty, + #[error("timeout: {0:?}")] + Timeout(Duration), +} + +pub struct ClientPool { + 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 new file mode 100644 index 0000000..250f045 --- /dev/null +++ b/crates/spotifyioweb/src/session.rs @@ -0,0 +1,142 @@ +use std::sync::{Arc, Weak}; + +use once_cell::sync::OnceCell; +use reqwest::Client; +use spotifyio_model::FileId; + +use crate::{ + apresolve::ApResolver, + cache::SessionCache, + spclient::{RequestStrategy, SpClient}, +}; + +#[derive(Clone, Debug)] +pub struct SessionConfig { + pub client_id: String, + pub language: String, + pub request_strategy: RequestStrategy, + pub pagination_chunks: u32, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + client_id: crate::util::SPOTIFY_CLIENT_ID.to_owned(), + language: "en".to_owned(), + request_strategy: RequestStrategy::TryTimes(10), + pagination_chunks: 50, + } + } +} + +struct SessionInternal { + config: SessionConfig, + cache: SessionCache, + + http_client: Client, + + apresolver: OnceCell, + 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 new file mode 100644 index 0000000..976b610 --- /dev/null +++ b/crates/spotifyioweb/src/spclient.rs @@ -0,0 +1,2248 @@ +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 new file mode 100644 index 0000000..2fa2dea --- /dev/null +++ b/crates/spotifyioweb/src/spotify_pool.rs @@ -0,0 +1,105 @@ +use std::{num::NonZeroU32, path::PathBuf, sync::Arc, time::Duration}; + +use bytes::Bytes; +use governor::{Jitter, Quota}; + +use crate::{ + pool::{ClientPool, PoolError}, + spclient::SpClient, + ApplicationCache, Error, Session, SessionConfig, +}; + +#[derive(Clone)] +pub struct SpotifyIoPool { + inner: Arc, +} + +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 new file mode 100644 index 0000000..cffdab5 --- /dev/null +++ b/crates/spotifyioweb/src/totp.rs @@ -0,0 +1,88 @@ +use std::{collections::BTreeMap, io::Write}; + +use hmac::{Hmac, Mac}; +use reqwest::Client; +use sha1::Sha1; +use time::OffsetDateTime; + +use crate::Error; + +const SPOTIFY_SECRETS_JSON: &str = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretDict.json"; + +#[derive(Debug, Clone)] +pub struct Totp { + version: u32, + secret: Vec, + 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 new file mode 100644 index 0000000..b8c234d --- /dev/null +++ b/crates/spotifyioweb/src/util.rs @@ -0,0 +1,111 @@ +//! Additional utilities for interacting with Spotify + +use crate::pb::AudioFileFormat; + +pub type SocketAddress = (String, u16); + +/// User agent for HTTP requests +pub const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"; + +/// Spotify app version +pub const SPOTIFY_VERSION: &str = "1.2.74.275.g610d7cec"; + +/// Spotify client ID +pub const SPOTIFY_CLIENT_ID: &str = "d8a5ed958d274c2e8ee717e6a4b0971d"; + +/// AES128 initialization vector for audio decryption +pub const AUDIO_AESIV: [u8; 16] = [ + 0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64, 0x3f, 0x63, 0x0d, 0x93, +]; + +pub fn audio_format_extension(format: AudioFileFormat) -> Option<&'static str> { + match format { + AudioFileFormat::OGG_VORBIS_96 + | AudioFileFormat::OGG_VORBIS_160 + | AudioFileFormat::OGG_VORBIS_320 => Some(".ogg"), + AudioFileFormat::MP3_96 + | AudioFileFormat::MP3_160 + | AudioFileFormat::MP3_160_ENC + | AudioFileFormat::MP3_256 + | AudioFileFormat::MP3_320 => Some(".mp3"), + AudioFileFormat::AAC_24 + | AudioFileFormat::AAC_48 + | AudioFileFormat::AAC_160 + | AudioFileFormat::AAC_320 => Some(".aac"), + AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256 => Some(".m4a"), + AudioFileFormat::FLAC_FLAC => Some(".flac"), + AudioFileFormat::UNKNOWN_FORMAT => None, + } +} + +pub fn audio_format_mime(format: AudioFileFormat) -> Option<&'static str> { + match format { + AudioFileFormat::OGG_VORBIS_96 + | AudioFileFormat::OGG_VORBIS_160 + | AudioFileFormat::OGG_VORBIS_320 => Some("audio/ogg"), + AudioFileFormat::MP3_96 + | AudioFileFormat::MP3_160 + | AudioFileFormat::MP3_160_ENC + | AudioFileFormat::MP3_256 + | AudioFileFormat::MP3_320 => Some("audio/mp3"), + AudioFileFormat::AAC_24 + | AudioFileFormat::AAC_48 + | AudioFileFormat::AAC_160 + | AudioFileFormat::AAC_320 => Some("audio/aac"), + AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256 => Some("audio/mp4"), + AudioFileFormat::FLAC_FLAC => Some("audio/flac"), + AudioFileFormat::UNKNOWN_FORMAT => None, + } +} + +/// Get the bitrate of the audio format in kbit/s +pub fn audio_format_bitrate(format: AudioFileFormat) -> Option { + 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 new file mode 100644 index 0000000..8904df7 --- /dev/null +++ b/crates/spotifyioweb/src/web_model.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct ClientTokenRequest<'a> { + pub client_data: ClientTokenRequestData<'a>, +} + +#[derive(Debug, Serialize)] +pub struct ClientTokenRequestData<'a> { + pub client_version: &'a str, + pub client_id: &'a str, + pub js_sdk_data: ClientTokenRequestJsData<'a>, +} + +#[derive(Debug, Serialize)] +pub struct ClientTokenRequestJsData<'a> { + pub device_brand: &'a str, + pub device_model: &'a str, + pub os: &'a str, + pub device_id: String, + pub device_type: &'a str, +} + +impl Default for ClientTokenRequest<'_> { + fn default() -> Self { + Self { + client_data: ClientTokenRequestData { + client_version: crate::util::SPOTIFY_VERSION, + client_id: crate::util::SPOTIFY_CLIENT_ID, + js_sdk_data: ClientTokenRequestJsData { + device_brand: "unknown", + device_model: "unknown", + os: "linux", + device_id: uuid::Uuid::new_v4().as_simple().to_string(), + device_type: "computer", + }, + }, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct ClientTokenResponse { + pub granted_token: ClientTokenResponseToken, +} + +#[derive(Debug, Deserialize)] +pub struct ClientTokenResponseToken { + pub token: String, + pub expires_after_seconds: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerTimeResponse { + pub server_time: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthTokenResponse { + pub access_token: String, + pub access_token_expiration_timestamp_ms: u64, +}