Compare commits

...

5 commits

33 changed files with 5573 additions and 767 deletions

View file

@ -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" }

View file

@ -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 }

View file

@ -13,7 +13,6 @@ use std::{
time::{Duration, Instant},
};
use aes::cipher::{KeyIvInit, StreamCipher};
use futures_util::{stream, StreamExt, TryStreamExt};
use indicatif::{MultiProgress, ProgressBar};
use itertools::Itertools;
@ -29,17 +28,21 @@ use model::{
};
use once_cell::sync::Lazy;
use path_macro::path;
use reqwest::header;
use serde::Serialize;
use spotifyio::{
use spotifyioweb::{
model::{
AlbumId, ArtistId, EpisodeId, FileId, Id, IdConstruct, IdError, PlayableId, PlaylistId,
TrackId,
},
pb::AudioFileFormat,
AudioKey, CdnUrl, Error as SpotifyError, NormalisationData, NotModifiedRes, PoolConfig,
PoolError, Quota, Session, SessionConfig, SpotifyIoPool,
Error as SpotifyError, NotModifiedRes, PoolConfig, PoolError, Quota, Session, SessionConfig,
SpotifyIoPool,
};
use spotifyioweb::{
pb::metadata::{Album, Availability, Restriction, Track},
CdnUrl, NormalisationData,
};
use spotifyio_protocol::metadata::{Album, Availability, Restriction, Track};
use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, SqlitePool};
use time::OffsetDateTime;
@ -48,7 +51,7 @@ use widevine::{Cdm, Pssh, ServiceCertificate};
pub mod model;
type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
// type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
const DOT_SEPARATOR: &str = "";
@ -56,8 +59,6 @@ const DOT_SEPARATOR: &str = " • ";
pub enum Error {
#[error("Spotify: {0}")]
Spotify(#[from] SpotifyError),
#[error("OAuth: {0}")]
OAuth(#[from] spotifyio::oauth::OAuthError),
#[error("DB: {0}")]
Db(#[from] sqlx::Error),
#[error("File IO: {0}")]
@ -110,7 +111,6 @@ pub struct SpotifyDownloaderInner {
mp4decryptor: Option<Mp4Decryptor>,
cdm: Option<Cdm>,
apply_tags: bool,
format_m4a: bool,
widevine_cert: RwLock<Option<(ServiceCertificate, Instant)>>,
}
@ -127,9 +127,9 @@ pub enum Mp4Decryptor {
impl Mp4Decryptor {
pub fn from_env() -> Option<Self> {
if let Ok(mp4decrypt) = which::which("mp4decrypt") {
return Some(Self::Mp4decrypt(mp4decrypt));
}
// if let Ok(mp4decrypt) = which::which("mp4decrypt") {
// return Some(Self::Mp4decrypt(mp4decrypt));
// }
if let Ok(ffmpeg) = which::which("ffmpeg") {
return Some(Self::Ffmpeg(ffmpeg));
}
@ -215,7 +215,7 @@ pub fn album_folder(
}
pub fn audio_fname(name: &str, track_nr: u32, format: AudioFileFormat) -> String {
let ext = spotifyio::util::audio_format_extension(format).expect("unknown file format");
let ext = spotifyioweb::util::audio_format_extension(format).expect("unknown file format");
better_filenamify(&format!("{:02} {}", track_nr, name,), Some(ext))
}
@ -253,14 +253,14 @@ impl From<PoolError> for Error {
}
enum AudioKeyVariant {
Clearkey(AudioKey),
// Clearkey(AudioKey),
Widevine(widevine::Key),
}
impl std::fmt::Display for AudioKeyVariant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let key = match self {
AudioKeyVariant::Clearkey(audio_key) => audio_key.0.as_slice(),
// AudioKeyVariant::Clearkey(audio_key) => audio_key.0.as_slice(),
AudioKeyVariant::Widevine(key) => key.key.as_slice(),
};
data_encoding::HEXLOWER.encode_write(key, f)
@ -282,13 +282,12 @@ pub struct SpotifyDownloaderConfig {
pub widevine_device: Option<PathBuf>,
pub mp4decryptor: Option<Mp4Decryptor>,
pub apply_tags: bool,
pub format_m4a: bool,
}
impl Default for SpotifyDownloaderConfig {
fn default() -> Self {
Self {
cache_path: path!("spotifyio.json"),
cache_path: path!("spotifyioweb.json"),
quota: Quota::per_minute(NonZeroU32::new(1).unwrap())
.allow_burst(NonZeroU32::new(3).unwrap()),
db_path: path!("tracks.db"),
@ -297,7 +296,6 @@ impl Default for SpotifyDownloaderConfig {
widevine_device: None,
mp4decryptor: Mp4Decryptor::from_env(),
apply_tags: true,
format_m4a: false,
}
}
}
@ -334,7 +332,6 @@ impl SpotifyDownloader {
cdm,
mp4decryptor: cfg.mp4decryptor,
apply_tags: cfg.apply_tags,
format_m4a: cfg.format_m4a,
widevine_cert: RwLock::new(None),
}
.into(),
@ -342,7 +339,6 @@ impl SpotifyDownloader {
}
pub async fn shutdown(&self) {
self.i.sp.shutdown();
self.i.pool.close().await;
}
@ -445,24 +441,7 @@ impl SpotifyDownloader {
audio_item.track_id
);
let formats_m4a = [
AudioFileFormat::MP4_128,
AudioFileFormat::OGG_VORBIS_160,
AudioFileFormat::MP3_160,
AudioFileFormat::OGG_VORBIS_96,
AudioFileFormat::MP3_96,
];
let formats_default = [
AudioFileFormat::OGG_VORBIS_160,
AudioFileFormat::MP3_160,
AudioFileFormat::OGG_VORBIS_96,
AudioFileFormat::MP3_96,
];
let formats = if self.i.format_m4a {
formats_m4a.as_slice()
} else {
formats_default.as_slice()
};
let formats = &[AudioFileFormat::MP4_128];
let (format, file_id) = formats
.iter()
@ -475,26 +454,8 @@ impl SpotifyDownloader {
Ok((audio_item, file_id, format))
}
async fn get_file_key_clearkey(
&self,
spotify_id: PlayableId<'_>,
file_id: FileId,
) -> Result<AudioKey, Error> {
tracing::debug!("getting clearkey for {file_id}");
let key = self
.i
.sp
.audio_key()
.await?
.request(spotify_id, &file_id)
.await
.map_err(|e| Error::AudioKey(e.to_string()))?;
Ok(key)
}
async fn get_file_key_widevine(&self, file_id: FileId) -> Result<widevine::Key, Error> {
tracing::debug!("getting widevine key for {file_id}");
tracing::debug!("getting widevine key for file:{file_id}");
let cdm = self
.i
.cdm
@ -510,7 +471,11 @@ impl SpotifyDownloader {
.open()
.set_service_certificate(cert)?
.get_license_request(pssh, widevine::LicenseType::STREAMING)?;
let license = sp.get_widevine_license(request.challenge()?, false).await?;
let license = self
.i
.sp
.get_widevine_license(request.challenge()?, false)
.await?;
let keys = request.get_keys(&license)?;
let key = keys
.of_type(widevine::KeyType::CONTENT)
@ -521,18 +486,15 @@ impl SpotifyDownloader {
async fn get_file_key(
&self,
spotify_id: PlayableId<'_>,
file_id: FileId,
format: AudioFileFormat,
) -> Result<AudioKeyVariant, Error> {
if matches!(format, AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256) {
if spotifyioweb::util::audio_format_widevine(format) {
Ok(AudioKeyVariant::Widevine(
self.get_file_key_widevine(file_id).await?,
))
} else {
Ok(AudioKeyVariant::Clearkey(
self.get_file_key_clearkey(spotify_id, file_id).await?,
))
Err(Error::AudioKey("clearkey unsupported".to_owned()))
}
}
@ -628,7 +590,7 @@ impl SpotifyDownloader {
audio_url: &str,
audio_key: &AudioKeyVariant,
dest: &Path,
) -> Result<(Option<NormalisationData>, u64), Error> {
) -> Result<u64, Error> {
let tpath_tmp = dest.with_extension("tmp");
let mut res = self
@ -641,13 +603,13 @@ impl SpotifyDownloader {
.await?
.error_for_status()?;
let mut file_size = res
let file_size = res
.content_length()
.ok_or_else(|| SpotifyError::failed_precondition("no file size"))?;
let mut file = tokio::fs::File::create(&tpath_tmp).await?;
let mut norm_data = None;
match audio_key {
/*
AudioKeyVariant::Clearkey(audio_key) => {
let mut stream = res.bytes_stream();
file_size -= 167;
@ -673,6 +635,7 @@ impl SpotifyDownloader {
let h = spotify_header.ok_or(SpotifyError::failed_precondition("no header"))?;
norm_data = Some(NormalisationData::parse_from_ogg(&mut Cursor::new(&h))?);
}
*/
AudioKeyVariant::Widevine(key) => {
let decryptor = self
.i
@ -685,12 +648,37 @@ impl SpotifyDownloader {
}
drop(file);
tracing::debug!("widevine key: {:?}", key);
decryptor.decrypt(&tpath_tmp, dest, key)?;
std::fs::remove_file(&tpath_tmp)?;
}
}
Ok((norm_data, file_size))
Ok(file_size)
}
async fn get_norm_data(
&self,
audio_files: &AudioFiles,
) -> Result<Option<NormalisationData>, Error> {
if let Some(file_id) = audio_files.0.get(&AudioFileFormat::OGG_VORBIS_320) {
let sess = self.i.sp.session()?;
let head_url = sess.head_file_url(file_id);
let res = sess
.http_client()
.get(head_url)
.header(header::RANGE, "bytes=0-166")
.send()
.await?
.error_for_status()?;
let head_data = res.bytes().await?;
Ok(Some(NormalisationData::parse_from_ogg(&mut Cursor::new(
head_data,
))?))
} else {
Ok(None)
}
}
#[tracing::instrument(level = "error", skip(self, force))]
@ -717,7 +705,7 @@ impl SpotifyDownloader {
.resolve_audio(self.i.sp.session()?)
.await?;
let audio_url = &cdn_url.try_get_url()?.0;
let key = self.get_file_key(pid, file_id, audio_format).await?;
let key = self.get_file_key(file_id, audio_format).await?;
let track_fields = if let UniqueFields::Track(f) = &sp_item.unique_fields {
f
@ -795,8 +783,9 @@ impl SpotifyDownloader {
std::fs::create_dir_all(&album_dir)?;
// Download the file
let (norm_data, file_size) = self.download_audio_file(audio_url, &key, &tpath).await?;
let file_size = file_size as i64;
let file_size = self.download_audio_file(audio_url, &key, &tpath).await? as i64;
let norm_data = self.get_norm_data(&sp_item.files).await?;
// Download album cover
let cover_path = path!(album_dir / "cover.jpg");
@ -1561,3 +1550,33 @@ fn album_n_discs(album: &Album) -> u32 {
.max()
.unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::{SpotifyDownloader, SpotifyDownloaderConfig};
use path_macro::path;
use spotifyioweb::model::{IdConstruct, PlayableId, TrackId};
fn get_dl() -> SpotifyDownloader {
SpotifyDownloader::new(SpotifyDownloaderConfig {
cache_path: path!(
env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyioweb.json"
),
..Default::default()
})
.unwrap()
}
#[tokio::test]
async fn norm_data() {
let dl = get_dl();
let track = dl
.get_audio_item(PlayableId::Track(
TrackId::from_id("4sVrgFJLB3PCD9WvIsUH5j").unwrap(),
))
.await
.unwrap();
let norm_data = dl.get_norm_data(&track.files).await.unwrap().unwrap();
dbg!(norm_data);
}
}

View file

@ -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(())

View file

@ -1,7 +1,7 @@
use std::{collections::HashMap, fmt::Debug};
use spotifyio::{model::FileId, pb::AudioFileFormat};
use spotifyio_protocol::metadata::AudioFile as AudioFileMessage;
use spotifyioweb::pb::metadata::AudioFile as AudioFileMessage;
use spotifyioweb::{model::FileId, pb::AudioFileFormat};
#[derive(Debug, Clone, Default)]
pub struct AudioFiles(pub HashMap<AudioFileFormat, FileId>);

View file

@ -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>;

View file

@ -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;

View file

@ -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"

View file

@ -135,17 +135,22 @@ impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {
}
}
}
} else if let Some(token) = url
} else if let Some((_, token)) = url
.query_pairs()
.into_iter()
.find(|(key, _value)| key == "Expires")
{
//"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=",
if let Some(end) = token.1.find('~') {
if let Some(end) = token.find('~') {
// this is the only valid invariant for spotifycdn.com
let slice = &token.1[..end];
let slice = &token[..end];
expiry_str = Some(String::from(&slice[..end]));
}
} else if let Some((_, token)) = url.query_pairs().into_iter().find(|(key, _value)| key == "verify") {
// https://audio-cf.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?verify=1688165560-0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=
if let Some(first) = token.split('-').next() {
expiry_str = Some(String::from(first));
}
} else if let Some(query) = url.query() {
//"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=",
let mut items = query.split('_');
@ -189,20 +194,21 @@ mod test {
format!("https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"),
format!("https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="),
format!("https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="),
format!("https://audio-cf.spotifycdn.com/audio/6a28ff353c2da587bfc1005064bbf4913c7fdc9e?verify={timestamp}-6WJMBXvDIB22xKy9G80aAFHQ2%2F%2BBv4bha1FTcJ4Zuy4%3D"),
"https://audio4-fa.scdn.co/foo?baz".to_string(),
];
msg.fileid = vec![0];
let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls");
assert_eq!(urls.len(), 4);
assert!(urls[0].1.is_some());
assert!(urls[1].1.is_some());
assert!(urls[2].1.is_some());
assert!(urls[3].1.is_none());
assert_eq!(urls.len(), 5);
assert!(urls[4].1.is_none());
let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN;
assert_eq!(
urls[0].1.unwrap().unix_timestamp(),
timestamp_margin.whole_seconds()
);
for i in 0..4 {
assert_eq!(
urls[i].1.unwrap().unix_timestamp(),
timestamp_margin.whole_seconds()
);
}
}
}

View file

@ -441,7 +441,7 @@ impl From<Utf8Error> for Error {
impl From<PoolError> for Error {
fn from(value: PoolError) -> Self {
match &value {
PoolError::Empty => Self::invalid_argument(value),
PoolError::Empty => Self::invalid_argument("No spotify clients"),
PoolError::Timeout(_) => Self::resource_exhausted(value),
}
}

View file

@ -3,7 +3,7 @@
use std::num::NonZeroU32;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DefaultOnError, DisplayFromStr};
use serde_with::{serde_as, DefaultOnError, DisplayFromStr, VecSkipError};
use time::OffsetDateTime;
use spotifyio_model::{
@ -18,14 +18,35 @@ pub(crate) struct LyricsWrap {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ItemWrap<T> {
pub item: T,
pub struct GqlWrap<T> {
pub data: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GqlWrap<T> {
pub data: T,
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum ArtistOption {
Artist(ArtistItem),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum AlbumOption {
Album(AlbumItem),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum TrackOption {
Track(TrackItem),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -375,20 +396,52 @@ pub(crate) struct SearchResultWrap {
pub search_v2: GqlSearchResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum MatchedField {
Lyrics,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GqlSearchTrackWrap {
pub item: GqlWrap<TrackOption>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub matched_fields: Vec<MatchedField>,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChipOrder {
#[serde_as(as = "VecSkipError<_>")]
pub items: Vec<Chip>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chip {
type_name: SearchItemType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct GqlSearchResult {
#[serde(default)]
pub artists: GqlPagination<GqlWrap<ArtistItem>>,
pub artists: GqlPagination<GqlWrap<ArtistOption>>,
#[serde(default)]
pub albums_v2: GqlPagination<AlbumItemWrap>,
#[serde(default)]
pub tracks_v2: GqlPagination<ItemWrap<GqlWrap<TrackItem>>>,
pub tracks_v2: GqlPagination<GqlSearchTrackWrap>,
#[serde(default)]
pub playlists: GqlPagination<GqlWrap<PlaylistOption>>,
#[serde(default)]
pub users: GqlPagination<GqlWrap<UserItem>>,
pub chip_order: ChipOrder,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -482,7 +535,8 @@ pub struct ConcertOfferDates {
pub end_date_iso_string: OffsetDateTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SearchItemType {
Artists,
Albums,
@ -558,8 +612,8 @@ pub struct Seektable {
pub encoder_delay_samples: u32,
pub pssh: String,
pub timescale: u32,
#[serde(alias = "init_range")]
pub index_range: (u32, u32),
pub init_range: Option<(u32, u32)>,
pub index_range: Option<(u32, u32)>,
pub segments: Vec<(u32, u32)>,
pub offset: usize,
}
@ -603,6 +657,24 @@ pub struct PlaylistWrap {
pub playlist_v2: PlaylistOption,
}
impl ArtistOption {
pub fn into_option(self) -> Option<ArtistItem> {
match self {
ArtistOption::Artist(artist_item) => Some(artist_item),
ArtistOption::NotFound => None,
}
}
}
impl TrackOption {
pub fn into_option(self) -> Option<TrackItem> {
match self {
TrackOption::Track(track_item) => Some(track_item),
TrackOption::NotFound => None,
}
}
}
impl PlaylistOption {
pub fn into_option(self) -> Option<GqlPlaylistItem> {
match self {
@ -621,6 +693,18 @@ impl ConcertOption {
}
}
impl From<GqlWrap<ArtistOption>> for Option<ArtistItem> {
fn from(value: GqlWrap<ArtistOption>) -> Self {
value.data.into_option()
}
}
impl From<GqlWrap<TrackOption>> for Option<TrackItem> {
fn from(value: GqlWrap<TrackOption>) -> Self {
value.data.into_option()
}
}
impl From<GqlWrap<PlaylistOption>> for Option<GqlPlaylistItem> {
fn from(value: GqlWrap<PlaylistOption>) -> Self {
value.data.into_option()

View file

@ -70,6 +70,9 @@ pub mod pb {
pub use spotifyio_protocol::*;
}
/// Version of the SpotifyIO crate
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Clone)]
pub struct SpotifyIoPool {
inner: Arc<SpotifyIoPoolInner>,

View file

@ -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) {

View file

@ -197,8 +197,9 @@ impl SpClient {
}
pub async fn base_url(&self) -> Result<String, Error> {
let ap = self.get_accesspoint().await?;
Ok(format!("https://{}:{}", ap.0, ap.1))
// let ap = self.get_accesspoint().await?;
// Ok(format!("https://{}:{}", ap.0, ap.1))
Ok("https://spclient.wg.spotify.com".to_owned())
}
pub async fn client_token(&self) -> Result<String, Error> {
@ -498,10 +499,7 @@ impl SpClient {
}
#[tracing::instrument("spclient", level = "error", skip_all, fields(usr = self.session().user_id()))]
pub async fn _request_generic(
&self,
p: RequestParams<'_>,
) -> Result<EtagResponse<Bytes>, Error> {
async fn _request_generic(&self, p: RequestParams<'_>) -> Result<EtagResponse<Bytes>, Error> {
let mut tries: usize = 0;
let mut last_error: Error;
let mut retry_after: Option<u64> = None;
@ -532,6 +530,7 @@ impl SpClient {
.http_client()
.request(p.method.clone(), url)
.bearer_auth(auth_token)
// .bearer_auth("BQDdwSxvV2qf5GqQJY4t37YcRePJPkQ2hv_rwXJLsR6NCsCb0CFJaW0Ecs9paIikmbM0VG3k7E6K7ufkYaF_ut4VX0Q6QoC6SaU7YXqBCxn0ZGUG9iTLhjXqIw7n2iomrNb8r_3Vyk3xeYWqZmJymXokEjIjgr0FBKT69djnemIxAtm3YSK54fH1QPw1AwqvXooIqIUPMOwskz_a-gUE4n_YWzzSiHJQ38Kw8dzRLa7wMcoy8PO_tchWcofvdRhGw2IglFr4x2xjaFqozoPTaQsLCo1vdKYPUN8xr8xF7Ls76SU8YEFHP3krH0krNgpolyNbPR1fd4jJg3T7Mpfd08mtxVd4rMCNpa683HWJSHI4rtMJGaaSx2zx-4KrklA_8w")
.header(header::ACCEPT_LANGUAGE, &self.session().config().language);
if let Some(content_type) = p.content_type {
@ -620,7 +619,7 @@ impl SpClient {
ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => {
warn!("API error: {last_error} (retrying)");
// Keep trying the current access point three times before dropping it.
if tries % 3 == 0 {
if tries.is_multiple_of(3) {
self.flush_accesspoint().await
}
}
@ -644,7 +643,7 @@ impl SpClient {
Err(last_error)
}
pub async fn request_pb<O: Message>(
async fn request_pb<O: Message>(
&self,
method: Method,
endpoint: &str,
@ -662,7 +661,7 @@ impl SpClient {
Ok(O::parse_from_bytes(&res.data)?)
}
pub async fn request_get_pb<O: Message>(
async fn request_get_pb<O: Message>(
&self,
endpoint: &str,
if_none_match: Option<&str>,
@ -954,9 +953,9 @@ impl SpClient {
Ok(resp)
}
pub async fn get_audio_preview(&self, preview_id: &FileId) -> Result<Response, Error> {
debug!("getting audio preview {preview_id}");
let mut url = self.session().audio_preview_url(preview_id);
pub async fn get_audio_preview(&self, file_id: &FileId) -> Result<Response, Error> {
debug!("getting audio preview for file:{file_id}");
let mut url = self.session().audio_preview_url(file_id);
let separator = match url.find('?') {
Some(_) => "&",
None => "?",
@ -973,9 +972,22 @@ impl SpClient {
Ok(resp)
}
pub async fn get_head_file(&self, file_id: &FileId) -> Result<Response, Error> {
debug!("getting head file for file:{file_id}");
let resp = self
.session()
.http_client()
.get(self.session().head_file_url(file_id))
.send()
.await?
.error_for_status()?;
Ok(resp)
}
/// Get the seektable required for streaming AAC tracks
pub async fn get_seektable(&self, file_id: &FileId) -> Result<Seektable, Error> {
debug!("getting seektable {file_id}");
debug!("getting seektable for file:{file_id}");
let url = format!(
"https://seektables.scdn.co/seektable/{}.json",
file_id.base16()
@ -2301,6 +2313,7 @@ mod tests {
}
#[tokio::test]
#[tracing_test::traced_test]
async fn search() {
let s = conn().await;
let search = s

View file

@ -1,617 +0,0 @@
use std::{borrow::Cow, fmt};
use serde::{de::Visitor, Deserialize, Serialize};
use crate::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpotifyItemType {
Album,
Artist,
Episode,
Playlist,
User,
Show,
Track,
Local,
Concert,
Prerelease,
Unknown,
}
impl From<&str> for SpotifyItemType {
fn from(v: &str) -> Self {
match v {
"album" => Self::Album,
"artist" => Self::Artist,
"episode" => Self::Episode,
"playlist" => Self::Playlist,
"user" => Self::User,
"show" => Self::Show,
"track" => Self::Track,
"concert" => Self::Concert,
"prerelease" => Self::Prerelease,
_ => Self::Unknown,
}
}
}
impl From<SpotifyItemType> for &str {
fn from(item_type: SpotifyItemType) -> &'static str {
match item_type {
SpotifyItemType::Album => "album",
SpotifyItemType::Artist => "artist",
SpotifyItemType::Episode => "episode",
SpotifyItemType::Playlist => "playlist",
SpotifyItemType::User => "user",
SpotifyItemType::Show => "show",
SpotifyItemType::Track => "track",
SpotifyItemType::Local => "local",
SpotifyItemType::Concert => "concert",
SpotifyItemType::Prerelease => "prerelease",
SpotifyItemType::Unknown => "unknown",
}
}
}
impl std::fmt::Display for SpotifyItemType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str((*self).into())
}
}
impl SpotifyItemType {
fn uses_textual_id(self) -> bool {
matches!(self, Self::User | Self::Local)
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SpotifyId {
id: SpotifyIdInner,
item_type: SpotifyItemType,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum SpotifyIdInner {
/// Numeric Spotify IDs (either base62 or base16-encoded)
Numeric(u128),
/// Textual Spotify IDs (used only for user IDs, since they may be the username for older accounts)
Textual(String),
}
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
pub enum SpotifyIdError {
#[error("ID cannot be parsed")]
InvalidId,
#[error("not a valid Spotify URI")]
InvalidFormat,
#[error("URI does not belong to Spotify")]
InvalidRoot,
}
impl From<SpotifyIdError> for Error {
fn from(err: SpotifyIdError) -> Self {
Error::invalid_argument(err)
}
}
const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef";
impl SpotifyId {
const SIZE: usize = 16;
const SIZE_BASE16: usize = 32;
const SIZE_BASE62: usize = 22;
/// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`.
///
/// `src` is expected to be 32 bytes long and encoded using valid characters.
///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
pub fn from_base16(src: &str, item_type: SpotifyItemType) -> Result<Self, SpotifyIdError> {
if src.len() != 32 {
return Err(SpotifyIdError::InvalidId.into());
}
let mut dst: u128 = 0;
for c in src.as_bytes() {
let p = match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => c - b'a' + 10,
_ => return Err(SpotifyIdError::InvalidId.into()),
} as u128;
dst <<= 4;
dst += p;
}
Ok(Self {
id: SpotifyIdInner::Numeric(dst),
item_type,
})
}
/// Parses a base62 encoded [Spotify ID] into a `u128`.
///
/// `src` is expected to be 22 bytes long and encoded using valid characters.
///
/// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
pub fn from_base62(src: &str, item_type: SpotifyItemType) -> Result<Self, SpotifyIdError> {
if src.len() != 22 {
return Err(SpotifyIdError::InvalidId.into());
}
let mut dst: u128 = 0;
for c in src.as_bytes() {
let p = match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'z' => c - b'a' + 10,
b'A'..=b'Z' => c - b'A' + 36,
_ => return Err(SpotifyIdError::InvalidId.into()),
} as u128;
dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?;
dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?;
}
Ok(Self {
id: SpotifyIdInner::Numeric(dst),
item_type,
})
}
/// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order.
///
/// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`.
pub fn from_raw(src: &[u8], item_type: SpotifyItemType) -> Result<Self, SpotifyIdError> {
match src.try_into() {
Ok(dst) => Ok(Self {
id: SpotifyIdInner::Numeric(u128::from_be_bytes(dst)),
item_type,
}),
Err(_) => Err(SpotifyIdError::InvalidId.into()),
}
}
/// Parses a [Spotify URI] into a `SpotifyId`.
///
/// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}`
/// can be arbitrary while `{id}` is a 22-character long, base62 encoded Spotify ID.
///
/// Note that this should not be used for playlists, which have the form of
/// `spotify:playlist:{id}`.
///
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
pub fn from_uri(src: &str) -> Result<Self, SpotifyIdError> {
// Basic: `spotify:{type}:{id}`
// Named: `spotify:user:{user}:{type}:{id}`
let mut parts = src.splitn(3, ':');
let scheme = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
let item_type = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
let id = parts.next().ok_or(SpotifyIdError::InvalidFormat)?;
if scheme != "spotify" {
return Err(SpotifyIdError::InvalidRoot.into());
}
let item_type = SpotifyItemType::from(item_type);
if item_type.uses_textual_id() {
if id.is_empty() {
return Err(SpotifyIdError::InvalidId.into());
}
Ok(Self {
id: SpotifyIdInner::Textual(id.to_owned()),
item_type,
})
} else {
if id.len() != Self::SIZE_BASE62 {
return Err(SpotifyIdError::InvalidId.into());
}
Self::from_base62(id, item_type)
}
}
/// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32)
/// character long `String`.
#[allow(clippy::wrong_self_convention)]
pub fn to_base16(&self) -> Result<String, Error> {
to_base16(&self.to_raw()?, &mut [0u8; Self::SIZE_BASE16])
}
/// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22)
/// character long `String`.
///
/// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
#[allow(clippy::wrong_self_convention)]
pub fn to_base62<'a>(&'a self) -> Result<Cow<'a, str>, Error> {
match &self.id {
SpotifyIdInner::Numeric(n) => {
let mut dst = [0u8; 22];
let mut i = 0;
// The algorithm is based on:
// https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c
//
// We are not using naive division of self.id as it is an u128 and div + mod are software
// emulated at runtime (and unoptimized into mul + shift) on non-128bit platforms,
// making them very expensive.
//
// Trezor's algorithm allows us to stick to arithmetic on native registers making this
// an order of magnitude faster. Additionally, as our sizes are known, instead of
// dealing with the ID on a byte by byte basis, we decompose it into four u32s and
// use 64-bit arithmetic on them for an additional speedup.
for shift in &[96, 64, 32, 0] {
let mut carry = (n >> shift) as u32 as u64;
for b in &mut dst[..i] {
carry += (*b as u64) << 32;
*b = (carry % 62) as u8;
carry /= 62;
}
while carry > 0 {
dst[i] = (carry % 62) as u8;
carry /= 62;
i += 1;
}
}
for b in &mut dst {
*b = BASE62_DIGITS[*b as usize];
}
dst.reverse();
String::from_utf8(dst.to_vec())
.map(Cow::Owned)
.map_err(|_| SpotifyIdError::InvalidId.into())
}
SpotifyIdInner::Textual(id) => Ok(id.into()),
}
}
/// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in
/// big-endian order.
#[allow(clippy::wrong_self_convention)]
pub fn to_raw(&self) -> Result<[u8; Self::SIZE], Error> {
match &self.id {
SpotifyIdInner::Numeric(n) => Ok(n.to_be_bytes()),
SpotifyIdInner::Textual(_) => Err(Error::invalid_argument(
"textual IDs have no raw representation",
)),
}
}
/// Returns the `SpotifyId` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`,
/// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded
/// Spotify ID.
///
/// If the `SpotifyId` has an associated type unrecognized by the library, `{type}` will
/// be encoded as `unknown`.
///
/// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids
#[allow(clippy::wrong_self_convention)]
pub fn to_uri(&self) -> Result<String, Error> {
// 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31
// + unknown size item_type.
let item_type: &str = self.item_type.into();
let mut dst = String::with_capacity(31 + item_type.len());
dst.push_str("spotify:");
dst.push_str(item_type);
dst.push(':');
let base_62 = self.to_base62()?;
dst.push_str(&base_62);
Ok(dst)
}
pub(crate) fn to_uri_urlenc(&self) -> Result<String, Error> {
let item_type: &str = self.item_type.into();
let mut dst = String::with_capacity(31 + item_type.len());
dst.push_str("spotify%3A");
dst.push_str(item_type);
dst.push_str("%3A");
let base_62 = self.to_base62()?;
dst.push_str(&base_62);
Ok(dst)
}
}
impl fmt::Debug for SpotifyId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("SpotifyId")
.field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
.finish()
}
}
impl fmt::Display for SpotifyId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into()))
}
}
impl Serialize for SpotifyId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_uri().map_err(serde::ser::Error::custom)?)
}
}
impl<'de> Deserialize<'de> for SpotifyId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SpotifyIdVisitor;
impl<'de> Visitor<'de> for SpotifyIdVisitor {
type Value = SpotifyId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("Spotify URI")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
SpotifyId::from_uri(v).map_err(|e| serde::de::Error::custom(format!("{e}: `{v}`")))
}
}
deserializer.deserialize_str(SpotifyIdVisitor)
}
}
pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result<String, Error> {
let mut i = 0;
for v in src {
buf[i] = BASE16_DIGITS[(v >> 4) as usize];
buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize];
i += 2;
}
String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into())
}
#[cfg(test)]
mod tests {
use super::*;
struct ConversionCase {
id: SpotifyIdInner,
kind: SpotifyItemType,
uri: &'static str,
base16: &'static str,
base62: &'static str,
raw: &'static [u8],
}
static CONV_VALID: [ConversionCase; 4] = [
ConversionCase {
id: SpotifyIdInner::Numeric(238762092608182713602505436543891614649),
kind: SpotifyItemType::Track,
uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH",
base16: "b39fe8081e1f4c54be38e8d6f9f12bb9",
base62: "5sWHDYs0csV6RS48xBl0tH",
raw: &[
179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185,
],
},
ConversionCase {
id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048),
kind: SpotifyItemType::Track,
uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4",
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
base62: "4GNcXTGWmnZ3ySrqvol3o4",
raw: &[
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
],
},
ConversionCase {
id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048),
kind: SpotifyItemType::Episode,
uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4",
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
base62: "4GNcXTGWmnZ3ySrqvol3o4",
raw: &[
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
],
},
ConversionCase {
id: SpotifyIdInner::Numeric(204841891221366092811751085145916697048),
kind: SpotifyItemType::Show,
uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4",
base16: "9a1b1cfbc6f244569ae0356c77bbe9d8",
base62: "4GNcXTGWmnZ3ySrqvol3o4",
raw: &[
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216,
],
},
];
static CONV_INVALID: [ConversionCase; 5] = [
ConversionCase {
id: SpotifyIdInner::Numeric(0),
kind: SpotifyItemType::Unknown,
// Invalid ID in the URI.
uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH",
base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9",
base62: "!!!!!Ys0csV6RS48xBl0tH",
raw: &[
// Invalid length.
154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255,
],
},
ConversionCase {
id: SpotifyIdInner::Numeric(0),
kind: SpotifyItemType::Unknown,
// Missing colon between ID and type.
uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH",
base16: "--------------------",
base62: "....................",
raw: &[
// Invalid length.
154, 27, 28, 251,
],
},
ConversionCase {
id: SpotifyIdInner::Numeric(0),
kind: SpotifyItemType::Unknown,
// Uri too short
uri: "spotify:azb:aRS48xBl0tH",
// too long, should return error but not panic overflow
base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
// too long, should return error but not panic overflow
base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
raw: &[
// Invalid length.
154, 27, 28, 251,
],
},
ConversionCase {
id: SpotifyIdInner::Numeric(0),
kind: SpotifyItemType::Unknown,
// Uri too short
uri: "spotify:azb:aRS48xBl0tH",
base16: "--------------------",
// too short to encode a 128 bits int
base62: "aa",
raw: &[
// Invalid length.
154, 27, 28, 251,
],
},
ConversionCase {
id: SpotifyIdInner::Numeric(0),
kind: SpotifyItemType::Unknown,
uri: "cleary invalid uri",
base16: "--------------------",
// too high of a value, this would need a 132 bits int
base62: "ZZZZZZZZZZZZZZZZZZZZZZ",
raw: &[
// Invalid length.
154, 27, 28, 251,
],
},
];
#[test]
fn from_base62() {
for c in &CONV_VALID {
assert_eq!(SpotifyId::from_base62(c.base62, c.kind).unwrap().id, c.id);
}
for c in &CONV_INVALID {
assert!(SpotifyId::from_base62(c.base62, c.kind).is_err(),);
}
}
#[test]
fn to_base62() {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id.clone(),
item_type: c.kind,
};
assert_eq!(id.to_base62().unwrap(), c.base62);
}
}
#[test]
fn from_base16() {
for c in &CONV_VALID {
assert_eq!(SpotifyId::from_base16(c.base16, c.kind).unwrap().id, c.id);
}
for c in &CONV_INVALID {
assert!(SpotifyId::from_base16(c.base16, c.kind).is_err(),);
}
}
#[test]
fn to_base16() {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id.clone(),
item_type: c.kind,
};
assert_eq!(id.to_base16().unwrap(), c.base16);
}
}
#[test]
fn from_uri() {
for c in &CONV_VALID {
let actual = SpotifyId::from_uri(c.uri).unwrap();
assert_eq!(actual.id, c.id);
assert_eq!(actual.item_type, c.kind);
}
for c in &CONV_INVALID {
assert!(SpotifyId::from_uri(c.uri).is_err());
}
}
#[test]
fn to_uri() {
for c in &CONV_VALID {
let id = SpotifyId {
id: c.id.clone(),
item_type: c.kind,
};
assert_eq!(id.to_uri().unwrap(), c.uri);
}
}
#[test]
fn from_raw() {
for c in &CONV_VALID {
assert_eq!(SpotifyId::from_raw(c.raw, c.kind).unwrap().id, c.id);
}
for c in &CONV_INVALID {
assert!(SpotifyId::from_raw(c.raw, c.kind).is_err());
}
}
#[test]
fn user_id() {
let id = SpotifyId::from_uri("spotify:user:fabianakhavan").unwrap();
assert_eq!(
id,
SpotifyId {
id: SpotifyIdInner::Textual("fabianakhavan".to_string()),
item_type: SpotifyItemType::User
}
);
}
#[test]
fn from_serde() {
#[derive(Serialize, Deserialize)]
struct SpotifyUriS {
uri: SpotifyId,
}
let spotify_id = SpotifyId::from_uri("spotify:artist:1EfwyuCzDQpCslZc8C9gkG").unwrap();
let json = r#"{"uri":"spotify:artist:1EfwyuCzDQpCslZc8C9gkG"}"#;
let got_id = serde_json::from_str::<SpotifyUriS>(json).unwrap().uri;
assert_eq!(got_id, spotify_id);
let got_json = serde_json::to_string(&SpotifyUriS { uri: spotify_id }).unwrap();
assert_eq!(got_json, json);
}
}

View file

@ -0,0 +1,68 @@
[package]
name = "spotifyioweb"
version = "0.0.1"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
keywords.workspace = true
categories.workspace = true
[features]
default = ["default-tls"]
# Reqwest TLS options
default-tls = ["reqwest/default-tls"]
native-tls = ["reqwest/native-tls"]
native-tls-alpn = ["reqwest/native-tls-alpn"]
native-tls-vendored = ["reqwest/native-tls-vendored"]
rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
[dependencies]
tokio = { version = "1.20.4", features = ["macros"] }
tokio-stream = "0.1"
reqwest = { version = "0.12.0", features = ["json"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3.0.0", default-features = false, features = [
"alloc",
"macros",
] }
serde_json = "1"
tracing = "0.1.0"
time = { version = "0.3.21", features = [
"serde-well-known",
"formatting",
"parsing",
] }
once_cell = "1.0"
thiserror = "2.0"
dotenvy = "0.15.7"
path_macro = "1.0"
uuid = { version = "1.0", features = ["v4"] }
bytes = "1.0"
data-encoding = "2.5"
sha1 = { version = "0.10", features = ["oid"] }
hmac = "0.12"
byteorder = "1.0"
futures-util = { version = "0.3", features = ["sink"] }
url = "2"
governor = { version = "0.10", default-features = false, features = [
"std",
"quanta",
"jitter",
] }
rand = "0.9"
urlencoding = "2.1.0"
parking_lot = "0.12.0"
async-stream = "0.3.0"
ogg_pager = "0.7.0"
protobuf.workspace = true
spotifyio-protocol.workspace = true
spotifyio-model.workspace = true
[dev-dependencies]
tracing-test = "0.2.5"
hex_lit = "0.1"
protobuf-json-mapping = "3"

View file

@ -0,0 +1,137 @@
use std::collections::VecDeque;
use serde::Deserialize;
use tracing::warn;
use crate::{util::SocketAddress, Error};
#[derive(Default)]
pub struct AccessPoints {
accesspoint: VecDeque<SocketAddress>,
dealer: VecDeque<SocketAddress>,
spclient: VecDeque<SocketAddress>,
}
#[derive(Deserialize, Default)]
pub struct ApResolveData {
accesspoint: Vec<String>,
dealer: Vec<String>,
spclient: Vec<String>,
}
impl ApResolveData {
// These addresses probably do some geo-location based traffic management or at least DNS-based
// load balancing. They are known to fail when the normal resolvers are up, so that's why they
// should only be used as fallback.
fn fallback() -> Self {
Self {
accesspoint: vec![String::from("ap.spotify.com:443")],
dealer: vec![String::from("dealer.spotify.com:443")],
spclient: vec![String::from("spclient.wg.spotify.com:443")],
}
}
}
impl AccessPoints {
fn is_any_empty(&self) -> bool {
self.accesspoint.is_empty() || self.dealer.is_empty() || self.spclient.is_empty()
}
}
component! {
ApResolver : ApResolverInner {
data: AccessPoints = AccessPoints::default(),
}
}
impl ApResolver {
fn process_ap_strings(&self, data: Vec<String>) -> VecDeque<SocketAddress> {
data.into_iter()
.filter_map(|ap| {
let mut split = ap.rsplitn(2, ':');
let port = split.next()?;
let port: u16 = port.parse().ok()?;
let host = split.next()?.to_owned();
Some((host, port))
})
.collect()
}
fn parse_resolve_to_access_points(&self, resolve: ApResolveData) -> AccessPoints {
AccessPoints {
accesspoint: self.process_ap_strings(resolve.accesspoint),
dealer: self.process_ap_strings(resolve.dealer),
spclient: self.process_ap_strings(resolve.spclient),
}
}
pub async fn try_apresolve(&self) -> Result<ApResolveData, Error> {
let data = self
.session()
.http_client()
.get("https://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient")
.send()
.await?
.error_for_status()?
.json::<ApResolveData>()
.await?;
Ok(data)
}
async fn apresolve(&self) {
let result = self.try_apresolve().await;
self.lock(|inner| {
let (data, error) = match result {
Ok(data) => (data, None),
Err(e) => (ApResolveData::default(), Some(e)),
};
inner.data = self.parse_resolve_to_access_points(data);
if inner.data.is_any_empty() {
warn!("Failed to resolve all access points, using fallbacks");
if let Some(error) = error {
warn!("Resolve access points error: {}", error);
}
let fallback = self.parse_resolve_to_access_points(ApResolveData::fallback());
inner.data.accesspoint.extend(fallback.accesspoint);
inner.data.dealer.extend(fallback.dealer);
inner.data.spclient.extend(fallback.spclient);
}
})
}
fn is_any_empty(&self) -> bool {
self.lock(|inner| inner.data.is_any_empty())
}
pub async fn resolve(&self, endpoint: &str) -> Result<SocketAddress, Error> {
if self.is_any_empty() {
self.apresolve().await;
}
self.lock(|inner| {
let access_point = match endpoint {
// take the first position instead of the last with `pop`, because Spotify returns
// access points with ports 4070, 443 and 80 in order of preference from highest
// to lowest.
"accesspoint" => inner.data.accesspoint.pop_front(),
"dealer" => inner.data.dealer.pop_front(),
"spclient" => inner.data.spclient.pop_front(),
_ => {
return Err(Error::unimplemented(format!(
"No implementation to resolve access point {endpoint}"
)))
}
};
let access_point = access_point.ok_or_else(|| {
Error::unavailable(format!("No access point available for endpoint {endpoint}"))
})?;
Ok(access_point)
})
}
}

View file

@ -0,0 +1,329 @@
use std::{
collections::HashMap,
fs::File,
io::BufReader,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{spclient::Token, Error, Session, SessionConfig};
type CacheData = HashMap<String, CachedSession>;
#[derive(Clone)]
pub struct ApplicationCache {
inner: Arc<CacheInner>,
}
struct CacheInner {
path: PathBuf,
data: RwLock<CacheData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedSession {
device_id: String,
email: Option<String>,
country: Option<String>,
is_premium: bool,
sp_dc: String,
auth_token: Option<Token>,
client_token: Option<Token>,
#[serde(with = "time::serde::rfc3339", default = "OffsetDateTime::now_utc")]
created_at: OffsetDateTime,
}
impl Default for CachedSession {
fn default() -> Self {
Self {
device_id: Uuid::new_v4().simple().to_string(),
email: None,
country: None,
is_premium: false,
sp_dc: String::new(),
auth_token: None,
client_token: None,
created_at: OffsetDateTime::now_utc(),
}
}
}
pub struct SessionCache {
inner: Arc<CacheInner>,
/// Username as cache key
user_id: String,
}
pub struct AccountMetadata {
pub user_id: String,
pub email: Option<String>,
pub created_at: OffsetDateTime,
}
impl ApplicationCache {
pub fn new(path: PathBuf) -> Self {
tracing::trace!("cache path: {path:?}");
let data = if path.is_file() {
match Self::read_file(&path) {
Ok(data) => data,
Err(e) => {
tracing::error!("could not read cache file: {e}");
CacheData::default()
}
}
} else {
CacheData::default()
};
Self {
inner: CacheInner {
path,
data: RwLock::new(data),
}
.into(),
}
}
pub fn new_session(&self, user_id: String) -> SessionCache {
SessionCache {
inner: self.inner.clone(),
user_id,
}
}
/// Get a list of device ids for all registered sessions
pub fn device_ids(&self) -> Vec<String> {
let data = self.inner.data.read().unwrap();
data.keys().cloned().collect()
}
pub fn first_session(&self) -> Result<SessionCache, Error> {
let data = self.inner.data.read().unwrap();
if let Some(user_id) = data.keys().next() {
tracing::debug!("first session: {user_id}");
Ok(SessionCache {
inner: self.inner.clone(),
user_id: user_id.to_owned(),
})
} else {
Err(Error::unauthenticated("no sessions available"))
}
}
/// Get a list of sessions for all registered accounts
pub fn sessions(&self, cfg: &SessionConfig, max_sessions: usize) -> Vec<Session> {
let data = self.inner.data.read().unwrap();
data.keys()
.take(max_sessions)
.map(|user_id| {
let cache = SessionCache {
inner: self.inner.clone(),
user_id: user_id.to_owned(),
};
Session::new(cfg.clone(), cache)
})
.collect()
}
/// Get a list of registered Spotify accounts
pub fn accounts(&self) -> Vec<AccountMetadata> {
let data = self.inner.data.read().unwrap();
let mut accounts = data
.iter()
.map(|(user_id, session)| AccountMetadata {
user_id: user_id.to_owned(),
email: session.email.clone(),
created_at: session.created_at,
})
.collect::<Vec<_>>();
accounts.sort_by_key(|a| a.created_at);
accounts
}
pub fn remove_account(&self, user_id: &str) -> Result<(), Error> {
let mut data = self.inner.data.write().unwrap();
if data.remove(user_id).is_none() {
Err(Error::not_found("account does not exist"))
} else {
drop(data);
self.inner.write_file();
Ok(())
}
}
fn read_file(path: &Path) -> Result<CacheData, Error> {
let file = File::open(path)?;
let data: CacheData = serde_json::from_reader(BufReader::new(file))?;
Ok(data)
}
}
impl CacheInner {
fn _write_file(&self) -> Result<(), Error> {
let entry = {
let x = self.data.read().unwrap();
x.clone()
};
let file = File::create(&self.path)?;
serde_json::to_writer_pretty(file, &entry)?;
Ok(())
}
fn write_file(&self) {
if let Err(e) = self._write_file() {
tracing::error!("could not write cache file: {e}");
}
}
}
impl SessionCache {
#[cfg(test)]
pub(crate) fn testing() -> Self {
use path_macro::path;
let cache = ApplicationCache::new(path!(
env!("CARGO_MANIFEST_DIR") / ".." / ".." / "data" / "spotifyioweb.json"
));
cache.first_session().unwrap()
}
pub fn get_user_id(&self) -> String {
self.user_id.to_owned()
}
pub fn read_device_id(&self) -> String {
let data = self.inner.data.read().unwrap();
if let Some(session) = data.get(&self.user_id) {
return session.device_id.to_owned();
}
let mut data = self.inner.data.write().unwrap();
let entry = data.entry(self.user_id.to_owned()).or_default();
entry.device_id.to_owned()
}
pub fn read_email(&self) -> Option<String> {
let data = self.inner.data.read().unwrap();
data.get(&self.user_id).and_then(|d| d.email.to_owned())
}
pub fn read_is_premium(&self) -> bool {
let data = self.inner.data.read().unwrap();
data.get(&self.user_id)
.map(|d| d.is_premium)
.unwrap_or_default()
}
pub fn read_country(&self) -> Option<String> {
let data = self.inner.data.read().unwrap();
data.get(&self.user_id).and_then(|d| d.country.to_owned())
}
pub fn read_sp_dc(&self) -> Option<String> {
let data = self.inner.data.read().unwrap();
data.get(&self.user_id).map(|d| d.sp_dc.clone())
}
pub fn read_auth_token(&self) -> Option<Token> {
let data = self.inner.data.read().unwrap();
data.get(&self.user_id).and_then(|d| {
d.auth_token
.as_ref()
.filter(|token| !token.is_expired())
.cloned()
})
}
pub fn read_client_token(&self) -> Option<Token> {
let data = self.inner.data.read().unwrap();
data.get(&self.user_id).and_then(|d| {
d.client_token
.as_ref()
.filter(|token| !token.is_expired())
.cloned()
})
}
pub fn write_email(&self, email: Option<String>) {
let mut data = self.inner.data.write().unwrap();
let entry = data.entry(self.user_id.to_owned()).or_default();
if entry.email != email {
entry.email = email;
drop(data);
self.inner.write_file();
}
}
pub fn write_country(&self, country: String) {
let mut data = self.inner.data.write().unwrap();
let entry = data.entry(self.user_id.to_owned()).or_default();
if entry.country.as_deref() != Some(&country) {
entry.country = Some(country);
drop(data);
self.inner.write_file();
}
}
pub fn write_is_premium(&self, is_premium: bool) {
let mut data = self.inner.data.write().unwrap();
let entry = data.entry(self.user_id.to_owned()).or_default();
if entry.is_premium != is_premium {
entry.is_premium = is_premium;
drop(data);
self.inner.write_file();
}
}
pub fn write_sp_dc(&self, sp_dc: String) {
let mut data = self.inner.data.write().unwrap();
let entry = data.entry(self.user_id.to_owned()).or_default();
if entry.sp_dc != sp_dc {
entry.sp_dc = sp_dc;
drop(data);
self.inner.write_file();
}
}
pub fn write_auth_token(&self, auth_token: Token) {
let mut data = self.inner.data.write().unwrap();
let entry = data.entry(self.user_id.to_owned()).or_default();
let new_token = Some(auth_token);
if entry.auth_token != new_token {
entry.auth_token = new_token;
drop(data);
self.inner.write_file();
}
}
pub fn write_client_token(&self, client_token: Token) {
let mut data = self.inner.data.write().unwrap();
let entry = data.entry(self.user_id.to_owned()).or_default();
let new_token = Some(client_token);
if entry.client_token != new_token {
entry.client_token = new_token;
drop(data);
self.inner.write_file();
}
}
}
#[cfg(test)]
mod tests {
use super::CachedSession;
#[test]
fn t1() {
let session = CachedSession::default();
println!("{}", serde_json::to_string_pretty(&session).unwrap());
}
}

View file

@ -0,0 +1,214 @@
use std::ops::{Deref, DerefMut};
use thiserror::Error;
use time::{Duration, OffsetDateTime};
use tracing::{trace, warn};
use url::Url;
use crate::{model::FileId, Error, Session};
use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result;
use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;
use spotifyio_protocol as protocol;
#[derive(Debug, Clone)]
pub struct MaybeExpiringUrl(pub String, pub Option<OffsetDateTime>);
const CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60);
#[derive(Debug, Clone)]
pub struct MaybeExpiringUrls(pub Vec<MaybeExpiringUrl>);
impl Deref for MaybeExpiringUrls {
type Target = Vec<MaybeExpiringUrl>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for MaybeExpiringUrls {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[derive(Debug, Error)]
pub enum CdnUrlError {
#[error("all URLs expired")]
Expired,
#[error("resolved storage is not for CDN")]
Storage,
#[error("no URLs resolved")]
Unresolved,
}
impl From<CdnUrlError> for Error {
fn from(err: CdnUrlError) -> Self {
match err {
CdnUrlError::Expired => Error::deadline_exceeded(err),
CdnUrlError::Storage | CdnUrlError::Unresolved => Error::unavailable(err),
}
}
}
#[derive(Debug, Clone)]
pub struct CdnUrl {
pub file_id: FileId,
urls: MaybeExpiringUrls,
}
impl CdnUrl {
pub fn new(file_id: FileId) -> Self {
Self {
file_id,
urls: MaybeExpiringUrls(Vec::new()),
}
}
pub async fn resolve_audio(&self, session: &Session) -> Result<Self, Error> {
let file_id = self.file_id;
let msg = session.spclient().pb_audio_storage(&file_id).await?;
let urls = MaybeExpiringUrls::try_from(msg)?;
let cdn_url = Self { file_id, urls };
trace!("Resolved CDN storage: {:#?}", cdn_url);
Ok(cdn_url)
}
pub fn try_get_url(&self) -> Result<&MaybeExpiringUrl, Error> {
if self.urls.is_empty() {
return Err(CdnUrlError::Unresolved.into());
}
let now = OffsetDateTime::now_utc();
let url = self.urls.iter().find(|url| match url.1 {
Some(expiry) => now < expiry,
None => true,
});
if let Some(url) = url {
Ok(url)
} else {
Err(CdnUrlError::Expired.into())
}
}
}
impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {
type Error = crate::Error;
fn try_from(msg: CdnUrlMessage) -> Result<Self, Self::Error> {
if !matches!(
msg.result.enum_value_or_default(),
StorageResolveResponse_Result::CDN
) {
return Err(CdnUrlError::Storage.into());
}
let is_expiring = !msg.fileid.is_empty();
let result = msg
.cdnurl
.iter()
.map(|cdn_url| {
let url = Url::parse(cdn_url)?;
let mut expiry: Option<OffsetDateTime> = None;
if is_expiring {
let mut expiry_str: Option<String> = None;
if let Some(token) = url
.query_pairs()
.into_iter()
.find(|(key, _value)| key == "__token__")
{
//"https://audio-ak-spotify-com.akamaized.net/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?__token__=exp=1688165560~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41",
if let Some(mut start) = token.1.find("exp=") {
start += 4;
if token.1.len() >= start {
let slice = &token.1[start..];
if let Some(end) = slice.find('~') {
// this is the only valid invariant for akamaized.net
expiry_str = Some(String::from(&slice[..end]));
} else {
expiry_str = Some(String::from(slice));
}
}
}
} else if let Some((_, token)) = url
.query_pairs()
.into_iter()
.find(|(key, _value)| key == "Expires")
{
//"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=",
if let Some(end) = token.find('~') {
// this is the only valid invariant for spotifycdn.com
let slice = &token[..end];
expiry_str = Some(String::from(&slice[..end]));
}
} else if let Some((_, token)) = url.query_pairs().into_iter().find(|(key, _value)| key == "verify") {
// https://audio-cf.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?verify=1688165560-0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=
if let Some(first) = token.split('-').next() {
expiry_str = Some(String::from(first));
}
} else if let Some(query) = url.query() {
//"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=",
let mut items = query.split('_');
if let Some(first) = items.next() {
// this is the only valid invariant for scdn.co
expiry_str = Some(String::from(first));
}
}
if let Some(exp_str) = expiry_str {
if let Ok(expiry_parsed) = exp_str.parse::<i64>() {
if let Ok(expiry_at) = OffsetDateTime::from_unix_timestamp(expiry_parsed) {
let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN);
expiry = Some(with_margin);
}
} else {
warn!("Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'");
}
} else {
warn!("Unknown CDN URL format: {cdn_url}");
}
}
Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry))
})
.collect::<Result<Vec<MaybeExpiringUrl>, Error>>()?;
Ok(Self(result))
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_maybe_expiring_urls() {
let timestamp = 1688165560;
let mut msg = CdnUrlMessage::new();
msg.result = StorageResolveResponse_Result::CDN.into();
msg.cdnurl = vec![
format!("https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"),
format!("https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="),
format!("https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="),
format!("https://audio-cf.spotifycdn.com/audio/6a28ff353c2da587bfc1005064bbf4913c7fdc9e?verify={timestamp}-6WJMBXvDIB22xKy9G80aAFHQ2%2F%2BBv4bha1FTcJ4Zuy4%3D"),
"https://audio4-fa.scdn.co/foo?baz".to_string(),
];
msg.fileid = vec![0];
let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls");
assert_eq!(urls.len(), 5);
assert!(urls[4].1.is_none());
let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN;
for i in 0..4 {
assert_eq!(
urls[i].1.unwrap().unix_timestamp(),
timestamp_margin.whole_seconds()
);
}
}
}

View file

@ -0,0 +1,31 @@
macro_rules! component {
($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => {
#[derive(Clone)]
pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, parking_lot::Mutex<$inner>)>);
impl $name {
#[allow(dead_code)]
pub(crate) fn new(session: $crate::session::SessionWeak) -> $name {
tracing::debug!(target:"librespot::component", "new {}", stringify!($name));
$name(::std::sync::Arc::new((session, parking_lot::Mutex::new($inner {
$($key : $value,)*
}))))
}
#[allow(dead_code)]
fn lock<F: FnOnce(&mut $inner) -> R, R>(&self, f: F) -> R {
let mut inner = (self.0).1.lock();
f(&mut inner)
}
#[allow(dead_code)]
fn session(&self) -> $crate::session::Session {
(self.0).0.upgrade()
}
}
struct $inner {
$($key : $ty,)*
}
}
}

View file

@ -0,0 +1,460 @@
use std::{
error, fmt,
num::{ParseIntError, TryFromIntError},
str::Utf8Error,
string::FromUtf8Error,
};
use protobuf::Error as ProtobufError;
use reqwest::StatusCode;
use spotifyio_model::IdError;
use thiserror::Error;
use tokio::sync::{
mpsc::error::SendError, oneshot::error::RecvError, AcquireError, TryAcquireError,
};
use url::ParseError;
use crate::pool::PoolError;
#[derive(Debug)]
pub struct Error {
pub kind: ErrorKind,
pub error: Box<dyn error::Error + Send + Sync>,
}
#[derive(Clone, Copy, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)]
pub enum ErrorKind {
#[error("The operation was cancelled by the caller")]
Cancelled = 1,
#[error("Unknown error")]
Unknown = 2,
#[error("Client specified an invalid argument")]
InvalidArgument = 3,
#[error("Deadline expired before operation could complete")]
DeadlineExceeded = 4,
#[error("Requested entity was not found")]
NotFound = 5,
#[error("Attempt to create entity that already exists")]
AlreadyExists = 6,
#[error("Permission denied")]
PermissionDenied = 7,
#[error("No valid authentication credentials")]
Unauthenticated = 16,
#[error("Resource has been exhausted")]
ResourceExhausted = 8,
#[error("Invalid state")]
FailedPrecondition = 9,
#[error("Operation aborted")]
Aborted = 10,
#[error("Operation attempted past the valid range")]
OutOfRange = 11,
#[error("Not implemented")]
Unimplemented = 12,
#[error("Internal error")]
Internal = 13,
#[error("Service unavailable")]
Unavailable = 14,
#[error("Entity has not been modified since the last request")]
NotModified = 15,
#[error("Operation must not be used")]
DoNotUse = -1,
}
#[derive(Debug, Error)]
struct ErrorMessage(String);
impl fmt::Display for ErrorMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error {
pub fn new<E>(kind: ErrorKind, error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind,
error: error.into(),
}
}
pub fn aborted<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Aborted,
error: error.into(),
}
}
pub fn already_exists<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::AlreadyExists,
error: error.into(),
}
}
pub fn cancelled<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Cancelled,
error: error.into(),
}
}
pub fn not_modified<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::NotModified,
error: error.into(),
}
}
pub fn deadline_exceeded<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::DeadlineExceeded,
error: error.into(),
}
}
pub fn do_not_use<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::DoNotUse,
error: error.into(),
}
}
pub fn failed_precondition<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::FailedPrecondition,
error: error.into(),
}
}
pub fn internal<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Internal,
error: error.into(),
}
}
pub fn invalid_argument<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::InvalidArgument,
error: error.into(),
}
}
pub fn not_found<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::NotFound,
error: error.into(),
}
}
pub fn out_of_range<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::OutOfRange,
error: error.into(),
}
}
pub fn permission_denied<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::PermissionDenied,
error: error.into(),
}
}
pub fn resource_exhausted<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::ResourceExhausted,
error: error.into(),
}
}
pub fn unauthenticated<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unauthenticated,
error: error.into(),
}
}
pub fn unavailable<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unavailable,
error: error.into(),
}
}
pub fn unimplemented<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unimplemented,
error: error.into(),
}
}
pub fn unknown<E>(error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self {
kind: ErrorKind::Unknown,
error: error.into(),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.error.source()
}
}
impl fmt::Display for Error {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(fmt, "{} {{ ", self.kind)?;
self.error.fmt(fmt)?;
write!(fmt, " }}")
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
if let Some(status) = err.status() {
match status {
StatusCode::NOT_FOUND => Self::not_found(err),
StatusCode::BAD_REQUEST => Self::invalid_argument(err),
StatusCode::UNAUTHORIZED => Self::unauthenticated(err),
StatusCode::FORBIDDEN => Self::permission_denied(err),
StatusCode::TOO_MANY_REQUESTS => Self::resource_exhausted(err),
StatusCode::GATEWAY_TIMEOUT => Self::deadline_exceeded(err),
_ => Self::unknown(err),
}
} else if err.is_body() || err.is_request() {
Self::invalid_argument(err)
} else if err.is_builder() {
Self::internal(err)
} else if err.is_connect() || err.is_redirect() {
Self::unavailable(err)
} else if err.is_decode() {
Self::failed_precondition(err)
} else if err.is_timeout() {
Self::deadline_exceeded(err)
} else {
Self::unknown(err)
}
}
}
impl From<time::error::Parse> for Error {
fn from(err: time::error::Parse) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<time::error::ComponentRange> for Error {
fn from(err: time::error::ComponentRange) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
use std::io::ErrorKind as IoErrorKind;
match err.kind() {
IoErrorKind::NotFound => Self::new(ErrorKind::NotFound, err),
IoErrorKind::PermissionDenied => Self::new(ErrorKind::PermissionDenied, err),
IoErrorKind::AddrInUse | IoErrorKind::AlreadyExists => {
Self::new(ErrorKind::AlreadyExists, err)
}
IoErrorKind::AddrNotAvailable
| IoErrorKind::ConnectionRefused
| IoErrorKind::NotConnected => Self::new(ErrorKind::Unavailable, err),
IoErrorKind::BrokenPipe
| IoErrorKind::ConnectionReset
| IoErrorKind::ConnectionAborted => Self::new(ErrorKind::Aborted, err),
IoErrorKind::Interrupted | IoErrorKind::WouldBlock => {
Self::new(ErrorKind::Cancelled, err)
}
IoErrorKind::InvalidData | IoErrorKind::UnexpectedEof => {
Self::new(ErrorKind::FailedPrecondition, err)
}
IoErrorKind::TimedOut => Self::new(ErrorKind::DeadlineExceeded, err),
IoErrorKind::InvalidInput => Self::new(ErrorKind::InvalidArgument, err),
IoErrorKind::WriteZero => Self::new(ErrorKind::ResourceExhausted, err),
_ => Self::new(ErrorKind::Unknown, err),
}
}
}
impl From<FromUtf8Error> for Error {
fn from(err: FromUtf8Error) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<ParseError> for Error {
fn from(err: ParseError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<ParseIntError> for Error {
fn from(err: ParseIntError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<TryFromIntError> for Error {
fn from(err: TryFromIntError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<ProtobufError> for Error {
fn from(err: ProtobufError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<RecvError> for Error {
fn from(err: RecvError) -> Self {
Self::new(ErrorKind::Internal, err)
}
}
impl<T> From<SendError<T>> for Error {
fn from(err: SendError<T>) -> Self {
Self {
kind: ErrorKind::Internal,
error: ErrorMessage(err.to_string()).into(),
}
}
}
impl From<AcquireError> for Error {
fn from(err: AcquireError) -> Self {
Self {
kind: ErrorKind::ResourceExhausted,
error: ErrorMessage(err.to_string()).into(),
}
}
}
impl From<TryAcquireError> for Error {
fn from(err: TryAcquireError) -> Self {
Self {
kind: ErrorKind::ResourceExhausted,
error: ErrorMessage(err.to_string()).into(),
}
}
}
impl From<Utf8Error> for Error {
fn from(err: Utf8Error) -> Self {
Self::new(ErrorKind::FailedPrecondition, err)
}
}
impl From<PoolError> for Error {
fn from(value: PoolError) -> Self {
match &value {
PoolError::Empty => Self::invalid_argument("No spotify clients"),
PoolError::Timeout(_) => Self::resource_exhausted(value),
}
}
}
impl From<IdError> for Error {
fn from(value: IdError) -> Self {
Self::invalid_argument(value)
}
}
pub trait NotModifiedRes<T> {
fn into_option(self) -> Result<Option<T>, Error>;
}
impl<T> NotModifiedRes<T> for Result<T, Error> {
fn into_option(self) -> Result<Option<T>, Error> {
match self {
Ok(res) => Ok(Some(res)),
Err(e) => {
if matches!(e.kind, ErrorKind::NotModified) {
Ok(None)
} else {
Err(e)
}
}
}
}
}

View file

@ -0,0 +1,735 @@
//! Data model for the Spotify API
use std::num::NonZeroU32;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DefaultOnError, DisplayFromStr, VecSkipError};
use time::OffsetDateTime;
use spotifyio_model::{
AlbumId, ArtistId, ConcertId, PlaylistId, PrereleaseId, SongwriterId, TrackId, UserId,
UserlikeId,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LyricsWrap {
pub lyrics: Lyrics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GqlWrap<T> {
pub data: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum ArtistOption {
Artist(ArtistItem),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum AlbumOption {
Album(AlbumItem),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum TrackOption {
Track(TrackItem),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum PlaylistOption {
Playlist(GqlPlaylistItem),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
#[allow(clippy::large_enum_variant)]
pub enum ConcertOption {
ConcertV2(ConcertGql),
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GqlPagination<T> {
pub items: Vec<T>,
pub total_count: Option<NonZeroU32>,
}
impl<T> Default for GqlPagination<T> {
fn default() -> Self {
Self {
items: Vec::new(),
total_count: None,
}
}
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Lyrics {
#[serde_as(as = "DefaultOnError")]
pub sync_type: Option<SyncType>,
pub lines: Vec<LyricsLine>,
pub provider: Option<String>,
pub provider_lyrics_id: Option<String>,
pub provider_display_name: Option<String>,
pub language: Option<String>,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum SyncType {
LineSynced,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LyricsLine {
#[serde_as(as = "Option<DisplayFromStr>")]
pub start_time_ms: Option<u32>,
pub words: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ArtistGqlWrap {
pub artist_union: ArtistGql,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ArtistGql {
pub uri: ArtistId<'static>,
pub profile: ArtistProfile,
pub related_content: Option<RelatedContent>,
pub stats: Option<ArtistStats>,
#[serde(default)]
pub visuals: Visuals,
pub pre_release: Option<PrereleaseItem>,
#[serde(default)]
pub goods: Goods,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ArtistProfile {
pub name: String,
#[serde(default)]
pub verified: bool,
#[serde(default)]
pub biography: Biography,
#[serde(default)]
pub external_links: GqlPagination<ExternalLink>,
#[serde(default)]
pub playlists_v2: GqlPagination<GqlWrap<PlaylistOption>>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Biography {
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExternalLink {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ArtistStats {
pub followers: u32,
pub monthly_listeners: u32,
pub world_rank: u32,
pub top_cities: GqlPagination<City>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct City {
pub city: String,
pub country: String,
pub number_of_listeners: u32,
pub region: String,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
#[non_exhaustive]
pub struct Visuals {
pub avatar_image: Option<Image>,
pub gallery: GqlPagination<Image>,
pub header_image: Option<Image>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
#[non_exhaustive]
pub struct Goods {
pub events: Events,
pub merch: GqlPagination<MerchItem>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Events {
pub concerts: GqlPagination<Concert>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Concert {
pub uri: ConcertId<'static>,
pub title: String,
pub date: DateWrap,
#[serde(default)]
pub festival: bool,
pub partner_links: Option<GqlPagination<PartnerLink>>,
pub venue: Venue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PartnerLink {
pub partner_name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Venue {
pub name: String,
pub location: Option<Location>,
pub coordinates: Option<Coordinates>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Location {
pub name: String,
pub city: Option<String>,
pub coordinates: Option<Coordinates>,
// ISO-3166 country code
pub country: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Coordinates {
pub latitude: f64,
pub longitude: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MerchItem {
pub uri: String,
pub url: String,
pub name: String,
pub price: String,
pub description: String,
pub image: Image,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Image {
pub sources: Vec<ImageSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ImageSource {
pub url: String,
pub height: Option<u16>,
pub width: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Name {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct GqlPlaylistItem {
pub uri: PlaylistId<'static>,
pub name: String,
pub images: GqlPagination<Image>,
pub owner_v2: GqlWrap<UserItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct UserItem {
pub uri: Option<UserId<'static>>,
#[serde(alias = "displayName")]
pub name: Option<String>,
pub avatar: Option<Image>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ArtistItem {
pub uri: ArtistId<'static>,
pub profile: Name,
pub visuals: Option<Visuals>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RelatedContent {
#[serde(default)]
pub discovered_on_v2: GqlPagination<GqlWrap<PlaylistOption>>,
#[serde(default)]
pub featuring_v2: GqlPagination<GqlWrap<PlaylistOption>>,
#[serde(default)]
pub related_artists: GqlPagination<ArtistItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PrereleaseLookup {
pub lookup: Vec<GqlWrap<PrereleaseItem>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PrereleaseItem {
/// URI of the prerelease
pub uri: PrereleaseId<'static>,
pub pre_release_content: PrereleaseContent,
pub release_date: DateWrap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PrereleaseContent {
/// URI of the to-be-released album
pub uri: Option<AlbumId<'static>>,
pub name: String,
pub cover_art: Option<Image>,
pub artists: Option<GqlPagination<GqlWrap<ArtistItem>>>,
pub tracks: Option<GqlPagination<PrereleaseTrackItem>>,
pub copyright: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PrereleaseTrackItem {
pub uri: TrackId<'static>,
pub name: String,
pub duration: Option<DurationWrap>,
pub artists: GqlPagination<GqlWrap<ArtistItem>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DateWrap {
#[serde(with = "time::serde::iso8601")]
pub iso_string: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DateYear {
pub year: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DurationWrap {
pub total_milliseconds: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SearchResultWrap {
pub search_v2: GqlSearchResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum MatchedField {
Lyrics,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GqlSearchTrackWrap {
pub item: GqlWrap<TrackOption>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub matched_fields: Vec<MatchedField>,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChipOrder {
#[serde_as(as = "VecSkipError<_>")]
pub items: Vec<Chip>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chip {
type_name: SearchItemType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct GqlSearchResult {
#[serde(default)]
pub artists: GqlPagination<GqlWrap<ArtistOption>>,
#[serde(default)]
pub albums_v2: GqlPagination<AlbumItemWrap>,
#[serde(default)]
pub tracks_v2: GqlPagination<GqlSearchTrackWrap>,
#[serde(default)]
pub playlists: GqlPagination<GqlWrap<PlaylistOption>>,
#[serde(default)]
pub users: GqlPagination<GqlWrap<UserItem>>,
pub chip_order: ChipOrder,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "__typename")]
pub enum AlbumItemWrap {
AlbumResponseWrapper {
data: AlbumItem,
},
PreReleaseResponseWrapper {
data: PrereleaseItem,
},
#[serde(alias = "GenericError")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AlbumItem {
pub uri: AlbumId<'static>,
pub name: String,
pub date: Option<DateYear>,
pub cover_art: Option<Image>,
pub artists: Option<GqlPagination<ArtistItem>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct TrackItem {
pub uri: TrackId<'static>,
pub name: String,
pub duration: DurationWrap,
pub artists: GqlPagination<ArtistItem>,
pub album_of_track: AlbumItem,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConcertGqlWrap {
pub concert: ConcertOption,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConcertGql {
pub uri: ConcertId<'static>,
pub title: String,
#[serde(default)]
pub artists: GqlPagination<GqlWrap<ArtistItem>>,
#[serde(default, with = "time::serde::iso8601::option")]
pub start_date_iso_string: Option<OffsetDateTime>,
#[serde(default, with = "time::serde::iso8601::option")]
pub doors_open_time_iso_string: Option<OffsetDateTime>,
#[serde(default)]
pub festival: bool,
pub html_description: Option<String>,
pub location: Option<Location>,
#[serde(default)]
pub offers: GqlPagination<ConcertOffer>,
#[serde(default)]
pub related_concerts: GqlPagination<GqlWrap<ConcertOption>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConcertOffer {
pub access_code: String,
pub currency: Option<String>,
pub dates: Option<ConcertOfferDates>,
#[serde(default)]
pub first_party: bool,
pub max_price: Option<f32>,
pub min_price: Option<f32>,
pub provider_image_url: Option<String>,
pub provider_name: Option<String>,
pub sale_type: Option<String>,
pub sold_out: Option<bool>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConcertOfferDates {
#[serde(with = "time::serde::iso8601")]
pub start_date_iso_string: OffsetDateTime,
#[serde(with = "time::serde::iso8601")]
pub end_date_iso_string: OffsetDateTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SearchItemType {
Artists,
Albums,
Tracks,
Playlists,
Users,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserProfile {
pub uri: UserId<'static>,
pub name: Option<String>,
pub image_url: Option<String>,
pub followers_count: Option<u32>,
pub following_count: Option<u32>,
#[serde(default)]
pub public_playlists: Vec<PublicPlaylistItem>,
pub total_public_playlists_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserPlaylists {
#[serde(default)]
pub public_playlists: Vec<PublicPlaylistItem>,
pub total_public_playlists_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PublicPlaylistItem {
pub uri: PlaylistId<'static>,
pub name: String,
pub owner_name: String,
pub owner_uri: UserId<'static>,
/// UID-based image id
///
/// - `spotify:image:ab67706c0000da8474fffd106bb7f5be3ba4b758`
/// - `spotify:mosaic:ab67616d00001e021c04efd2804b16cf689de7f0:ab67616d00001e0269f63a842ea91ca7c522593a:ab67616d00001e0270dbc9f47669d120ad874ec1:ab67616d00001e027d384516b23347e92a587ed1`
pub image_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UserProfilesWrap<T> {
pub profiles: Vec<T>,
}
/// May be an artist or an user
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FollowerItem {
pub uri: UserId<'static>,
pub name: Option<String>,
pub followers_count: Option<u32>,
pub image_url: Option<String>,
}
/// May be an artist or an user
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub(crate) struct FollowerItemUserlike {
pub uri: UserlikeId<'static>,
pub name: Option<String>,
pub followers_count: Option<u32>,
pub image_url: Option<String>,
}
/// Seektable for AAC tracks
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Seektable {
pub padding_samples: u32,
pub encoder_delay_samples: u32,
pub pssh: String,
pub timescale: u32,
pub init_range: Option<(u32, u32)>,
pub index_range: Option<(u32, u32)>,
pub segments: Vec<(u32, u32)>,
pub offset: usize,
}
/// Information about a track's artists, writers and producers
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackCredits {
pub track_uri: TrackId<'static>,
pub track_title: String,
#[serde(default)]
pub role_credits: Vec<RoleCredits>,
// pub extended_credits: (),
#[serde(default)]
pub source_names: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoleCredits {
pub role_title: String,
pub artists: Vec<CreditedArtist>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreditedArtist {
pub name: String,
pub uri: Option<ArtistId<'static>>,
pub creator_uri: Option<SongwriterId<'static>>,
pub external_url: Option<String>,
/// Image URL
pub image_uri: Option<String>,
pub subroles: Vec<String>,
pub weight: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistWrap {
pub playlist_v2: PlaylistOption,
}
impl ArtistOption {
pub fn into_option(self) -> Option<ArtistItem> {
match self {
ArtistOption::Artist(artist_item) => Some(artist_item),
ArtistOption::NotFound => None,
}
}
}
impl TrackOption {
pub fn into_option(self) -> Option<TrackItem> {
match self {
TrackOption::Track(track_item) => Some(track_item),
TrackOption::NotFound => None,
}
}
}
impl PlaylistOption {
pub fn into_option(self) -> Option<GqlPlaylistItem> {
match self {
PlaylistOption::Playlist(playlist_item) => Some(playlist_item),
PlaylistOption::NotFound => None,
}
}
}
impl ConcertOption {
pub fn into_option(self) -> Option<ConcertGql> {
match self {
ConcertOption::ConcertV2(concert) => Some(concert),
ConcertOption::NotFound => None,
}
}
}
impl From<GqlWrap<ArtistOption>> for Option<ArtistItem> {
fn from(value: GqlWrap<ArtistOption>) -> Self {
value.data.into_option()
}
}
impl From<GqlWrap<TrackOption>> for Option<TrackItem> {
fn from(value: GqlWrap<TrackOption>) -> Self {
value.data.into_option()
}
}
impl From<GqlWrap<PlaylistOption>> for Option<GqlPlaylistItem> {
fn from(value: GqlWrap<PlaylistOption>) -> Self {
value.data.into_option()
}
}
impl From<GqlWrap<ConcertOption>> for Option<ConcertGql> {
fn from(value: GqlWrap<ConcertOption>) -> Self {
value.data.into_option()
}
}
impl TryFrom<FollowerItemUserlike> for FollowerItem {
type Error = ();
fn try_from(value: FollowerItemUserlike) -> Result<Self, Self::Error> {
if let UserlikeId::User(uri) = value.uri {
Ok(Self {
uri,
name: value.name,
followers_count: value.followers_count,
image_url: value.image_url,
})
} else {
Err(())
}
}
}

View file

@ -0,0 +1,56 @@
#[macro_use]
mod component;
mod apresolve;
mod cache;
mod cdn_url;
mod error;
mod normalisation;
mod pagination;
mod pool;
mod session;
mod spclient;
mod spotify_pool;
mod totp;
mod web_model;
pub mod gql_model;
pub mod login;
pub mod util;
pub use cache::{ApplicationCache, SessionCache};
pub use cdn_url::{CdnUrl, CdnUrlError, MaybeExpiringUrl};
pub use error::{Error, ErrorKind, NotModifiedRes};
pub use governor::{Jitter, Quota};
pub use normalisation::NormalisationData;
pub use pool::PoolError;
pub use session::{Session, SessionConfig};
pub use spclient::{EtagResponse, RequestStrategy};
pub use spotify_pool::{PoolConfig, SpotifyIoPool};
pub use spotifyio_model as model;
/// Protobuf enums
pub mod pb {
pub use spotifyio_protocol::canvaz_meta::Type as CanvazType;
pub use spotifyio_protocol::extended_metadata::ExtensionType;
pub use spotifyio_protocol::extension_kind::ExtensionKind;
pub use spotifyio_protocol::metadata::{
album::Type as AlbumType,
artist_with_role::ArtistRole,
audio_file::Format as AudioFileFormat,
copyright::Type as CopyrightType,
episode::EpisodeType,
image::Size as ImageSize,
restriction::{Catalogue as CatalogueRestriction, Type as TypeRestriction},
show::{ConsumptionOrder, MediaType},
};
pub use spotifyio_protocol::playlist4_external::{
op::Kind as OpKind, source_info::Client, GeoblockBlockingType, ItemAttributeKind,
ListAttributeKind,
};
pub use spotifyio_protocol::playlist_permission::PermissionLevel;
pub use spotifyio_protocol::*;
}
/// Version of the SpotifyIO crate
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View file

@ -0,0 +1,96 @@
use reqwest::{header, Client};
use spotifyio_model::{Id, PrivateUser};
use time::OffsetDateTime;
use url::Url;
use crate::{
spclient::Token,
totp::Totp,
web_model::{AuthTokenResponse, ServerTimeResponse},
ApplicationCache, Error, SessionCache,
};
pub(crate) async fn get_server_time(http: &Client) -> Result<u64, Error> {
let resp = http
.get("https://open.spotify.com/api/server-time")
.send()
.await?
.error_for_status()?;
let time = resp.json::<ServerTimeResponse>().await?;
Ok(time.server_time)
}
pub(crate) async fn get_auth_token(
http: &Client,
totp_gen: &Totp,
sp_dc: &str,
) -> Result<Token, Error> {
let server_time = get_server_time(http).await?;
let totp = totp_gen.generate(server_time);
let resp = http
.get(Url::parse_with_params(
"https://open.spotify.com/api/token?reason=init&productType=web-player",
[
("totp", &totp),
("totpVer", &totp_gen.version()),
("ts", &server_time.to_string()),
],
)?)
.header(header::COOKIE, format!("sp_dc={sp_dc}"))
.send()
.await?;
if resp.status().is_success() {
let ok = resp.json::<AuthTokenResponse>().await?;
let now = OffsetDateTime::now_utc();
let auth_token = Token {
access_token: ok.access_token.clone(),
expires_in: (ok.access_token_expiration_timestamp_ms / 1000
- now.unix_timestamp() as u64) as u32,
timestamp: now,
};
Ok(auth_token)
} else {
let error_data = resp.text().await.unwrap_or_default();
Err(Error::unauthenticated(format!("auth error: {error_data}")))
}
}
async fn get_own_profile(http: &Client, access_token: &str) -> Result<PrivateUser, Error> {
Ok(http
.get("https://api.spotify.com/v1/me")
.bearer_auth(access_token)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
pub async fn new_session(app_cache: &ApplicationCache, sp_dc: &str) -> Result<SessionCache, Error> {
let http = Client::builder()
.user_agent(crate::util::USER_AGENT)
.build()
.unwrap();
let totp_gen = Totp::new(&http).await.unwrap();
let auth_token = get_auth_token(&http, &totp_gen, sp_dc).await?;
let profile = get_own_profile(&http, &auth_token.access_token).await?;
let sc = app_cache.new_session(profile.id.id().to_owned());
sc.write_sp_dc(sp_dc.to_owned());
sc.write_auth_token(auth_token);
sc.write_email(profile.email);
if let Some(country) = profile.country {
let cstr: &'static str = country.into();
sc.write_country(cstr.to_owned());
}
sc.write_is_premium(
profile
.product
.map(|p| matches!(p, spotifyio_model::SubscriptionLevel::Premium))
.unwrap_or_default(),
);
Ok(sc)
}

View file

@ -0,0 +1,45 @@
use std::io::{Cursor, Read, Seek};
use byteorder::{LittleEndian, ReadBytesExt};
use crate::Error;
/// Audio metadata for volume normalization
///
/// More information about ReplayGain and how to apply this infomation to audio files
/// can be found here: <https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification>.
#[derive(Clone, Copy, Debug)]
pub struct NormalisationData {
pub track_gain_db: f32,
pub track_peak: f32,
pub album_gain_db: f32,
pub album_peak: f32,
}
impl NormalisationData {
/// Parse normalisation data from a Spotify OGG header (first 167 bytes)
pub fn parse_from_ogg<R: Read + Seek>(file: &mut R) -> Result<Self, Error> {
let packets = ogg_pager::Packets::read_count(file, 1)
.map_err(|e| Error::failed_precondition(format!("invalid ogg file: {e}")))?;
let packet = packets.get(0).unwrap();
if packet.len() != 139 {
return Err(Error::failed_precondition(format!(
"ogg header len={}, expected=139",
packet.len()
)));
}
let mut rdr = Cursor::new(&packet[116..]);
let track_gain_db = rdr.read_f32::<LittleEndian>()?;
let track_peak = rdr.read_f32::<LittleEndian>()?;
let album_gain_db = rdr.read_f32::<LittleEndian>()?;
let album_peak = rdr.read_f32::<LittleEndian>()?;
Ok(Self {
track_gain_db,
track_peak,
album_gain_db,
album_peak,
})
}
}

View file

@ -0,0 +1,99 @@
//! Asynchronous implementation of automatic pagination requests.
use std::{future::Future, pin::Pin};
use futures_util::Stream;
use spotifyio_model::Page;
use crate::Error;
/// Alias for `futures::stream::Stream<Item = T>`, since async mode is enabled.
pub type Paginator<'a, T> = Pin<Box<dyn Stream<Item = T> + 'a + Send>>;
pub type RequestFuture<'a, T> = Pin<Box<dyn 'a + Future<Output = Result<Page<T>, Error>> + Send>>;
/// This is used to handle paginated requests automatically.
pub fn paginate_with_ctx<'a, Ctx: 'a + Send, T, Request>(
ctx: Ctx,
req: Request,
page_size: u32,
) -> Paginator<'a, Result<T, Error>>
where
T: 'a + Unpin + Send,
Request: 'a + for<'ctx> Fn(&'ctx Ctx, u32, u32) -> RequestFuture<'ctx, T> + Send,
{
use async_stream::stream;
let mut offset = 0;
Box::pin(stream! {
loop {
let request = req(&ctx, page_size, offset);
let page = request.await?;
offset += page.items.len() as u32;
// Occasionally, the Spotify will return an empty items with non-none next page
// So we have to check both conditions
// https://github.com/ramsayleung/rspotify/issues/492
if page.items.is_empty() {
break;
}
for item in page.items {
yield Ok(item);
}
if page.next.is_none() {
break;
}
}
})
}
pub fn paginate<'a, T, Fut, Request>(
req: Request,
page_size: u32,
) -> Paginator<'a, Result<T, Error>>
where
T: 'a + Unpin + Send,
Fut: Future<Output = Result<Page<T>, Error>> + Send,
Request: 'a + Fn(u32, u32) -> Fut + Send,
{
use async_stream::stream;
let mut offset = 0;
Box::pin(stream! {
loop {
let request = req(page_size, offset);
let page = request.await?;
offset += page.items.len() as u32;
for item in page.items {
yield Ok(item);
}
if page.next.is_none() {
break;
}
}
})
}
#[cfg(test)]
mod test {
use futures_util::{future, StreamExt};
use spotifyio_model::Page;
use super::paginate;
#[tokio::test]
async fn test_mt_scheduling() {
let mut paginator = paginate(
|_, offset| {
let fake_page = Page {
items: vec![offset, offset + 1, offset + 2],
..Page::default()
};
future::ok(fake_page)
},
32,
);
let mut expected = [0, 1, 2].into_iter();
while let Some(item) = paginator.next().await {
assert_eq!(expected.next().unwrap(), item.unwrap());
}
}
}

View file

@ -0,0 +1,275 @@
use std::time::Duration;
use governor::{clock::Clock, DefaultDirectRateLimiter, Jitter, Quota};
use parking_lot::Mutex;
use rand::Rng;
#[derive(Debug, thiserror::Error)]
pub enum PoolError {
#[error("no clients available")]
Empty,
#[error("timeout: {0:?}")]
Timeout(Duration),
}
pub struct ClientPool<T> {
robin: Mutex<usize>,
entries: Vec<ClientPoolEntry<T>>,
is_invalid: Box<dyn Fn(&T) -> bool + Sync + Send>,
jitter: Jitter,
}
struct ClientPoolEntry<T> {
entry: T,
limiter: DefaultDirectRateLimiter,
}
#[allow(dead_code)]
impl<T> ClientPool<T> {
pub fn new<E, F>(
quota: Quota,
jitter: Option<Jitter>,
entries: E,
is_invalid: F,
) -> Result<Self, PoolError>
where
E: IntoIterator<Item = T>,
F: Fn(&T) -> bool + Sync + Send + 'static,
{
let entries = entries
.into_iter()
.map(|entry| ClientPoolEntry {
entry,
limiter: DefaultDirectRateLimiter::direct(quota),
})
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(PoolError::Empty);
}
Ok(Self {
robin: Mutex::default(),
entries,
is_invalid: Box::new(is_invalid),
jitter: jitter.unwrap_or_else(|| Jitter::up_to(quota.replenish_interval() / 2)),
})
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
self.entries.iter().map(|e| &e.entry)
}
pub fn n_available(&self) -> usize {
self.entries
.iter()
.filter(|entry| !(self.is_invalid)(&entry.entry))
.count()
}
pub fn n_available_errored(&self) -> (usize, usize) {
let n_available = self.n_available();
(n_available, self.entries.len() - n_available)
}
fn wrapping_inc(&self, mut a: usize) -> usize {
a = a.wrapping_add(1);
if a >= self.len() {
a = 0;
}
a
}
fn next_pos(&self, timeout: Option<Duration>) -> Result<(usize, Duration), PoolError> {
let mut robin = self.robin.lock();
let mut i = *robin;
let mut min_to = Duration::MAX;
for _ in 0..((self.len() - 1).max(1)) {
let entry = &self.entries[i];
if !(self.is_invalid)(&entry.entry) {
match entry.limiter.check() {
Ok(_) => {
*robin = self.wrapping_inc(i);
return Ok((i, Duration::ZERO));
}
Err(not_until) => {
let wait_time = not_until.wait_time_from(entry.limiter.clock().now());
if timeout.is_some_and(|to| wait_time > to) {
min_to = min_to.min(wait_time);
} else {
*robin = self.wrapping_inc(i);
return Ok((i, wait_time));
}
}
}
}
i = self.wrapping_inc(i);
}
// If the pool is empty or all entries were skipped
Err(if min_to == Duration::MAX {
PoolError::Empty
} else {
PoolError::Timeout(min_to)
})
}
/// Get a client from the pool, waiting until the rate limit passes
pub async fn get(&self) -> Result<&T, PoolError> {
self._get_timeout(None).await
}
/// Get a client from the pool, waiting until the rate limit passes
///
/// If the timeout is longer than the given value, the function returns a [`PoolError::Timeout`].
pub async fn get_timeout(&self, timeout: Duration) -> Result<&T, PoolError> {
self._get_timeout(Some(timeout)).await
}
/// Get a random, valid client from the pool, skipping the rate limit
///
/// If no valid clients were found, [`PoolError::Empty`] is returned
pub fn random(&self) -> Result<&T, PoolError> {
let mut pos = rand::rng().random_range(0..self.len());
for _ in 0..((self.len() - 1).max(1)) {
let entry = &self.entries[pos].entry;
if !(self.is_invalid)(entry) {
return Ok(entry);
}
pos += 1;
}
Err(PoolError::Empty)
}
async fn _get_timeout(&self, timeout: Option<Duration>) -> Result<&T, PoolError> {
let (i, wait_time) = self.next_pos(timeout)?;
let entry = &self.entries[i];
if !wait_time.is_zero() {
let wait_with_jitter = self.jitter + wait_time;
if wait_with_jitter > Duration::from_secs(10) {
tracing::info!("waiting for {}s", wait_with_jitter.as_secs());
} else {
tracing::debug!("waiting for {}s", wait_with_jitter.as_secs());
};
tokio::time::sleep(wait_with_jitter).await;
}
Ok(&entry.entry)
}
}
#[cfg(test)]
mod tests {
use std::{num::NonZeroU32, time::Duration};
use tracing_test::traced_test;
use super::*;
const TIMEOUT: Duration = Duration::from_millis(100);
#[tokio::test]
#[traced_test]
async fn one_client() {
let pool = ClientPool::new(
Quota::per_second(NonZeroU32::new(2).unwrap()),
Some(Jitter::default()),
[1],
|_| false,
)
.unwrap();
// Deplete the limiter
for _ in 0..2 {
assert_eq!(
*tokio::time::timeout(TIMEOUT, pool.get())
.await
.unwrap()
.unwrap(),
1
);
}
assert!(tokio::time::timeout(TIMEOUT, pool.get()).await.is_err());
if let PoolError::Timeout(to) = pool.get_timeout(TIMEOUT).await.unwrap_err() {
assert!(to > TIMEOUT, "got {to:?}");
tokio::time::sleep(to).await;
} else {
panic!("no timeout err");
}
assert_eq!(
*tokio::time::timeout(TIMEOUT, pool.get())
.await
.unwrap()
.unwrap(),
1
);
assert_eq!(*pool.random().unwrap(), 1);
}
#[tokio::test]
#[traced_test]
async fn one_client_invalid() {
let pool = ClientPool::new(
Quota::per_minute(NonZeroU32::new(2).unwrap()),
Some(Jitter::default()),
[1],
|_| true,
)
.unwrap();
assert!(matches!(pool.get().await.unwrap_err(), PoolError::Empty));
assert!(matches!(
pool.get_timeout(TIMEOUT).await.unwrap_err(),
PoolError::Empty
));
assert!(matches!(pool.random().unwrap_err(), PoolError::Empty));
}
#[tokio::test]
#[traced_test]
async fn multiple_clients() {
let pool = ClientPool::new(
Quota::per_second(NonZeroU32::new(2).unwrap()),
Some(Jitter::default()),
[1, 2, 3, 4, 5], // active: 1, 3, 5
|n| *n % 2 == 0,
)
.unwrap();
let is_valid = |n: &i32| assert!(matches!(*n, 1 | 3 | 5));
// Deplete the limiter
for _ in 0..6 {
is_valid(
tokio::time::timeout(TIMEOUT, pool.get())
.await
.unwrap()
.unwrap(),
);
}
assert!(tokio::time::timeout(TIMEOUT, pool.get()).await.is_err());
if let PoolError::Timeout(to) = pool.get_timeout(TIMEOUT).await.unwrap_err() {
assert!(to > TIMEOUT, "got {to:?}");
tokio::time::sleep(to).await;
} else {
panic!("no timeout err");
}
is_valid(
tokio::time::timeout(TIMEOUT, pool.get())
.await
.unwrap()
.unwrap(),
);
is_valid(pool.random().unwrap());
}
}

View file

@ -0,0 +1,142 @@
use std::sync::{Arc, Weak};
use once_cell::sync::OnceCell;
use reqwest::Client;
use spotifyio_model::FileId;
use crate::{
apresolve::ApResolver,
cache::SessionCache,
spclient::{RequestStrategy, SpClient},
};
#[derive(Clone, Debug)]
pub struct SessionConfig {
pub client_id: String,
pub language: String,
pub request_strategy: RequestStrategy,
pub pagination_chunks: u32,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
client_id: crate::util::SPOTIFY_CLIENT_ID.to_owned(),
language: "en".to_owned(),
request_strategy: RequestStrategy::TryTimes(10),
pagination_chunks: 50,
}
}
}
struct SessionInternal {
config: SessionConfig,
cache: SessionCache,
http_client: Client,
apresolver: OnceCell<ApResolver>,
spclient: OnceCell<SpClient>,
}
#[derive(Clone)]
pub struct Session(Arc<SessionInternal>);
impl Session {
pub fn new(config: SessionConfig, cache: SessionCache) -> Self {
Self(Arc::new(SessionInternal {
config,
cache,
http_client: Client::builder()
.user_agent(crate::util::USER_AGENT)
.build()
.unwrap(),
apresolver: OnceCell::new(),
spclient: OnceCell::new(),
}))
}
pub fn config(&self) -> &SessionConfig {
&self.0.config
}
pub fn cache(&self) -> &SessionCache {
&self.0.cache
}
pub fn apresolver(&self) -> &ApResolver {
self.0
.apresolver
.get_or_init(|| ApResolver::new(self.weak()))
}
pub fn http_client(&self) -> &Client {
&self.0.http_client
}
pub fn spclient(&self) -> &SpClient {
self.0.spclient.get_or_init(|| SpClient::new(self.weak()))
}
fn weak(&self) -> SessionWeak {
SessionWeak(Arc::downgrade(&self.0))
}
pub fn device_id(&self) -> String {
self.cache().read_device_id()
}
pub fn client_id(&self) -> &str {
&self.0.config.client_id
}
pub fn user_id(&self) -> String {
self.cache().get_user_id()
}
pub fn email(&self) -> Option<String> {
self.cache().read_email()
}
pub fn country(&self) -> Option<String> {
self.cache().read_country()
}
pub fn is_premium(&self) -> bool {
self.cache().read_is_premium()
}
pub fn catalogue(&self) -> &'static str {
if self.cache().read_is_premium() {
"premium"
} else {
"free"
}
}
pub fn image_url(&self, image_id: &FileId) -> String {
format!("https://i.scdn.co/image/{}", &image_id.base16())
}
pub fn audio_preview_url(&self, file_id: &FileId) -> String {
format!("https://p.scdn.co/mp3-preview/{}", &file_id.base16())
}
pub fn head_file_url(&self, file_id: &FileId) -> String {
format!("https://heads-fa.scdn.co/head/{}", &file_id.base16())
}
}
#[derive(Clone)]
pub struct SessionWeak(Weak<SessionInternal>);
impl SessionWeak {
fn try_upgrade(&self) -> Option<Session> {
self.0.upgrade().map(Session)
}
pub(crate) fn upgrade(&self) -> Session {
self.try_upgrade()
.expect("session was dropped and so should have this component")
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,105 @@
use std::{num::NonZeroU32, path::PathBuf, sync::Arc, time::Duration};
use bytes::Bytes;
use governor::{Jitter, Quota};
use crate::{
pool::{ClientPool, PoolError},
spclient::SpClient,
ApplicationCache, Error, Session, SessionConfig,
};
#[derive(Clone)]
pub struct SpotifyIoPool {
inner: Arc<SpotifyIoPoolInner>,
}
struct SpotifyIoPoolInner {
app_cache: ApplicationCache,
pool: ClientPool<Session>,
}
pub struct PoolConfig {
pub max_sessions: usize,
pub quota_key: Quota,
pub jitter: Option<Jitter>,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
max_sessions: usize::MAX,
quota_key: Quota::per_minute(NonZeroU32::new(1).unwrap())
.allow_burst(NonZeroU32::new(3).unwrap()),
jitter: None,
}
}
}
impl SpotifyIoPool {
pub fn new<P: Into<PathBuf>>(
cache_path: P,
session_cfg: &SessionConfig,
pool_cfg: &PoolConfig,
) -> Result<Self, Error> {
Self::from_app_cache(
ApplicationCache::new(cache_path.into()),
session_cfg,
pool_cfg,
)
}
pub fn from_app_cache(
app_cache: ApplicationCache,
session_cfg: &SessionConfig,
pool_cfg: &PoolConfig,
) -> Result<Self, Error> {
let sessions = app_cache.sessions(session_cfg, pool_cfg.max_sessions);
let pool = ClientPool::new(pool_cfg.quota_key, pool_cfg.jitter, sessions, |_| false)?;
Ok(Self {
inner: SpotifyIoPoolInner { app_cache, pool }.into(),
})
}
pub fn app_cache(&self) -> &ApplicationCache {
&self.inner.app_cache
}
pub fn session(&self) -> Result<&Session, PoolError> {
self.inner.pool.random()
}
pub fn spclient(&self) -> Result<&SpClient, PoolError> {
Ok(self.inner.pool.random()?.spclient())
}
pub async fn get_widevine_license(
&self,
challenge: Vec<u8>,
is_video: bool,
) -> Result<Bytes, Error> {
self.inner
.pool
.get()
.await?
.spclient()
.get_widevine_license(challenge, is_video)
.await
}
pub async fn get_widevine_license_timeout(
&self,
challenge: Vec<u8>,
is_video: bool,
timeout: Duration,
) -> Result<Bytes, Error> {
self.inner
.pool
.get_timeout(timeout)
.await?
.spclient()
.get_widevine_license(challenge, is_video)
.await
}
}

View file

@ -0,0 +1,88 @@
use std::{collections::BTreeMap, io::Write};
use hmac::{Hmac, Mac};
use reqwest::Client;
use sha1::Sha1;
use time::OffsetDateTime;
use crate::Error;
const SPOTIFY_SECRETS_JSON: &str = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretDict.json";
#[derive(Debug, Clone)]
pub struct Totp {
version: u32,
secret: Vec<u8>,
updated_at: OffsetDateTime,
}
const PERIOD: u64 = 30;
const DIGITS: u32 = 6;
impl Totp {
pub async fn new(client: &Client) -> Result<Self, Error> {
let resp = client
.get(SPOTIFY_SECRETS_JSON)
.send()
.await?
.error_for_status()?;
let mut secrets = resp.json::<BTreeMap<u32, Vec<u8>>>().await?;
let (version, latest_secret) = secrets
.pop_last()
.ok_or_else(|| Error::failed_precondition("no spotify-secrets found"))?;
let secret = Self::derive_secret_number(&latest_secret);
Ok(Self {
version,
secret,
updated_at: OffsetDateTime::now_utc(),
})
}
fn derive_secret_number(secret_cipher_bytes: &[u8]) -> Vec<u8> {
secret_cipher_bytes
.iter()
.enumerate()
.map(|(i, b)| b ^ ((i % 33) as u8 + 9))
.fold(String::new(), |acc, b| format!("{acc}{b}"))
.into_bytes()
}
pub fn generate(&self, timestamp: u64) -> String {
let counter = timestamp / PERIOD;
let counter_bytes = counter.to_be_bytes();
let mut h = Hmac::<Sha1>::new_from_slice(&self.secret).unwrap();
h.write_all(&counter_bytes).unwrap();
let hmac_result = h.finalize().into_bytes();
let offset = (*hmac_result.last().unwrap() & 0x0F) as usize;
let binary = (hmac_result[offset] as u32 & 0x7F) << 24
| (hmac_result[offset + 1] as u32 & 0xFF) << 16
| (hmac_result[offset + 2] as u32 & 0xFF) << 8
| (hmac_result[offset + 3] as u32 & 0xFF);
let otp = binary % (10u32.pow(DIGITS));
format!("{otp:0>6}")
}
pub fn version(&self) -> String {
self.version.to_string()
}
pub fn is_fresh(&self) -> bool {
self.updated_at + time::Duration::DAY > OffsetDateTime::now_utc()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn totp() {
let client = Client::default();
let totp = Totp::new(&client).await.unwrap();
dbg!(&totp);
dbg!(totp.generate(1759088655));
}
}

View file

@ -0,0 +1,111 @@
//! Additional utilities for interacting with Spotify
use crate::pb::AudioFileFormat;
pub type SocketAddress = (String, u16);
/// User agent for HTTP requests
pub const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
/// Spotify app version
pub const SPOTIFY_VERSION: &str = "1.2.74.275.g610d7cec";
/// Spotify client ID
pub const SPOTIFY_CLIENT_ID: &str = "d8a5ed958d274c2e8ee717e6a4b0971d";
/// AES128 initialization vector for audio decryption
pub const AUDIO_AESIV: [u8; 16] = [
0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64, 0x3f, 0x63, 0x0d, 0x93,
];
pub fn audio_format_extension(format: AudioFileFormat) -> Option<&'static str> {
match format {
AudioFileFormat::OGG_VORBIS_96
| AudioFileFormat::OGG_VORBIS_160
| AudioFileFormat::OGG_VORBIS_320 => Some(".ogg"),
AudioFileFormat::MP3_96
| AudioFileFormat::MP3_160
| AudioFileFormat::MP3_160_ENC
| AudioFileFormat::MP3_256
| AudioFileFormat::MP3_320 => Some(".mp3"),
AudioFileFormat::AAC_24
| AudioFileFormat::AAC_48
| AudioFileFormat::AAC_160
| AudioFileFormat::AAC_320 => Some(".aac"),
AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256 => Some(".m4a"),
AudioFileFormat::FLAC_FLAC => Some(".flac"),
AudioFileFormat::UNKNOWN_FORMAT => None,
}
}
pub fn audio_format_mime(format: AudioFileFormat) -> Option<&'static str> {
match format {
AudioFileFormat::OGG_VORBIS_96
| AudioFileFormat::OGG_VORBIS_160
| AudioFileFormat::OGG_VORBIS_320 => Some("audio/ogg"),
AudioFileFormat::MP3_96
| AudioFileFormat::MP3_160
| AudioFileFormat::MP3_160_ENC
| AudioFileFormat::MP3_256
| AudioFileFormat::MP3_320 => Some("audio/mp3"),
AudioFileFormat::AAC_24
| AudioFileFormat::AAC_48
| AudioFileFormat::AAC_160
| AudioFileFormat::AAC_320 => Some("audio/aac"),
AudioFileFormat::MP4_128 | AudioFileFormat::MP4_256 => Some("audio/mp4"),
AudioFileFormat::FLAC_FLAC => Some("audio/flac"),
AudioFileFormat::UNKNOWN_FORMAT => None,
}
}
/// Get the bitrate of the audio format in kbit/s
pub fn audio_format_bitrate(format: AudioFileFormat) -> Option<u32> {
match format {
AudioFileFormat::OGG_VORBIS_96 | AudioFileFormat::MP3_96 => Some(96000),
AudioFileFormat::OGG_VORBIS_160
| AudioFileFormat::MP3_160
| AudioFileFormat::MP3_160_ENC => Some(160000),
AudioFileFormat::OGG_VORBIS_320 | AudioFileFormat::MP3_320 => Some(320000),
AudioFileFormat::MP4_128 | AudioFileFormat::AAC_160 => Some(128000),
AudioFileFormat::MP3_256 | AudioFileFormat::MP4_256 | AudioFileFormat::AAC_320 => {
Some(256000)
}
AudioFileFormat::AAC_24 => Some(24000),
AudioFileFormat::AAC_48 => Some(48000),
AudioFileFormat::FLAC_FLAC | AudioFileFormat::UNKNOWN_FORMAT => None,
}
}
pub fn audio_format_available(format: AudioFileFormat, is_premium: bool) -> bool {
match format {
AudioFileFormat::OGG_VORBIS_96
| AudioFileFormat::OGG_VORBIS_160
| AudioFileFormat::MP3_160
| AudioFileFormat::MP3_96
| AudioFileFormat::MP3_160_ENC
| AudioFileFormat::AAC_24
| AudioFileFormat::AAC_48
| AudioFileFormat::AAC_160
| AudioFileFormat::MP4_128 => true,
AudioFileFormat::OGG_VORBIS_320
| AudioFileFormat::MP3_256
| AudioFileFormat::MP3_320
| AudioFileFormat::AAC_320
| AudioFileFormat::MP4_256
| AudioFileFormat::FLAC_FLAC => is_premium,
AudioFileFormat::UNKNOWN_FORMAT => false,
}
}
/// Return true if the audio format uses Widevine
pub fn audio_format_widevine(format: AudioFileFormat) -> bool {
matches!(
format,
AudioFileFormat::MP4_128
| AudioFileFormat::MP4_256
| AudioFileFormat::AAC_160
| AudioFileFormat::AAC_320
| AudioFileFormat::AAC_24
| AudioFileFormat::AAC_48
)
}

View file

@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct ClientTokenRequest<'a> {
pub client_data: ClientTokenRequestData<'a>,
}
#[derive(Debug, Serialize)]
pub struct ClientTokenRequestData<'a> {
pub client_version: &'a str,
pub client_id: &'a str,
pub js_sdk_data: ClientTokenRequestJsData<'a>,
}
#[derive(Debug, Serialize)]
pub struct ClientTokenRequestJsData<'a> {
pub device_brand: &'a str,
pub device_model: &'a str,
pub os: &'a str,
pub device_id: String,
pub device_type: &'a str,
}
impl Default for ClientTokenRequest<'_> {
fn default() -> Self {
Self {
client_data: ClientTokenRequestData {
client_version: crate::util::SPOTIFY_VERSION,
client_id: crate::util::SPOTIFY_CLIENT_ID,
js_sdk_data: ClientTokenRequestJsData {
device_brand: "unknown",
device_model: "unknown",
os: "linux",
device_id: uuid::Uuid::new_v4().as_simple().to_string(),
device_type: "computer",
},
},
}
}
}
#[derive(Debug, Deserialize)]
pub struct ClientTokenResponse {
pub granted_token: ClientTokenResponseToken,
}
#[derive(Debug, Deserialize)]
pub struct ClientTokenResponseToken {
pub token: String,
pub expires_after_seconds: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerTimeResponse {
pub server_time: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthTokenResponse {
pub access_token: String,
pub access_token_expiration_timestamp_ms: u64,
}