use std::time::SystemTime;
use cookie_store::RawCookie;
use futures_util::TryFutureExt;
use md5::{Digest, Md5};
use reqwest::{
self,
header::{AUTHORIZATION, HeaderMap, HeaderValue},
};
use serde::Deserialize;
use url::Url;
use crate::{
arl::Arl,
config::{Config, Credentials},
error::{Error, ErrorKind, Result},
http::Client as HttpClient,
protocol::{
self, Codec, auth,
connect::{
AudioQuality, UserId,
queue::{self},
},
gateway::{
self, MediaUrl, Queue, Response, UserData,
list_data::{
ListData,
episodes::{self, EpisodeData},
livestream::{self, LivestreamData},
songs::{self, SongData},
},
user_radio::{self, UserRadio},
},
},
tokens::UserToken,
};
pub struct Gateway {
http_client: HttpClient,
user_data: Option<UserData>,
client_id: usize,
}
impl Gateway {
const COOKIE_ORIGIN: &'static str = "https://deezer.com";
const COOKIE_DOMAIN: &'static str = ".deezer.com";
const LANG_COOKIE: &'static str = "dz_lang";
const ARL_COOKIE: &'static str = "arl";
const JWT_AUTH_URL: &'static str = "https://auth.deezer.com";
const JWT_ENDPOINT_LOGIN: &'static str = "/login/arl";
const JWT_ENDPOINT_RENEW: &'static str = "/login/renew";
const JWT_ENDPOINT_LOGOUT: &'static str = "/logout";
const GATEWAY_URL: &'static str = "https://www.deezer.com/ajax/gw-light.php";
const GATEWAY_VERSION: &'static str = "1.0";
const GATEWAY_INPUT: usize = 3;
const OAUTH_CLIENT_ID: usize = 447_462;
const OAUTH_SALT: &'static str = "a83bf7f38ad2f137e444727cfc3775cf";
const OAUTH_SID_URL: &'static str = "https://connect.deezer.com/oauth/auth.php";
const OAUTH_LOGIN_URL: &'static str = "https://connect.deezer.com/oauth/user_auth.php";
const EMPTY_JSON_OBJECT: &'static str = "{}";
#[must_use]
fn cookie_origin() -> reqwest::Url {
reqwest::Url::parse(Self::COOKIE_ORIGIN).expect("invalid cookie origin")
}
fn cookie_jar(config: &Config) -> Result<reqwest_cookie_store::CookieStore> {
let mut cookie_jar = reqwest_cookie_store::CookieStore::new(None);
let cookie_origin = Self::cookie_origin();
let lang_cookie = RawCookie::build((Self::LANG_COOKIE, &config.app_lang))
.domain(Self::COOKIE_DOMAIN)
.path("/")
.secure(true)
.http_only(true)
.build();
if let Err(e) = cookie_jar.insert_raw(&lang_cookie, &cookie_origin) {
error!("unable to insert language cookie: {e}");
}
if let Credentials::Arl(ref arl) = config.credentials {
let arl_cookie = RawCookie::build((Self::ARL_COOKIE, arl.as_str()))
.domain(Self::COOKIE_DOMAIN)
.path("/")
.secure(true)
.http_only(true)
.build();
if let Err(e) = cookie_jar.insert_raw(&arl_cookie, &cookie_origin) {
return Err(crate::error::Error::invalid_argument(format!(
"failed to insert ARL cookie: {e}"
)));
}
}
Ok(cookie_jar)
}
pub fn new(config: &Config) -> Result<Self> {
let cookie_jar = Self::cookie_jar(config)?;
let http_client = HttpClient::with_cookies(config, cookie_jar)?;
Ok(Self {
client_id: config.client_id,
http_client,
user_data: None,
})
}
#[must_use]
pub fn cookies(&self) -> Option<reqwest_cookie_store::CookieStore> {
self.http_client
.cookie_jar
.as_ref()
.map(|jar| jar.lock().expect("cookie mutex was poisoned").clone())
}
pub async fn refresh(&mut self) -> Result<()> {
match self
.request::<UserData>(Self::EMPTY_JSON_OBJECT, None)
.await
{
Ok(response) => {
if let Some(data) = response.first() {
if data.gatekeeps.remote_control.is_some_and(|remote| !remote) {
return Err(Error::permission_denied(
"remote control is disabled for this account; upgrade your Deezer subscription",
));
}
if data.user.options.too_many_devices {
return Err(Error::resource_exhausted(
"too many devices; remove one or more in your account settings",
));
}
if data.user.options.ads_audio {
return Err(Error::unimplemented(
"ads are not implemented; upgrade your Deezer subscription",
));
}
self.set_user_data(data.clone());
} else {
return Err(Error::not_found("no user data received".to_string()));
}
Ok(())
}
Err(e) => {
if e.kind == ErrorKind::InvalidArgument {
return Err(Error::permission_denied(
"arl invalid or expired".to_string(),
));
}
Err(e)
}
}
}
pub async fn request<T>(
&mut self,
body: impl Into<reqwest::Body>,
headers: Option<HeaderMap>,
) -> Result<Response<T>>
where
T: std::fmt::Debug + gateway::Method + for<'de> Deserialize<'de>,
{
let api_token = self
.user_data
.as_ref()
.map(|data| data.api_token.as_str())
.unwrap_or_default();
let url_str = format!(
"{}?method={}&input={}&api_version={}&api_token={api_token}&cid={}",
Self::GATEWAY_URL,
T::METHOD,
Self::GATEWAY_INPUT,
Self::GATEWAY_VERSION,
self.client_id,
);
let url = url_str.parse::<reqwest::Url>()?;
let mut request = self.http_client.text(url, body);
if let Some(headers) = headers {
request.headers_mut().extend(headers);
}
let response = self.http_client.execute(request).await?;
let body = response.text().await?;
protocol::json(&body, T::METHOD)
}
#[must_use]
#[inline]
pub fn license_token(&self) -> Option<&str> {
self.user_data
.as_ref()
.map(|data| data.user.options.license_token.as_str())
}
#[must_use]
#[inline]
pub fn is_expired(&self) -> bool {
self.expires_at() <= SystemTime::now()
}
#[must_use]
#[inline]
pub fn expires_at(&self) -> SystemTime {
if let Some(data) = &self.user_data {
return data.user.options.expiration_timestamp;
}
SystemTime::UNIX_EPOCH
}
#[inline]
pub fn set_user_data(&mut self, data: UserData) {
self.user_data = Some(data);
}
#[must_use]
#[inline]
pub fn user_data(&self) -> Option<&UserData> {
self.user_data.as_ref()
}
#[must_use]
pub fn audio_quality(&self) -> AudioQuality {
self.user_data
.as_ref()
.map_or(AudioQuality::default(), |data| {
data.user.audio_settings.connected_device_streaming_preset
})
}
#[must_use]
#[expect(clippy::cast_possible_truncation)]
pub fn target_gain(&self) -> i8 {
self.user_data
.as_ref()
.map(|data| data.gain)
.unwrap_or_default()
.target
.clamp(i64::from(i8::MIN), i64::from(i8::MAX)) as i8
}
#[must_use]
#[inline]
pub fn user_name(&self) -> Option<&str> {
self.user_data.as_ref().map(|data| data.user.name.as_str())
}
#[must_use]
pub fn media_url(&self) -> Url {
self.user_data
.as_ref()
.map_or(MediaUrl::default(), |data| data.media_url.clone())
.into()
}
pub async fn list_to_queue(&mut self, list: &queue::List) -> Result<Queue> {
let ids = list
.tracks
.iter()
.map(|track| track.id.parse().map_err(Error::from))
.collect::<std::result::Result<Vec<_>, _>>()?;
if let Some(first) = list.tracks.first() {
let response: Response<ListData> = match first.typ.enum_value_or_default() {
queue::TrackType::TRACK_TYPE_SONG => {
let songs = songs::Request { song_ids: ids };
let request = serde_json::to_string(&songs)?;
self.request::<SongData>(request, None)
.map_ok(Into::into)
.await?
}
queue::TrackType::TRACK_TYPE_EPISODE => {
let episodes = episodes::Request { episode_ids: ids };
let request = serde_json::to_string(&episodes)?;
self.request::<EpisodeData>(request, None)
.map_ok(Into::into)
.await?
}
queue::TrackType::TRACK_TYPE_LIVE => {
let radio = livestream::Request {
livestream_id: first.id.parse()?,
supported_codecs: vec![Codec::ADTS, Codec::MP3],
};
let request = serde_json::to_string(&radio)?;
self.request::<LivestreamData>(request, None)
.map_ok(Into::into)
.await?
}
queue::TrackType::TRACK_TYPE_CHAPTER => {
return Err(Error::unimplemented(
"audio books not implemented - report what you were trying to play to the developers",
));
}
};
Ok(response.all().clone())
} else {
Ok(Queue::default())
}
}
pub async fn user_radio(&mut self, user_id: UserId) -> Result<Queue> {
let request = user_radio::Request { user_id };
let body = serde_json::to_string(&request)?;
match self.request::<UserRadio>(body, None).await {
Ok(response) => {
Ok(response
.all()
.clone()
.into_iter()
.map(|item| item.0)
.collect())
}
Err(e) => Err(e),
}
}
pub async fn get_arl(&mut self, access_token: &str) -> Result<Arl> {
let mut headers = HeaderMap::new();
headers.try_insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {access_token}"))?,
)?;
let arl = self
.request::<gateway::Arl>(Self::EMPTY_JSON_OBJECT, Some(headers))
.await
.and_then(|response| {
response
.first()
.map(|result| result.0.clone())
.ok_or_else(|| Error::not_found("no arl received".to_string()))
})?;
arl.parse::<Arl>()
}
pub async fn user_token(&mut self) -> Result<UserToken> {
if self.is_expired() {
debug!("refreshing user token");
self.refresh().await?;
}
match &self.user_data {
Some(data) => Ok(UserToken {
user_id: data.user.id,
token: data.user_token.clone(),
expires_at: self.expires_at(),
}),
None => Err(Error::unavailable("user data unavailable".to_string())),
}
}
#[inline]
pub fn flush_user_token(&mut self) {
if let Some(data) = self.user_data.as_mut() {
data.user.options.expiration_timestamp = SystemTime::UNIX_EPOCH;
}
}
pub async fn oauth(&mut self, email: &str, password: &str) -> Result<Arl> {
const LENGTH_CHECK: std::ops::Range<usize> = 1..255;
if !LENGTH_CHECK.contains(&email.len()) || !LENGTH_CHECK.contains(&password.len()) {
return Err(Error::out_of_range(
"email and password must be between 1 and 255 characters".to_string(),
));
}
let password = Md5::digest(password);
let hash = Md5::digest(format!(
"{}{email}{password:x}{}",
Self::OAUTH_CLIENT_ID,
Self::OAUTH_SALT,
));
let request = self.http_client.get(Url::parse(Self::OAUTH_SID_URL)?, "");
self.http_client.execute(request).await?;
let query = Url::parse_with_params(
Self::OAUTH_LOGIN_URL,
&[
("app_id", Self::OAUTH_CLIENT_ID.to_string()),
("login", email.to_string()),
("password", format!("{password:x}")),
("hash", format!("{hash:x}")),
],
)?;
let request = self.http_client.get(query.clone(), "");
let response = self.http_client.execute(request).await?;
let body = response.text().await?;
let result: auth::User = protocol::json(&body, query.path())
.map_err(|_| Error::permission_denied("email or password incorrect"))?;
self.get_arl(&result.access_token).await
}
pub async fn login_with_arl(&mut self, arl: &Arl) -> Result<()> {
let query = Url::parse_with_params(
&format!("{}{}", Self::JWT_AUTH_URL, Self::JWT_ENDPOINT_LOGIN),
&[("jo", "p"), ("rto", "c"), ("i", "p")],
)?;
let auth = auth::Jwt {
arl: arl.to_string(),
account_id: self.user_token().await?.user_id.to_string(),
};
let request = self.http_client.json(query, serde_json::to_string(&auth)?);
self.http_client.execute(request).await?;
Ok(())
}
pub async fn renew_login(&mut self) -> Result<()> {
let query = Url::parse_with_params(
&format!("{}{}", Self::JWT_AUTH_URL, Self::JWT_ENDPOINT_RENEW),
&[("jo", "p"), ("rto", "c"), ("i", "c")],
)?;
let request = self.http_client.json(query, Self::EMPTY_JSON_OBJECT);
self.http_client.execute(request).await?;
Ok(())
}
pub async fn logout(&mut self) -> Result<()> {
let query = Url::parse(&format!(
"{}{}",
Self::JWT_AUTH_URL,
Self::JWT_ENDPOINT_LOGOUT
))?;
let request = self.http_client.get(query, "");
self.http_client.execute(request).await?;
Ok(())
}
}