deezer / src /main.rs
recycleactor's picture
Upload 2 files
3dbb76e verified
Raw
History Blame
101 kB
use axum::{
routing::{get, post},
http::StatusCode,
http::{HeaderMap, Method, header::{RANGE, CONTENT_RANGE, ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_TYPE}},
response::{IntoResponse, Html},
extract::{Json, State, Query, Form, Host},
Router,
};
use tower_http::{cors::{CorsLayer, Any}, compression::CompressionLayer, trace::TraceLayer};
use serde_json::json;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use bytes::Bytes;
use futures_util::StreamExt;
use axum::body::Body;
use axum::http::HeaderValue;
use blowfish::Blowfish;
use cipher::{KeyIvInit, BlockDecryptMut};
use cipher::block_padding::NoPadding;
use cbc::Decryptor;
use reqwest::{cookie::Jar, Url};
use reqwest::cookie::CookieStore;
use reqwest::header::ACCEPT;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex, RwLock};
type BoxErr = Box<dyn std::error::Error + Send + Sync + 'static>;
mod api;
use api::{APIClient, APIError, Format};
#[derive(Clone)]
struct ArlSession {
check_form: String,
license_token: String,
}
#[derive(Clone)]
struct AppState {
api: Arc<Mutex<APIClient>>,
api_by_arl: Arc<RwLock<HashMap<String, Arc<Mutex<APIClient>>>>>,
arl_sessions: Arc<RwLock<HashMap<String, ArlSession>>>,
pair: Arc<RwLock<HashMap<String, PairSession>>>,
pair_store_path: String,
}
fn license_token_from_ud(ud: &serde_json::Value) -> String {
ud.pointer("/USER/OPTIONS/license_token")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string()
}
async fn store_arl_session(state: &AppState, arl: &str, check_form: &str, license_token: &str) {
if arl.len() < 20 {
return;
}
let mut map = state.arl_sessions.write().await;
if map.len() >= 64 {
map.clear();
}
map.insert(
arl.to_string(),
ArlSession {
check_form: check_form.to_string(),
license_token: license_token.to_string(),
},
);
}
fn json_error(status: StatusCode, message: impl Into<String>) -> axum::response::Response {
(status, Json(json!({ "error": message.into() }))).into_response()
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
async fn api_client_for_arl(state: &AppState, arl: Option<String>) -> Arc<Mutex<APIClient>> {
let Some(arl) = arl.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) else {
return state.api.clone();
};
let mut map = state.api_by_arl.write().await;
if let Some(c) = map.get(&arl) {
return c.clone();
}
if map.len() >= 32 {
map.clear();
}
let session = state.arl_sessions.read().await.get(&arl).cloned();
let client = if let Some(s) = session {
APIClient::new_with_arl_session(arl.clone(), s.check_form, s.license_token)
} else {
APIClient::new_with_arl(arl.clone())
};
let c = Arc::new(Mutex::new(client));
map.insert(arl, c.clone());
c
}
fn extract_media_url(media_json: &serde_json::Value) -> String {
let data = media_json.get("data").and_then(|v| v.as_array());
let Some(data0) = data.and_then(|a| a.get(0)) else {
return String::new();
};
let media_arr = data0.get("media").and_then(|v| v.as_array());
let Some(media_arr) = media_arr else {
return String::new();
};
for m in media_arr {
if let Some(sources) = m.get("sources").and_then(|v| v.as_array()) {
for s in sources {
if let Some(url) = s.get("url").and_then(|v| v.as_str()) {
let url = url.trim();
if !url.is_empty() {
return url.to_string();
}
}
}
}
}
String::new()
}
fn extract_media_format(media_json: &serde_json::Value) -> String {
let data = media_json.get("data").and_then(|v| v.as_array());
let Some(data0) = data.and_then(|a| a.get(0)) else {
return String::new();
};
let media_arr = data0.get("media").and_then(|v| v.as_array());
let Some(media_arr) = media_arr else {
return String::new();
};
for m in media_arr {
let has_url = m
.get("sources")
.and_then(|v| v.as_array())
.and_then(|a| a.get(0))
.and_then(|s| s.get("url"))
.and_then(|u| u.as_str())
.map(|u| !u.trim().is_empty())
.unwrap_or(false);
if !has_url {
continue;
}
if let Some(s) = m.get("format").and_then(|v| v.as_str()) {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
if let Some(s) = m.get("format_name").and_then(|v| v.as_str()) {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
if let Some(n) = m.get("format").and_then(|v| v.as_i64()) {
return n.to_string();
}
}
String::new()
}
fn total_from_content_range(v: &str) -> Option<u64> {
let v = v.trim();
let (_, rest) = v.split_once('/')?;
if rest.trim() == "*" {
return None;
}
rest.trim().parse::<u64>().ok()
}
fn media_response_err(json: &serde_json::Value) -> String {
if let Some(errs) = json.get("errors").and_then(|v| v.as_array()) {
if let Some(first) = errs.first() {
let s = first.to_string();
return s.chars().take(160).collect();
}
}
json.to_string().chars().take(160).collect()
}
async fn fetch_media_url(
client: &APIClient,
formats: &[Format],
track_token: &str,
) -> Result<String, String> {
if client.license_token.is_empty() {
return Err("license_token empty".to_string());
}
let media_resp = client
.get_media(&formats.to_vec(), vec![track_token])
.await
.map_err(|e| e.to_string())?;
let status = media_resp.status();
let media_json: serde_json::Value = media_resp
.json()
.await
.map_err(|_| "Bad media response".to_string())?;
let url = extract_media_url(&media_json);
if !url.is_empty() {
return Ok(url);
}
Err(format!(
"media:{}:{}",
status.as_u16(),
media_response_err(&media_json)
))
}
async fn media_url_for_track(
client: &mut APIClient,
id: u32,
formats: &Vec<Format>,
) -> Result<String, String> {
if client.license_token.is_empty() {
if let Err(e) = client.force_renew().await {
return Err(format!("renew:{e}"));
}
}
let resp: Result<DeezerTrackList, APIError> = client
.api_call(
"song.getListData",
&json!({"sng_ids":[id],"array_default":["SNG_ID","TRACK_TOKEN","DURATION"]}),
)
.await;
let track_list = resp.map_err(|e| e.to_string())?;
if track_list.data.is_empty() {
return Err("No valid ID".to_string());
}
let track_token = track_list.data[0].TRACK_TOKEN.as_str();
if track_token.is_empty() {
return Err("empty TRACK_TOKEN".to_string());
}
let mut last_err = String::from("no_url");
for fmt in formats {
match fetch_media_url(client, std::slice::from_ref(fmt), track_token).await {
Ok(url) => return Ok(url),
Err(e) => last_err = e,
}
}
if formats.len() > 1 {
if let Ok(url) = fetch_media_url(client, formats, track_token).await {
return Ok(url);
}
}
Err(last_err)
}
fn upstream_dzmedia_base() -> Option<String> {
let env = std::env::var("DZMEDIA_UPSTREAM").ok().unwrap_or_default();
let base = env.trim();
// Если переменная не задана — upstream отключён (используем только ARL)
// Чтобы включить upstream — задайте DZMEDIA_UPSTREAM=https://your-instance.fly.dev
if base.is_empty() || base == "-" {
return None;
}
Some(base.trim_end_matches('/').to_string())
}
async fn upstream_get_url_text(formats: &Vec<Format>, ids: &Vec<u32>) -> Result<String, String> {
let Some(base) = upstream_dzmedia_base() else {
return Err("upstream:disabled".to_string());
};
let url = format!("{base}/get_url");
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.build()
.map_err(|_| "upstream:client".to_string())?;
let r = client
.post(url)
.json(&json!({ "formats": formats, "ids": ids }))
.send()
.await
.map_err(|_| "upstream:network".to_string())?;
let status = r.status();
let text = r.text().await.map_err(|_| "upstream:read".to_string())?;
if !status.is_success() {
let snip: String = text.chars().take(200).collect();
return Err(format!("upstream:{}:{}", status.as_u16(), snip));
}
Ok(text)
}
async fn upstream_media_url(id: u32, formats: &Vec<Format>) -> Result<String, String> {
let text = upstream_get_url_text(formats, &vec![id]).await?;
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "upstream:parse".to_string())?;
let url = extract_media_url(&v);
if url.is_empty() {
return Err("upstream:no_url".to_string());
}
Ok(url)
}
async fn public_track_duration(id: u32) -> Option<u32> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok()?;
let r = client
.get(format!("https://api.deezer.com/track/{id}"))
.send()
.await
.ok()?;
if !r.status().is_success() {
return None;
}
let v: serde_json::Value = r.json().await.ok()?;
v.get("duration").and_then(|d| d.as_u64()).map(|d| d as u32)
}
#[derive(Deserialize)]
struct DeezerTrackList {
data: Vec<DeezerTrack>
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct DeezerTrack {
TRACK_TOKEN: String,
DURATION: Option<u32>
}
async fn root() -> &'static str {
"marecchione gay af"
}
#[derive(Debug, Deserialize)]
struct RequestParams {
formats: Vec<Format>,
ids: Vec<u32>,
arl: Option<String>,
}
async fn get_url(State(state): State<AppState>, Json(req): Json<RequestParams>) -> impl IntoResponse {
if req.formats.is_empty() {
return (StatusCode::BAD_REQUEST, "Format list cannot be empty".to_string());
}
if req.ids.is_empty() {
return (StatusCode::BAD_REQUEST, "ID list cannot be empty".to_string());
}
let arl = req.arl.clone().unwrap_or_default().trim().to_string();
if arl.is_empty() {
if let Ok(t) = upstream_get_url_text(&req.formats, &req.ids).await {
return (StatusCode::OK, t);
}
}
let client = api_client_for_arl(&state, req.arl.clone()).await;
let media_resp = {
let mut client = client.lock().await;
let resp: Result<DeezerTrackList, APIError> = client
.api_call(
"song.getListData",
&json!({"sng_ids":req.ids,"array_default":["SNG_ID","TRACK_TOKEN"]}),
)
.await;
let track_list = match resp {
Ok(t) => t,
Err(e) => return (StatusCode::SERVICE_UNAVAILABLE, e.to_string()),
};
if track_list.data.is_empty() {
return (StatusCode::BAD_REQUEST, "No valid IDs found".to_string());
}
let track_tokens: Vec<&str> = track_list
.data
.iter()
.map(|t| t.TRACK_TOKEN.as_str())
.collect();
match client.get_media(&req.formats, track_tokens).await {
Ok(r) => r,
Err(e) => return (StatusCode::SERVICE_UNAVAILABLE, e.to_string()),
}
};
match media_resp.text().await {
Ok(t) => (StatusCode::OK, t),
Err(e) => (StatusCode::SERVICE_UNAVAILABLE, e.to_string()),
}
}
#[derive(Debug, Deserialize)]
struct FetchParams {
url: String,
}
async fn fetch(Query(q): Query<FetchParams>) -> impl IntoResponse {
let parsed = match q.url.parse::<Url>() {
Ok(u) => u,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid url".to_string()).into_response(),
};
let host = parsed.host_str().unwrap_or("");
if !(host.ends_with(".dzcdn.net")) {
return (StatusCode::FORBIDDEN, "Host not allowed".to_string()).into_response();
}
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap();
let resp = match client.get(parsed).send().await {
Ok(r) => r,
Err(_) => return (StatusCode::BAD_GATEWAY, "Upstream request failed".to_string()).into_response(),
};
let status = resp.status();
if !status.is_success() {
return (StatusCode::BAD_GATEWAY, format!("Upstream status: {}", status)).into_response();
}
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(_) => return (StatusCode::BAD_GATEWAY, "Upstream read failed".to_string()).into_response(),
};
(
StatusCode::OK,
[(CONTENT_TYPE, "audio/mpeg")],
Bytes::from(bytes),
)
.into_response()
}
const SECRET: &str = "g4el58wc0zvf9na1";
fn blowfish_key(track_id: u32) -> [u8; 16] {
let md5hex = format!("{:x}", md5::compute(track_id.to_string().as_bytes()));
let md5hex = md5hex.as_bytes();
let secret = SECRET.as_bytes();
let mut out = [0u8; 16];
for i in 0..16 {
out[i] = md5hex[i] ^ md5hex[i + 16] ^ secret[i];
}
out
}
fn decrypt_stripe(block: &mut [u8], key: &[u8; 16]) -> Result<(), ()> {
let iv = [0u8, 1, 2, 3, 4, 5, 6, 7];
let dec = Decryptor::<Blowfish>::new_from_slices(key, &iv).map_err(|_| ())?;
dec.decrypt_padded_mut::<NoPadding>(block).map_err(|_| ())?;
Ok(())
}
#[derive(Debug, Deserialize)]
struct StreamParams {
id: u32,
format: Option<String>,
arl: Option<String>,
}
async fn stream_info(State(state): State<AppState>, Query(q): Query<StreamParams>) -> impl IntoResponse {
let formats = match q.format.as_deref().unwrap_or("AUTO") {
"FLAC" => vec![Format::FLAC, Format::MP3_320, Format::MP3_128],
"MP3_320" => vec![Format::MP3_320, Format::MP3_128],
"MP3_MISC" => vec![Format::MP3_MISC, Format::MP3_128],
"MP3_128" => vec![Format::MP3_128],
_ => vec![Format::FLAC, Format::MP3_320, Format::MP3_128],
};
let requested = q.format.clone().unwrap_or_else(|| "AUTO".to_string());
let mut url: String = String::new();
let mut used: String = String::new();
let mut last_err: Option<String> = None;
// Upstream (опционально — если DZMEDIA_UPSTREAM задан)
if let Ok(text) = upstream_get_url_text(&formats, &vec![q.id]).await {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
url = extract_media_url(&v);
used = extract_media_format(&v);
}
}
// ARL путь — до 2 попыток
if url.is_empty() {
if let Some(arl) = q.arl.clone().filter(|s| !s.trim().is_empty()) {
'arl: for attempt in 0..2u8 {
if attempt == 1 {
state.api_by_arl.write().await.remove(&arl);
}
let client = api_client_for_arl(&state, Some(arl.clone())).await;
let res = {
let mut c = client.lock().await;
media_url_for_track(&mut c, q.id, &formats).await
};
match res {
Ok(u) => { url = u; break 'arl; }
Err(e) => { last_err = Some(e); }
}
}
}
}
if url.is_empty() {
return json_error(
StatusCode::SERVICE_UNAVAILABLE,
last_err.unwrap_or_else(|| "stream_info:no_url".to_string()),
);
}
let duration = public_track_duration(q.id).await.unwrap_or(0);
let http = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let r = http
.get(url.clone())
.header(reqwest::header::RANGE, "bytes=0-0")
.send()
.await;
let mut total_bytes: u64 = 0;
let mut mime: String = String::new();
if let Ok(r) = r {
if let Some(ct) = r.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()) {
mime = ct.to_string();
}
if let Some(cr) = r.headers().get(reqwest::header::CONTENT_RANGE)
.and_then(|v| v.to_str().ok())
.and_then(total_from_content_range)
{
total_bytes = cr;
} else if let Some(cl) = r.headers().get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
{
total_bytes = cl.parse::<u64>().unwrap_or(0);
}
}
let bitrate_kbps = if duration > 0 && total_bytes > 0 {
((total_bytes as f64) * 8.0 / (duration as f64) / 1000.0).round() as u64
} else { 0 };
(StatusCode::OK, Json(json!({
"id": q.id,
"requested": requested,
"used": used,
"duration": duration,
"bytes": total_bytes,
"bitrate_kbps": bitrate_kbps,
"mime": mime
}))).into_response()
}
async fn stream(
State(state): State<AppState>,
Query(q): Query<StreamParams>,
headers: HeaderMap,
) -> impl IntoResponse {
let formats = match q.format.as_deref().unwrap_or("AUTO") {
"FLAC" => vec![Format::FLAC, Format::MP3_320, Format::MP3_128],
"MP3_320" => vec![Format::MP3_320, Format::MP3_128],
"MP3_MISC" => vec![Format::MP3_MISC, Format::MP3_128],
"MP3_128" => vec![Format::MP3_128],
_ => vec![Format::FLAC, Format::MP3_320, Format::MP3_128],
};
let arl_key = q.arl.clone()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let mut url: Option<String> = None;
let mut last_err: Option<String> = None;
// Если ARL не передан — пробуем upstream (может быть отключён)
if arl_key.is_none() {
match upstream_media_url(q.id, &formats).await {
Ok(u) => url = Some(u),
Err(e) => last_err = Some(e),
}
}
// До 3 попыток: 0 — renew+запрос, 1 — сброс кэша клиента, 2 — ещё раз renew
for attempt in 0..3u8 {
if url.is_some() { break; }
if attempt >= 1 {
if let Some(ref arl) = arl_key {
state.api_by_arl.write().await.remove(arl);
} else {
*state.api.lock().await = APIClient::new();
}
}
let client = api_client_for_arl(&state, arl_key.clone()).await;
let res = {
let mut c = client.lock().await;
if attempt >= 1 {
if let Err(e) = c.force_renew().await {
last_err = Some(format!("renew:{}", e));
continue;
}
}
media_url_for_track(&mut c, q.id, &formats).await
};
match res {
Ok(u) => { url = Some(u); break; }
Err(e) => { last_err = Some(e); }
}
}
let url = match url {
Some(u) => u,
None => return (
StatusCode::SERVICE_UNAVAILABLE,
last_err.unwrap_or_else(|| "stream:no_url".to_string()),
).into_response(),
};
fn parse_range_header(v: &str) -> Option<(u64, Option<u64>)> {
let v = v.trim();
let v = v.strip_prefix("bytes=")?;
let mut it = v.splitn(2, '-');
let a = it.next()?.trim();
let b = it.next().unwrap_or("").trim();
if a.is_empty() {
return None;
}
let start = a.parse::<u64>().ok()?;
let end = if b.is_empty() {
None
} else {
let e = b.parse::<u64>().ok()?;
Some(e)
};
Some((start, end))
}
fn parse_total_from_content_range(v: &str) -> Option<u64> {
let v = v.trim();
let (_, rest) = v.split_once('/')?;
if rest.trim() == "*" {
return None;
}
rest.trim().parse::<u64>().ok()
}
let req_range = headers
.get(RANGE)
.and_then(|v| v.to_str().ok())
.and_then(parse_range_header);
let http = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap();
let block_size: u64 = 2048;
let mut block_index: u64 = 0;
let mut drop_bytes: usize = 0;
let mut remaining: Option<u64> = None;
let mut req = http.get(url);
if let Some((start, end)) = req_range {
let aligned_start = start - (start % block_size);
drop_bytes = (start - aligned_start) as usize;
block_index = aligned_start / block_size;
if let Some(e) = end {
if e >= start {
remaining = Some(e - start + 1);
}
req = req.header(reqwest::header::RANGE, format!("bytes={}-{}", aligned_start, e));
} else {
req = req.header(reqwest::header::RANGE, format!("bytes={}-", aligned_start));
}
}
let upstream = match req.send().await {
Ok(r) => r,
Err(_) => {
return (StatusCode::SERVICE_UNAVAILABLE, "Upstream request failed".to_string())
.into_response()
}
};
if !upstream.status().is_success() {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Upstream status not OK".to_string(),
)
.into_response();
}
let key = blowfish_key(q.id);
let mut carry: Vec<u8> = Vec::with_capacity(4096);
let upstream_headers = upstream.headers().clone();
let upstream_total = upstream_headers
.get(reqwest::header::CONTENT_RANGE)
.and_then(|v| v.to_str().ok())
.and_then(parse_total_from_content_range);
let mut upstream = upstream.bytes_stream();
let out = async_stream::stream! {
let mut drop_bytes = drop_bytes;
let mut remaining = remaining;
let mut done = false;
while let Some(chunk_res) = upstream.next().await {
let chunk = match chunk_res {
Ok(c) => c,
Err(e) => {
yield Err::<Bytes, BoxErr>(Box::new(e));
break;
}
};
carry.extend_from_slice(&chunk);
while carry.len() >= 2048 {
let mut block = carry.drain(0..2048).collect::<Vec<u8>>();
if block_index % 3 == 0 {
let _ = decrypt_stripe(&mut block, &key);
}
block_index += 1;
if drop_bytes > 0 {
if drop_bytes >= block.len() {
drop_bytes -= block.len();
continue;
}
block = block.split_off(drop_bytes);
drop_bytes = 0;
}
if let Some(rem) = remaining {
if rem == 0 {
done = true;
break;
}
if (block.len() as u64) > rem {
block.truncate(rem as usize);
remaining = Some(0);
yield Ok::<Bytes, BoxErr>(Bytes::from(block));
done = true;
break;
} else {
remaining = Some(rem - block.len() as u64);
}
}
yield Ok::<Bytes, BoxErr>(Bytes::from(block));
}
if done { break; }
}
if !done && !carry.is_empty() {
let mut tail = std::mem::take(&mut carry);
if drop_bytes > 0 {
if drop_bytes < tail.len() {
tail = tail.split_off(drop_bytes);
} else {
tail.clear();
}
}
if !tail.is_empty() {
if let Some(rem) = remaining {
if rem > 0 {
if (tail.len() as u64) > rem {
tail.truncate(rem as usize);
}
yield Ok::<Bytes, BoxErr>(Bytes::from(tail));
}
} else {
yield Ok::<Bytes, BoxErr>(Bytes::from(tail));
}
}
}
};
let body = Body::from_stream(out);
let mut resp = axum::response::Response::new(body);
resp.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("audio/mpeg"));
resp.headers_mut()
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
if let Some((start, end)) = req_range {
if let Some(total) = upstream_total {
let end_for_hdr = end.unwrap_or_else(|| total.saturating_sub(1));
if end_for_hdr >= start {
let _ = resp.headers_mut().insert(
CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes {}-{}/{}", start, end_for_hdr, total))
.unwrap_or_else(|_| HeaderValue::from_static("bytes 0-0/*")),
);
let len = end_for_hdr.saturating_sub(start).saturating_add(1);
let _ = resp
.headers_mut()
.insert(CONTENT_LENGTH, HeaderValue::from_str(&len.to_string()).unwrap());
*resp.status_mut() = StatusCode::PARTIAL_CONTENT;
}
} else if let Some(end_for_hdr) = end {
if end_for_hdr >= start {
let _ = resp.headers_mut().insert(
CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes {}-{}/{}", start, end_for_hdr, "*"))
.unwrap_or_else(|_| HeaderValue::from_static("bytes 0-0/*")),
);
let len = end_for_hdr.saturating_sub(start).saturating_add(1);
let _ = resp
.headers_mut()
.insert(CONTENT_LENGTH, HeaderValue::from_str(&len.to_string()).unwrap());
*resp.status_mut() = StatusCode::PARTIAL_CONTENT;
}
}
}
resp
}
#[derive(Debug, Deserialize)]
struct UserDataReq {
arl: String,
}
async fn user_data(State(state): State<AppState>, Json(req): Json<UserDataReq>) -> impl IntoResponse {
let arl = req.arl.trim().to_string();
if arl.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "ARL required");
}
let client = api_client_for_arl(&state, Some(arl)).await;
let data = {
let mut client = client.lock().await;
match client.user_data().await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()),
}
};
let token = data["checkForm"].as_str().unwrap_or("").to_string();
let uid = data["USER"]["USER_ID"].as_i64().map(|n| n.to_string()).unwrap_or_default();
(StatusCode::OK, Json(json!({ "token": token, "uid": uid }))).into_response()
}
#[derive(Debug, Deserialize)]
struct PlaylistsReq {
arl: String,
start: Option<u32>,
nb: Option<u32>,
}
async fn playlists(State(state): State<AppState>, Json(req): Json<PlaylistsReq>) -> impl IntoResponse {
let arl = req.arl.trim().to_string();
if arl.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "ARL required");
}
let client = api_client_for_arl(&state, Some(arl)).await;
let res = {
let mut client = client.lock().await;
let ud = match client.user_data().await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()),
};
let uid = ud["USER"]["USER_ID"].as_i64().unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "No USER_ID");
}
let start = req.start.unwrap_or(0);
let nb = req.nb.unwrap_or(50);
let res: Result<serde_json::Value, APIError> = client
.api_call("playlist.getList", &json!({"user_id":uid,"nb":nb,"start":start}))
.await;
res
};
match res {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()),
}
}
#[derive(Debug, Deserialize)]
struct PlaylistTracksReq {
arl: String,
playlist_id: u64,
start: Option<u32>,
nb: Option<u32>,
}
async fn playlist_tracks(
State(state): State<AppState>,
Json(req): Json<PlaylistTracksReq>,
) -> impl IntoResponse {
let arl = req.arl.trim().to_string();
if arl.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "ARL required");
}
let client = api_client_for_arl(&state, Some(arl)).await;
let start = req.start.unwrap_or(0);
let nb = req.nb.unwrap_or(100);
let res: Result<serde_json::Value, APIError> = {
let mut client = client.lock().await;
let _ = client.user_data().await;
client
.api_call(
"playlist.getSongs",
&json!({
"playlist_id": req.playlist_id,
"start": start,
"nb": nb
}),
)
.await
};
match res {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()),
}
}
#[derive(Debug, Deserialize)]
struct LoginReq {
email: String,
password_md5: String,
}
#[derive(Deserialize)]
struct GwResp {
error: serde_json::Value,
results: serde_json::Value,
}
// ── Мобильный API Deezer ──────────────────────────────────────────────────
// Использует api.deezer.com/1.0/gateway.php с мобильным UA — не блокируется CF
const MOBILE_UA: &str = "Deezer/8.32.0.2 (iOS; 14.4; Mobile; en; iPhone10_5)";
const MOBILE_API_KEY: &str = "ZAIVAHCEISOHWAICUQUEXAEPICENGUAFAEZAIPHAELEEVAHPHUCUFONGUAPASUAY";
const MOBILE_GW: &str = "https://api.deezer.com/1.0/gateway.php";
/// Построить reqwest::Client с мобильным UA и нужными cookie
fn mobile_client_with_arl(arl: &str) -> reqwest::Client {
let jar = Arc::new(Jar::default());
let url = "https://api.deezer.com".parse::<Url>().unwrap();
if !arl.is_empty() {
jar.add_cookie_str(&format!("arl={}; Domain=.deezer.com", arl), &url);
jar.add_cookie_str(&format!("arl={}; Domain=.api.deezer.com", arl), &url);
}
reqwest::Client::builder()
.no_proxy()
.cookie_provider(jar)
.timeout(std::time::Duration::from_secs(20))
.build()
.unwrap()
}
/// Вызов мобильного gateway.php
async fn mobile_gw(
client: &reqwest::Client,
method: &str,
api_token: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let cid = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_else(|_| "0".to_string());
let r = client
.post(MOBILE_GW)
.query(&[
("method", method),
("api_version", "1.0"),
("api_token", api_token),
("input", "3"),
("output", "3"),
("cid", cid.as_str()),
("api_key", MOBILE_API_KEY),
])
.header("User-Agent", MOBILE_UA)
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "*/*")
.header("Accept-Language", "en-US")
.json(&body)
.send()
.await
.map_err(|e| format!("mobile_gw:network:{e}"))?;
let status = r.status();
let text = r.text().await.map_err(|_| "mobile_gw:read".to_string())?;
if !status.is_success() {
return Err(format!("mobile_gw:http:{}:{}", status.as_u16(), &text[..text.len().min(120)]));
}
let v: serde_json::Value = serde_json::from_str(&text)
.map_err(|_| format!("mobile_gw:parse:{}", &text[..text.len().min(120)]))?;
// Проверяем поле error
if let Some(err) = v.get("error") {
if let Some(obj) = err.as_object() {
if !obj.is_empty() {
let msg = obj.iter()
.map(|(k, v)| format!("{k}:{}", v.as_str().unwrap_or("?")))
.collect::<Vec<_>>()
.join(";");
return Err(format!("mobile_gw:deezer:{msg}"));
}
}
}
v.get("results")
.cloned()
.ok_or_else(|| format!("mobile_gw:no_results:{}", &text[..text.len().min(120)]))
}
/// Получить checkForm и SESSION через мобильный API (без ARL — анонимная сессия)
async fn mobile_get_user_data(client: &reqwest::Client) -> Result<serde_json::Value, String> {
mobile_gw(client, "deezer.getUserData", "null", json!({})).await
}
/// Логин по email+md5(password) через мобильный API — возвращает ARL
async fn mobile_login(email: &str, password_md5: &str) -> Result<String, String> {
let client = mobile_client_with_arl("");
// Шаг 1: получаем checkForm (анонимная сессия)
let ud = mobile_get_user_data(&client).await
.map_err(|e| format!("mobile:getUserData:{e}"))?;
let check_form = ud["checkForm"].as_str().unwrap_or("").to_string();
if check_form.is_empty() {
return Err("mobile:no_checkForm".to_string());
}
// Шаг 2: checkCredentials
let creds_result = mobile_gw(&client, "user.checkCredentials", &check_form, json!({
"login": email,
"password": password_md5,
"checkFormLogin": check_form
})).await;
// checkCredentials возвращает {} при успехе, ошибку при неверных данных
if let Err(e) = &creds_result {
// Неверный логин/пароль
return Err(format!("mobile:checkCredentials:{e}"));
}
// Шаг 3: после checkCredentials сессия аутентифицирована — получаем свежий checkForm
let ud2 = mobile_get_user_data(&client).await
.map_err(|e| format!("mobile:getUserData2:{e}"))?;
let check_form2 = ud2["checkForm"].as_str().unwrap_or("").to_string();
if check_form2.is_empty() {
return Err("mobile:no_checkForm2".to_string());
}
// Шаг 4: getArl
let arl_result = mobile_gw(&client, "user.getArl", &check_form2, json!({})).await
.map_err(|e| format!("mobile:getArl:{e}"))?;
let arl = arl_result.as_str().unwrap_or("").to_string();
if arl.len() < 100 {
return Err(format!("mobile:arl_too_short:{}", arl.len()));
}
Ok(arl)
}
const DEEZER_APP_SECRET: &str = "a83bf7f38ad2f137e444727cfc3775cf";
struct WebLoginResult {
arl: Option<String>,
token: String,
uid: String,
access_token: String,
license_token: String,
}
fn gw_browser_ua() -> &'static str {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
fn gw_cid() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_else(|_| "0".to_string())
}
fn capture_sid_from_response(jar: &Jar, url: &Url, headers: &reqwest::header::HeaderMap) {
for cookie_hdr in headers.get_all(reqwest::header::SET_COOKIE) {
if let Ok(s) = cookie_hdr.to_str() {
for part in s.split(',') {
let part = part.trim();
let name_val = part.split(';').next().unwrap_or("").trim();
if name_val.starts_with("sid=") {
jar.add_cookie_str(
&format!("{name_val}; Domain=.deezer.com; Path=/"),
url,
);
}
}
}
}
}
/// Deezer выдаёт анонимный sid в Set-Cookie ответа на GET user.getArl — нужен до OAuth.
async fn ensure_anonymous_sid(client: &reqwest::Client, jar: &Jar) -> Result<(), String> {
let url_deezer = "https://www.deezer.com".parse::<Url>().map_err(|_| "sid:url".to_string())?;
if jar_has_cookie(jar, &url_deezer, "sid=") {
return Ok(());
}
let cid = gw_cid();
let r = client
.get("https://www.deezer.com/ajax/gw-light.php")
.query(&[
("method", "user.getArl"),
("input", "3"),
("output", "3"),
("api_version", "1.0"),
("api_token", "null"),
("cid", cid.as_str()),
])
.header(ACCEPT, "*/*")
.header("Origin", "https://www.deezer.com")
.header("Referer", "https://www.deezer.com/")
.header("Sec-Fetch-Site", "same-origin")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Dest", "empty")
.header("User-Agent", gw_browser_ua())
.send()
.await
.map_err(|e| format!("sid:network:{e}"))?;
capture_sid_from_response(jar, &url_deezer, r.headers());
if !jar_has_cookie(jar, &url_deezer, "sid=") {
return Err("sid:not_set".to_string());
}
Ok(())
}
async fn gw_fetch_arl(
client: &reqwest::Client,
email: &str,
password_md5: &str,
user_id: Option<&str>,
) -> Result<(String, String, String, String), String> {
let mut last_err = String::from("getArl:none");
for attempt in 0..3u8 {
let ud = gw_light_call_custom(
client,
"deezer.getUserData",
"null",
None,
&json!({}),
None,
attempt > 0,
)
.await
.map_err(|e| format!("getUserData:{e}"))?;
let check_form = ud
.get("checkForm")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
if check_form.is_empty() {
last_err = "no_checkForm".to_string();
continue;
}
let uid = ud
.get("USER")
.and_then(|u| u.get("USER_ID"))
.and_then(|x| x.as_i64())
.map(|n| n.to_string())
.unwrap_or_default();
let uid_for_hdr = user_id
.filter(|s| !s.trim().is_empty())
.or_else(|| if uid.is_empty() { None } else { Some(uid.as_str()) });
match gw_light_call_custom(
client,
"user.getArl",
&check_form,
None,
&json!({}),
uid_for_hdr,
true,
)
.await
{
Ok(v) => {
let arl = v.as_str().unwrap_or("").to_string();
if arl.len() >= 100 {
let license_token = license_token_from_ud(&ud);
return Ok((arl, check_form, uid, license_token));
}
last_err = format!("arl_too_short:{}", arl.len());
}
Err(e) => {
last_err = e.clone();
if e.contains("NEED_USER_AUTH") && attempt == 0 {
let _ = gw_light_call_custom(
client,
"user.checkCredentials",
&check_form,
None,
&json!({
"login": email,
"password": password_md5,
"checkFormLogin": check_form
}),
uid_for_hdr,
false,
)
.await;
}
}
}
}
Err(format!("getArl:{last_err}"))
}
async fn try_fetch_arl_for_token(
access_token: &str,
user_id: Option<&str>,
) -> (Option<String>, Option<String>, Option<String>) {
let jar = Arc::new(Jar::default());
let url_deezer = match "https://www.deezer.com".parse::<Url>() {
Ok(u) => u,
Err(_) => return (None, None, None),
};
jar.add_cookie_str("comeback=1; Domain=.deezer.com; Path=/", &url_deezer);
let client = match reqwest::Client::builder()
.no_proxy()
.cookie_provider(jar.clone())
.timeout(std::time::Duration::from_secs(20))
.build()
{
Ok(c) => c,
Err(_) => return (None, None, None),
};
let _ = client
.get("https://www.deezer.com/")
.header(ACCEPT, "*/*")
.header("User-Agent", gw_browser_ua())
.send()
.await;
if ensure_anonymous_sid(&client, &jar).await.is_err() {
return (None, None, None);
}
establish_oauth_session(&client, &jar, access_token).await;
let uid = if let Some(uid) = user_id.filter(|s| !s.trim().is_empty()) {
Some(uid.to_string())
} else {
api_deezer_get(&client, "/user/me", access_token, &[])
.await
.ok()
.and_then(|v| v.get("id").and_then(|x| x.as_i64()))
.filter(|id| *id > 0)
.map(|id| id.to_string())
};
match gw_fetch_arl(&client, "", "", uid.as_deref()).await {
Ok((arl, token, _, license_token)) if arl.len() >= 20 => {
let gw_token = if token.is_empty() { None } else { Some(token) };
let lic = if license_token.is_empty() { None } else { Some(license_token) };
(Some(arl), gw_token, lic)
}
_ => (None, None, None),
}
}
async fn oauth_access_token(
client: &reqwest::Client,
email: &str,
password_md5: &str,
) -> Result<String, String> {
let app_id = deezer_app_id();
let hash_src = format!("{app_id}{email}{password_md5}{DEEZER_APP_SECRET}");
let hash = format!("{:x}", md5::compute(hash_src.as_bytes()));
let r = client
.get("https://connect.deezer.com/oauth/user_auth.php")
.query(&[
("app_id", app_id.as_str()),
("login", email),
("password", password_md5),
("hash", hash.as_str()),
])
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
)
.send()
.await
.map_err(|e| format!("network:{e}"))?;
let status = r.status();
let text = r.text().await.map_err(|_| "read".to_string())?;
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| {
let snip: String = text.chars().take(200).collect();
format!("parse:{}:{}", status.as_u16(), snip)
})?;
if let Some(err) = v.get("error") {
return Err(err.as_str().unwrap_or("error").to_string());
}
let token = v
.get("access_token")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
if token.is_empty() {
return Err("no_token".to_string());
}
Ok(token)
}
async fn establish_oauth_session(client: &reqwest::Client, jar: &Jar, access_token: &str) {
let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap();
let url_api = "https://api.deezer.com".parse::<Url>().unwrap();
let ua = gw_browser_ua();
let _ = client
.get("https://api.deezer.com/user/me")
.query(&[("access_token", access_token)])
.header("Authorization", format!("Bearer {access_token}"))
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("User-Agent", ua)
.send()
.await;
let _ = client
.get("https://api.deezer.com/platform/generic/track/80085")
.header("Authorization", format!("Bearer {access_token}"))
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("User-Agent", ua)
.send()
.await;
if !jar_has_cookie(jar, &url_deezer, "sid=") && !jar_has_cookie(jar, &url_api, "sid=") {
let _ = client
.get("https://api.deezer.com/platform/generic/track/3135556")
.header("Authorization", format!("Bearer {access_token}"))
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("User-Agent", ua)
.send()
.await;
}
}
async fn web_login(email: &str, password_md5: &str) -> Result<WebLoginResult, String> {
let jar = Arc::new(Jar::default());
let url_deezer = "https://www.deezer.com".parse::<Url>().map_err(|_| "web:url".to_string())?;
jar.add_cookie_str("comeback=1; Domain=.deezer.com; Path=/", &url_deezer);
let client = reqwest::Client::builder()
.no_proxy()
.cookie_provider(jar.clone())
.timeout(std::time::Duration::from_secs(25))
.build()
.map_err(|_| "web:client".to_string())?;
let _ = client
.get("https://www.deezer.com/")
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("User-Agent", gw_browser_ua())
.send()
.await;
// sid нужен ДО OAuth (как в официальном клиенте Deezer)
ensure_anonymous_sid(&client, &jar)
.await
.map_err(|e| format!("web:{e}"))?;
let access_token = oauth_access_token(&client, email, password_md5)
.await
.map_err(|e| format!("oauth:{e}"))?;
establish_oauth_session(&client, &jar, &access_token).await;
let _ = client
.get("https://www.deezer.com/")
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("User-Agent", gw_browser_ua())
.send()
.await;
let uid_from_api = api_deezer_get(
&client,
"/user/me",
&access_token,
&[],
)
.await
.ok()
.and_then(|v| v.get("id").and_then(|x| x.as_i64()))
.filter(|id| *id > 0)
.map(|id| id.to_string());
let (arl, token, uid, license_token) = match gw_fetch_arl(
&client,
email,
password_md5,
uid_from_api.as_deref(),
)
.await
{
Ok(v) => v,
Err(_e) => {
if let Ok(mobile_arl) = mobile_login(email, password_md5).await {
(
mobile_arl,
String::new(),
uid_from_api.clone().unwrap_or_default(),
String::new(),
)
} else {
return Ok(WebLoginResult {
arl: None,
token: String::new(),
uid: uid_from_api.unwrap_or_default(),
access_token,
license_token: String::new(),
});
}
}
};
Ok(WebLoginResult {
arl: Some(arl.clone()),
token,
uid: if uid.is_empty() {
uid_from_api.unwrap_or_default()
} else {
uid
},
access_token,
license_token,
})
}
fn worker_base() -> Option<String> {
let env = std::env::var("DZMEDIA_WORKER").ok().unwrap_or_default();
let base = env.trim();
if base.is_empty() || base == "-" {
return None;
}
Some(base.trim_end_matches('/').to_string())
}
#[derive(Serialize)]
struct WorkerLoginReq<'a> {
email: &'a str,
password_md5: &'a str,
}
#[derive(Deserialize)]
struct WorkerLoginResp {
arl: Option<String>,
error: Option<String>,
}
async fn worker_login(email: &str, password_md5: &str) -> Result<String, String> {
let Some(base) = worker_base() else {
return Err("worker:disabled".to_string());
};
let url = format!("{base}/login");
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(25))
.build()
.map_err(|_| "worker:client".to_string())?;
let r = client
.post(url)
.json(&WorkerLoginReq { email, password_md5 })
.send()
.await
.map_err(|e| format!("worker:network:{e}"))?;
let status = r.status();
let text = r.text().await.map_err(|_| "worker:read".to_string())?;
if !status.is_success() {
let snip: String = text.chars().take(200).collect();
return Err(format!("worker:http:{}:{}", status.as_u16(), snip));
}
let v: WorkerLoginResp = serde_json::from_str(&text).map_err(|_| {
let snip: String = text.chars().take(200).collect();
format!("worker:parse:{snip}")
})?;
if let Some(arl) = v.arl {
if arl.len() >= 100 {
return Ok(arl);
}
return Err(format!("worker:arl_too_short:{}", arl.len()));
}
Err(format!("worker:error:{}", v.error.unwrap_or_else(|| "unknown".to_string())))
}
async fn gw_light_call(
client: &reqwest::Client,
_jar: &Arc<Jar>,
method: &str,
api_token: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, String> {
let url = "https://www.deezer.com/ajax/gw-light.php";
let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap();
fn cid() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_else(|_| "0".to_string())
}
async fn do_req(
client: &reqwest::Client,
_url_deezer: &Url,
url: &str,
method: &str,
api_token: &str,
params: &serde_json::Value,
use_get: bool,
user_id: Option<&str>,
) -> Result<serde_json::Value, String> {
let cid = cid();
let mut req = if use_get || method == "user.getArl" {
client.get(url)
} else {
client
.post(url)
.header("Content-Type", "text/plain;charset=UTF-8")
.body(params.to_string())
};
if let Some(uid) = user_id.filter(|s| !s.trim().is_empty()) {
req = req.header("x-deezer-user", uid);
}
let r = req
.query(&[
("method", method),
("input", "3"),
("output", "3"),
("api_version", "1.0"),
("api_token", api_token),
("cid", cid.as_str()),
])
.header(ACCEPT, "*/*")
.header("Cache-Control", "max-age=0")
.header("Origin", "https://www.deezer.com")
.header("Referer", "https://www.deezer.com/")
.header("Sec-Fetch-Site", "same-origin")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Dest", "empty")
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Content-Language", "en-US")
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
)
.header("sec-ch-ua", "\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"")
.header("sec-ch-ua-mobile", "?0")
.header("sec-ch-ua-platform", "\"Windows\"")
.send()
.await
.map_err(|_| "network".to_string())?;
let status = r.status();
let text = r.text().await.map_err(|_| "read".to_string())?;
if status.as_u16() == 403 && text.to_ascii_lowercase().contains("access denied") {
return Err("blocked:403".to_string());
}
let json: GwResp = serde_json::from_str(&text).map_err(|_| {
let snip: String = text.chars().take(200).collect();
format!("parse:{}:{}", status.as_u16(), snip)
})?;
if let Some(error) = json.error.as_object() {
for (code, message) in error {
let msg = message.as_str().unwrap_or("");
return Err(format!("{}:{}", code, msg));
}
}
Ok(json.results)
}
if method == "deezer.getUserData" {
// Только POST — GET вариант блокируется как бот
match do_req(client, &url_deezer, url, method, api_token, &params, false, None).await {
Ok(v) => Ok(v),
Err(e1) => Err(format!("getUserData:{e1}; fallback:{e1}")),
}
} else {
match do_req(client, &url_deezer, url, method, api_token, &params, false, None).await {
Ok(v) => Ok(v),
Err(e1) => match do_req(client, &url_deezer, url, method, api_token, &params, false, None).await {
Ok(v) => Ok(v),
Err(e2) => Err(format!("{e1}; retry:{e2}")),
},
}
}
}
async fn gw_light_call_custom(
client: &reqwest::Client,
method: &str,
api_token: &str,
gateway_input: Option<&str>,
params: &serde_json::Value,
user_id: Option<&str>,
use_get: bool,
) -> Result<serde_json::Value, String> {
fn cid() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_else(|_| "0".to_string())
}
let cid = cid();
let mut req = if use_get || method == "user.getArl" {
client.get("https://www.deezer.com/ajax/gw-light.php")
} else {
client
.post("https://www.deezer.com/ajax/gw-light.php")
.header("Content-Type", "text/plain;charset=UTF-8")
.body(params.to_string())
};
if let Some(uid) = user_id.filter(|s| !s.trim().is_empty()) {
req = req.header("x-deezer-user", uid);
}
let mut qp: Vec<(&str, &str)> = vec![
("method", method),
("input", "3"),
("output", "3"),
("api_version", "1.0"),
("api_token", api_token),
("cid", cid.as_str()),
];
if let Some(gi) = gateway_input.filter(|s| !s.trim().is_empty()) {
qp.push(("gateway_input", gi));
}
let r = req
.query(&qp)
.header(ACCEPT, "*/*")
.header("Cache-Control", "max-age=0")
.header("Origin", "https://www.deezer.com")
.header("Referer", "https://www.deezer.com/")
.header("Sec-Fetch-Site", "same-origin")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Dest", "empty")
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Content-Language", "en-US")
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
)
.send()
.await
.map_err(|_| "network".to_string())?;
let status = r.status();
let text = r.text().await.map_err(|_| "read".to_string())?;
if status.as_u16() == 403 && text.to_ascii_lowercase().contains("access denied") {
return Err("blocked:403".to_string());
}
let json: GwResp = serde_json::from_str(&text).map_err(|_| {
let snip: String = text.chars().take(200).collect();
format!("parse:{}:{}", status.as_u16(), snip)
})?;
if let Some(error) = json.error.as_object() {
for (code, message) in error {
let msg = message.as_str().unwrap_or("");
return Err(format!("{}:{}", code, msg));
}
}
Ok(json.results)
}
fn jar_has_cookie(jar: &Jar, url: &Url, cookie_prefix: &str) -> bool {
let Some(hv) = jar.cookies(url) else { return false };
let Ok(s) = hv.to_str() else { return false };
s.split(';').any(|p| p.trim_start().starts_with(cookie_prefix))
}
async fn login(State(state): State<AppState>, Json(req): Json<LoginReq>) -> impl IntoResponse {
let email = req.email.trim().to_string();
let pass = req.password_md5.trim().to_string();
if email.is_empty() || pass.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "Email and password_md5 required");
}
match web_login(&email, &pass).await {
Ok(res) => {
if let Some(ref arl) = res.arl {
store_arl_session(&state, arl, &res.token, &res.license_token).await;
state.api_by_arl.write().await.remove(arl);
}
(StatusCode::OK, Json(json!({
"arl": res.arl,
"token": res.token,
"uid": res.uid,
"access_token": res.access_token
}))).into_response()
}
Err(e) => json_error(
StatusCode::UNAUTHORIZED,
format!("login_failed; {e}"),
),
}
}
#[derive(Clone, Serialize, Deserialize)]
struct PairSession {
created_ms: u128,
access_token: Option<String>,
token: Option<String>,
user_id: Option<String>,
email: Option<String>,
arl: Option<String>,
error: Option<String>,
}
async fn pair_store_load(state: &AppState) {
let path = state.pair_store_path.trim();
if path.is_empty() {
return;
}
if let Ok(txt) = tokio::fs::read_to_string(path).await {
if let Ok(map) = serde_json::from_str::<HashMap<String, PairSession>>(&txt) {
*state.pair.write().await = map;
}
}
}
async fn pair_store_save(state: &AppState) {
let path = state.pair_store_path.trim();
if path.is_empty() {
return;
}
let map = state.pair.read().await.clone();
let Ok(txt) = serde_json::to_string(&map) else { return };
let tmp = format!("{path}.tmp");
if tokio::fs::write(&tmp, txt).await.is_ok() {
if tokio::fs::rename(&tmp, path).await.is_err() {
let _ = tokio::fs::remove_file(path).await;
let _ = tokio::fs::rename(&tmp, path).await;
}
}
}
fn gen_pair_code() -> String {
let ms = now_ms();
let hex = format!("{:x}", md5::compute(format!("pair:{ms}:{SECRET}").as_bytes()));
hex.chars().take(10).collect::<String>()
}
fn deezer_app_id() -> String {
std::env::var("DEEZER_APP_ID")
.ok()
.unwrap_or_else(|| "447462".to_string())
.trim()
.to_string()
}
fn forwarded_proto(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http")
.trim()
.to_string()
}
fn urlenc(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
for &b in s.as_bytes() {
let c = b as char;
let ok = matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~');
if ok {
out.push(c);
} else {
out.push('%');
out.push_str(&format!("{:02X}", b));
}
}
out
}
#[derive(Deserialize)]
struct PairStatusQuery {
code: String,
}
async fn pair_start(State(state): State<AppState>) -> impl IntoResponse {
let code = gen_pair_code();
let created_ms = now_ms();
{
let mut map = state.pair.write().await;
let now = created_ms;
let ttl_ms: u128 = 10 * 60 * 1000;
map.retain(|_, v| now.saturating_sub(v.created_ms) <= ttl_ms);
if map.len() >= 512 {
map.clear();
}
map.insert(
code.clone(),
PairSession {
created_ms,
access_token: None,
token: None,
user_id: None,
email: None,
arl: None,
error: None,
},
);
}
pair_store_save(&state).await;
(StatusCode::OK, Json(json!({ "code": code }))).into_response()
}
async fn pair_status(State(state): State<AppState>, Query(q): Query<PairStatusQuery>) -> impl IntoResponse {
let code = q.code.trim().to_string();
if code.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "code required");
}
let now = now_ms();
let ttl_ms: u128 = 10 * 60 * 1000;
let mut map = state.pair.write().await;
let Some(sess) = map.get(&code) else {
return json_error(StatusCode::NOT_FOUND, "no such code");
};
if now.saturating_sub(sess.created_ms) > ttl_ms {
map.remove(&code);
drop(map);
pair_store_save(&state).await;
return json_error(StatusCode::NOT_FOUND, "code expired");
}
if let Some(err) = &sess.error {
return (StatusCode::OK, Json(json!({ "status": "error", "error": err }))).into_response();
}
if let Some(tok) = &sess.access_token {
return (
StatusCode::OK,
Json(json!({ "status": "ok", "access_token": tok, "token": sess.token, "user_id": sess.user_id, "email": sess.email, "arl": sess.arl })),
)
.into_response();
}
if sess.arl.as_deref().unwrap_or("").len() > 20 {
return (
StatusCode::OK,
Json(json!({ "status": "ok", "access_token": null, "token": sess.token, "user_id": sess.user_id, "email": sess.email, "arl": sess.arl })),
)
.into_response();
}
if sess.token.as_deref().unwrap_or("").trim().len() > 0 {
return (
StatusCode::OK,
Json(json!({ "status": "ok", "access_token": null, "token": sess.token, "user_id": sess.user_id, "email": sess.email, "arl": sess.arl })),
)
.into_response();
}
(StatusCode::OK, Json(json!({ "status": "pending" }))).into_response()
}
#[derive(Deserialize)]
struct PairPageQuery {
code: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PairOauthReq {
code: String,
access_token: String,
user_id: Option<String>,
}
async fn pair_oauth(State(state): State<AppState>, Json(req): Json<PairOauthReq>) -> impl IntoResponse {
let code = req.code.trim().to_string();
let access_token = req.access_token.trim().to_string();
let user_id = req
.user_id
.unwrap_or_default()
.trim()
.to_string()
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>();
if code.is_empty() || access_token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "code/access_token required");
}
let http = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let email = api_deezer_get(&http, "/user/me", &access_token, &[])
.await
.ok()
.and_then(|v| v.get("email").and_then(|x| x.as_str()).map(|s| s.to_string()));
let uid_from_api = if user_id.is_empty() {
api_deezer_get(&http, "/user/me", &access_token, &[])
.await
.ok()
.and_then(|v| v.get("id").and_then(|x| x.as_i64()))
.filter(|id| *id > 0)
.map(|id| id.to_string())
} else {
Some(user_id.clone())
};
let (arl_opt, token_opt, license_opt) = try_fetch_arl_for_token(
&access_token,
uid_from_api.as_deref(),
)
.await;
if let (Some(ref arl), Some(ref token), Some(ref lic)) = (&arl_opt, &token_opt, &license_opt) {
store_arl_session(&state, arl, token, lic).await;
state.api_by_arl.write().await.remove(arl);
} else if let (Some(ref arl), Some(ref token)) = (&arl_opt, &token_opt) {
store_arl_session(&state, arl, token, "").await;
state.api_by_arl.write().await.remove(arl);
}
let mut map = state.pair.write().await;
let Some(sess) = map.get_mut(&code) else {
return json_error(StatusCode::NOT_FOUND, "no such code");
};
sess.access_token = Some(access_token);
if let Some(uid) = uid_from_api {
sess.user_id = Some(uid);
}
if let Some(arl) = arl_opt {
sess.arl = Some(arl);
}
if let Some(token) = token_opt {
sess.token = Some(token);
}
if let Some(email) = email {
sess.email = Some(email);
}
sess.error = None;
drop(map);
pair_store_save(&state).await;
(StatusCode::OK, Json(json!({ "ok": true }))).into_response()
}
async fn pair_channel() -> impl IntoResponse {
let html = "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body></body></html>";
(StatusCode::OK, Html(html.to_string())).into_response()
}
async fn pair_page(Host(host): Host, headers: HeaderMap, Query(q): Query<PairPageQuery>) -> impl IntoResponse {
let code = q.code.unwrap_or_default().trim().to_string();
let safe_code = htmlesc(&code);
let scheme = forwarded_proto(&headers);
let app_id = deezer_app_id();
let redirect_url = format!("{scheme}://{host}/pair/oauth_cb");
let oauth_url = format!(
"https://connect.deezer.com/oauth/auth.php?app_id={}&redirect_uri={}&perms={}&response_type=token&state={}",
urlenc(&app_id),
urlenc(&redirect_url),
urlenc("basic_access,email,manage_library,listening_history"),
urlenc(&code)
);
let code_js = serde_json::to_string(&code).unwrap_or_else(|_| "\"\"".to_string());
let oauth_url_js = serde_json::to_string(&oauth_url).unwrap_or_else(|_| "\"\"".to_string());
let redirect_url_txt = htmlesc(&redirect_url);
let mut html = String::with_capacity(6000);
html.push_str(r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">"#);
html.push_str(r#"<title>Deezer Login</title>"#);
html.push_str(r#"<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0b0b;color:#fff;margin:0;padding:20px}.box{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}button{width:100%;padding:12px 14px;border-radius:10px;border:0;background:#a238ff;color:#fff;font-weight:800;font-size:16px}.muted{color:#aaa;font-size:13px;line-height:1.35}.ok{color:#1db954}.err{color:#ff5a5a}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:#101010;border-radius:8px;padding:10px;word-break:break-all}a{color:#a238ff}input{width:100%;box-sizing:border-box;padding:12px 12px;border-radius:10px;border:1px solid #2b2b2b;background:#0f0f0f;color:#fff;outline:none}input:focus{border-color:#a238ff}.row{margin:10px 0 0}</style>"#);
html.push_str(r#"</head><body><div class="box">"#);
html.push_str(r#"<h2 style="margin:0 0 6px">Deezer: вход для ТВ</h2>"#);
html.push_str(r#"<div class="muted">Код пары: <b>"#);
html.push_str(&safe_code);
html.push_str(r#"</b></div>"#);
html.push_str(r#"<div class="muted" style="margin:10px 0 14px">Нажми кнопку, войди в Deezer и подтверди доступ. После успеха вернись на телевизор.</div>"#);
html.push_str(r#"<button id="dz-login" type="button">Войти через Deezer</button>"#);
html.push_str(r#"<div id="msg" class="muted" style="margin:12px 0 0"></div>"#);
html.push_str(r#"<div class="muted" style="margin:12px 0 8px">Рекомендуется OAuth (кнопка выше). Вход по логину/паролю ниже может блокироваться на некоторых прокси/хостингах (403 Access Denied).</div>"#);
html.push_str(r#"<form method="POST" action="/pair/complete" autocomplete="on" style="margin:10px 0 0">"#);
html.push_str(r#"<input type="hidden" name="code" value="" id="pair-code-hidden">"#);
html.push_str(r#"<div class="row"><input name="email" type="email" placeholder="Email" autocomplete="username" required></div>"#);
html.push_str(r#"<div class="row"><input name="password" type="password" placeholder="Пароль" autocomplete="current-password" required></div>"#);
html.push_str(r#"<div class="row"><button type="submit">Войти по логину и паролю</button></div>"#);
html.push_str(r#"</form>"#);
html.push_str(r#"<div class="muted" style="margin:14px 0 8px">Redirect URI для Deezer приложения:</div>"#);
html.push_str(r#"<div class="mono">"#);
html.push_str(&redirect_url_txt);
html.push_str(r#"</div>"#);
html.push_str(r#"<script>(function(){"#);
html.push_str("var code=");
html.push_str(&code_js);
html.push_str(r#";var oauthUrl="#);
html.push_str(&oauth_url_js);
html.push_str(r#";var msg=document.getElementById('msg');function setMsg(cls,text){msg.className=cls?(cls+' muted'):'muted';msg.textContent=text;}if(!code){setMsg('err','Нет кода пары. Открой ссылку заново с QR.');return;}var hidden=document.getElementById('pair-code-hidden');if(hidden){hidden.value=code;}document.getElementById('dz-login').addEventListener('click',function(){setMsg('','Открываю Deezer…');if(!oauthUrl){setMsg('err','Не удалось собрать Deezer OAuth URL');return;}window.location.href=oauthUrl;});})();</script>"#);
html.push_str(r#"<p class="muted" style="margin:14px 0 0">Если Deezer пишет про неверный redirect/domain — нужно указать свой <b>DEEZER_APP_ID</b> для домена этого сервера.</p>"#);
html.push_str(r#"</div></body></html>"#);
Html(html)
}
#[derive(Deserialize)]
struct PairOauthCbQuery {
code: Option<String>,
state: Option<String>,
}
async fn pair_oauth_cb(Query(q): Query<PairOauthCbQuery>) -> impl IntoResponse {
let code = q
.code
.or(q.state)
.unwrap_or_default()
.trim()
.to_string();
let safe_code = htmlesc(&code);
let code_js = serde_json::to_string(&code).unwrap_or_else(|_| "\"\"".to_string());
let mut html = String::with_capacity(3500);
html.push_str(r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">"#);
html.push_str(r#"<title>Deezer Login</title>"#);
html.push_str(r#"<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0b0b;color:#fff;margin:0;padding:20px}.box{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}.muted{color:#aaa;font-size:13px;line-height:1.35}.ok{color:#1db954}.err{color:#ff5a5a}</style>"#);
html.push_str(r#"</head><body><div class="box">"#);
html.push_str(r#"<h2 style="margin:0 0 6px">Deezer: вход для ТВ</h2>"#);
html.push_str(r#"<div class="muted">Код пары: <b>"#);
html.push_str(&safe_code);
html.push_str(r#"</b></div>"#);
html.push_str(r#"<div id="msg" class="muted" style="margin:12px 0 0">Получаю токен…</div>"#);
html.push_str(r#"<script>(function(){"#);
html.push_str("var codeFromQuery=");
html.push_str(&code_js);
html.push_str(r#";var msg=document.getElementById('msg');function setMsg(cls,text){msg.className=cls?(cls+' muted'):'muted';msg.textContent=text;}function parseHash(){var h=window.location.hash||'';if(h.charAt(0)==='#')h=h.slice(1);var out={};h.split('&').forEach(function(p){if(!p)return;var i=p.indexOf('=');var k=i>=0?p.slice(0,i):p;var v=i>=0?p.slice(i+1):'';try{k=decodeURIComponent(k);}catch(e){}try{v=decodeURIComponent(v);}catch(e){}out[k]=v;});return out;}var h=parseHash();var code=(codeFromQuery||h.state||'');if(!code){setMsg('err','Нет кода пары. Вернись на телевизор и обнови QR/ссылку.');return;}var at=h.access_token||'';if(!at){setMsg('err','Deezer не вернул access_token. Попробуй ещё раз.');return;}try{history.replaceState(null,'',window.location.pathname+window.location.search);}catch(e){}fetch('/pair/oauth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:code,access_token:at})}).then(function(r){return r.json().catch(function(){return {};}).then(function(j){return {ok:r.ok,json:j};});}).then(function(r){if(r.ok&&!r.json.error){setMsg('ok','Готово ✓ Вернись на телевизор.');}else{setMsg('err','Ошибка сохранения: '+(r.json.error||'error'));}}).catch(function(){setMsg('err','Ошибка сети при сохранении');});})();</script>"#);
html.push_str(r#"</div></body></html>"#);
(StatusCode::OK, Html(html)).into_response()
}
#[derive(Deserialize)]
struct PairCompleteForm {
code: String,
email: String,
password: String,
}
fn htmlesc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
async fn api_deezer_get(
client: &reqwest::Client,
path: &str,
access_token: &str,
extra: &[(&str, String)],
) -> Result<serde_json::Value, String> {
let url = format!("https://api.deezer.com{path}");
let mut req = client
.get(url)
.header("Authorization", format!("Bearer {}", access_token))
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
)
.query(&[("access_token", access_token)]);
if !extra.is_empty() {
req = req.query(extra);
}
let r = req.send().await.map_err(|_| "api:network".to_string())?;
let status = r.status();
let text = r.text().await.map_err(|_| "api:read".to_string())?;
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| {
let snip: String = text.chars().take(200).collect();
format!("api:parse:{}:{}", status.as_u16(), snip)
})?;
if let Some(e) = v.get("error") {
if e.is_object() {
let code = e.get("code").and_then(|x| x.as_i64()).unwrap_or(0);
let msg = e.get("message").and_then(|x| x.as_str()).unwrap_or("error");
return Err(format!("api:{code}:{msg}"));
}
return Err("api:error".to_string());
}
Ok(v)
}
async fn pair_complete(State(state): State<AppState>, Form(f): Form<PairCompleteForm>) -> impl IntoResponse {
let code = f.code.trim().to_string();
let email = f.email.trim().to_string();
let password = f.password;
if code.is_empty() || email.is_empty() || password.is_empty() {
return Html("<h3>Нужны code/email/password</h3>".to_string()).into_response();
}
{
let map = state.pair.read().await;
if !map.contains_key(&code) {
return Html("<h3>Код не найден или истёк</h3>".to_string()).into_response();
}
}
let pass_md5 = format!("{:x}", md5::compute(password.as_bytes()));
let res = web_login(&email, &pass_md5).await;
match res {
Err(e) => {
let mut map = state.pair.write().await;
if let Some(s) = map.get_mut(&code) {
s.error = Some(e.clone());
}
drop(map);
pair_store_save(&state).await;
Html(format!("<h3>Ошибка входа</h3><pre>{}</pre>", htmlesc(&e))).into_response()
}
Ok(login) => {
let email_from_api = {
let http = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok();
if let Some(http) = http {
api_deezer_get(&http, "/user/me", &login.access_token, &[])
.await
.ok()
.and_then(|v| v.get("email").and_then(|x| x.as_str()).map(|s| s.to_string()))
} else {
None
}
};
let mut map = state.pair.write().await;
if let Some(s) = map.get_mut(&code) {
s.arl = login.arl.clone();
s.token = if login.token.is_empty() {
None
} else {
Some(login.token.clone())
};
s.access_token = Some(login.access_token.clone());
if !login.uid.is_empty() {
s.user_id = Some(login.uid.clone());
}
s.email = email_from_api.or(Some(email));
s.error = None;
}
drop(map);
pair_store_save(&state).await;
if let Some(ref arl) = login.arl {
store_arl_session(&state, arl, &login.token, &login.license_token).await;
state.api_by_arl.write().await.remove(arl);
}
let msg = if login.arl.is_some() {
"<h3>Готово</h3><p>Вернись на ТВ — плагин подключится автоматически.</p>"
} else {
"<h3>Вход выполнен</h3><p>Библиотека доступна. ARL не получен — полные треки могут не работать, попробуй кнопку «Войти через Deezer».</p>"
};
Html(msg.to_string()).into_response()
}
}
}
#[derive(Debug, Deserialize)]
struct OauthMeReq {
access_token: String,
}
async fn oauth_me(Json(req): Json<OauthMeReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
#[derive(Debug, Deserialize)]
struct OauthPlaylistsReq {
access_token: String,
index: Option<u32>,
limit: Option<u32>,
}
async fn oauth_playlists(Json(req): Json<OauthPlaylistsReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let index = req.index.unwrap_or(0).to_string();
let limit = req.limit.unwrap_or(50).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let me = match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e),
};
let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id");
}
let path = format!("/user/{uid}/playlists");
let extra = [("index", index), ("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
#[derive(Debug, Deserialize)]
struct OauthPlaylistTracksReq {
access_token: String,
playlist_id: u64,
index: Option<u32>,
limit: Option<u32>,
}
async fn oauth_playlist_tracks(Json(req): Json<OauthPlaylistTracksReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
if req.playlist_id == 0 {
return json_error(StatusCode::BAD_REQUEST, "playlist_id required");
}
let index = req.index.unwrap_or(0).to_string();
let limit = req.limit.unwrap_or(100).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let path = format!("/playlist/{}/tracks", req.playlist_id);
let extra = [("index", index), ("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
#[derive(Debug, Deserialize)]
struct OauthArlReq {
access_token: String,
}
async fn oauth_arl(State(state): State<AppState>, Json(req): Json<OauthArlReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let jar = Arc::new(Jar::default());
let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap();
jar.add_cookie_str("comeback=1; Domain=.deezer.com", &url_deezer);
jar.add_cookie_str("dzr_uniq_id=; Domain=.deezer.com", &url_deezer);
let client = reqwest::Client::builder()
.no_proxy()
.cookie_provider(jar.clone())
.timeout(std::time::Duration::from_secs(20))
.build()
.unwrap();
let _ = client
.get("https://www.deezer.com/")
.header(ACCEPT, "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("User-Agent", gw_browser_ua())
.send()
.await;
if let Err(e) = ensure_anonymous_sid(&client, &jar).await {
return json_error(StatusCode::SERVICE_UNAVAILABLE, format!("oauth_arl:{e}"));
}
establish_oauth_session(&client, &jar, &token).await;
let uid = api_deezer_get(&client, "/user/me", &token, &[])
.await
.ok()
.and_then(|v| v.get("id").and_then(|x| x.as_i64()))
.filter(|id| *id > 0)
.map(|id| id.to_string());
match gw_fetch_arl(&client, "", "", uid.as_deref()).await {
Ok((arl, check_form, _, license_token)) if arl.len() >= 20 => {
store_arl_session(&state, &arl, &check_form, &license_token).await;
state.api_by_arl.write().await.remove(&arl);
(StatusCode::OK, Json(json!({ "arl": arl }))).into_response()
}
Ok((arl, _, _, _)) => {
json_error(
StatusCode::SERVICE_UNAVAILABLE,
format!("oauth_arl:arl_too_short:{}", arl.len()),
)
}
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, format!("oauth_arl:{e}")),
}
}
#[derive(Debug, Deserialize)]
struct GwPageReq {
arl: String,
token: Option<String>,
user_id: Option<String>,
page: Option<String>,
lang: Option<String>,
}
async fn gw_page(Json(req): Json<GwPageReq>) -> impl IntoResponse {
let arl = req.arl.trim().to_string();
if arl.len() < 20 {
return json_error(StatusCode::BAD_REQUEST, "arl required");
}
let page = req.page.unwrap_or_else(|| "home".to_string()).trim().to_string();
if page.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "page required");
}
let lang = req
.lang
.unwrap_or_else(|| "en".to_string())
.trim()
.to_string();
let lang = if lang.is_empty() { "en".to_string() } else { lang };
let jar = Arc::new(Jar::default());
let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap();
jar.add_cookie_str("comeback=1; Domain=.deezer.com", &url_deezer);
jar.add_cookie_str(&format!("arl={arl}; Domain=.deezer.com; Path=/"), &url_deezer);
let client = reqwest::Client::builder()
.no_proxy()
.cookie_provider(jar.clone())
.timeout(std::time::Duration::from_secs(20))
.build()
.unwrap();
let ud = match gw_light_call_custom(
&client,
"deezer.getUserData",
"null",
None,
&json!({}),
None,
true,
)
.await
{
Ok(v) => v,
Err(e) => return json_error(StatusCode::UNAUTHORIZED, format!("gw:getUserData:{e}")),
};
let token = req
.token
.unwrap_or_default()
.trim()
.to_string()
.chars()
.filter(|c| !c.is_control())
.collect::<String>();
let api_token = if !token.is_empty() {
token
} else {
ud.get("checkForm")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string()
};
if api_token.is_empty() {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "gw:no_checkForm");
}
let user_id = req
.user_id
.unwrap_or_default()
.trim()
.to_string()
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>();
let user_id = if !user_id.is_empty() {
Some(user_id)
} else {
ud.get("USER")
.and_then(|u| u.get("USER_ID"))
.and_then(|x| x.as_i64())
.filter(|n| *n > 0)
.map(|n| n.to_string())
};
let support = json!({
"ads": [],
"deeplink-list": ["deeplink"],
"event-card": ["live-event"],
"grid-preview-one": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"],
"grid-preview-two": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"],
"grid": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"],
"horizontal-grid": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"],
"horizontal-list": ["track","song"],
"item-highlight": ["radio"],
"large-card": ["album","external-link","playlist","show","video-link"],
"list": ["episode"],
"mini-banner": ["external-link"],
"slideshow": ["album","artist","channel","external-link","flow","livestream","playlist","show","smarttracklist","user","video-link"],
"small-horizontal-grid": ["flow"],
"long-card-horizontal-grid": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"],
"filterable-grid": ["flow"]
});
let gateway_input = json!({
"PAGE": page,
"VERSION": "2.5",
"SUPPORT": support,
"LANG": lang,
"OPTIONS": ["deeplink_newsandentertainment", "deeplink_subscribeoffer"]
})
.to_string();
let results = match gw_light_call_custom(
&client,
"page.get",
&api_token,
Some(&gateway_input),
&json!({}),
user_id.as_deref(),
false,
)
.await
{
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, format!("gw:page.get:{e}")),
};
(StatusCode::OK, Json(json!({ "results": results }))).into_response()
}
#[derive(Debug, Deserialize)]
struct GwFlowReq {
arl: String,
token: Option<String>,
user_id: String,
config_id: Option<String>,
}
async fn gw_flow(Json(req): Json<GwFlowReq>) -> impl IntoResponse {
let arl = req.arl.trim().to_string();
if arl.len() < 20 {
return json_error(StatusCode::BAD_REQUEST, "arl required");
}
let user_id = req.user_id.trim().to_string();
if user_id.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "user_id required");
}
let jar = Arc::new(Jar::default());
let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap();
jar.add_cookie_str("comeback=1; Domain=.deezer.com", &url_deezer);
jar.add_cookie_str(&format!("arl={arl}; Domain=.deezer.com; Path=/"), &url_deezer);
let client = reqwest::Client::builder()
.no_proxy()
.cookie_provider(jar.clone())
.timeout(std::time::Duration::from_secs(20))
.build()
.unwrap();
let ud = match gw_light_call_custom(
&client,
"deezer.getUserData",
"null",
None,
&json!({}),
None,
true,
)
.await
{
Ok(v) => v,
Err(e) => return json_error(StatusCode::UNAUTHORIZED, format!("gw:getUserData:{e}")),
};
let token = req.token.unwrap_or_default().trim().to_string();
let api_token = if !token.is_empty() {
token
} else {
ud.get("checkForm")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string()
};
if api_token.is_empty() {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "gw:no_checkForm");
}
let mut params = json!({ "user_id": user_id });
let config_id = req.config_id.unwrap_or_default().trim().to_string();
if !config_id.is_empty() && config_id != "default" {
if let Some(obj) = params.as_object_mut() {
obj.insert("config_id".to_string(), serde_json::Value::String(config_id));
}
}
let results = match gw_light_call_custom(
&client,
"radio.getUserRadio",
&api_token,
None,
&params,
Some(req.user_id.as_str()),
false,
)
.await
{
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, format!("gw:radio.getUserRadio:{e}")),
};
(StatusCode::OK, Json(json!({ "results": results }))).into_response()
}
#[derive(Debug, Deserialize)]
struct OauthFlowReq {
access_token: String,
limit: Option<u32>,
}
async fn oauth_flow(Json(req): Json<OauthFlowReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let limit = req.limit.unwrap_or(40).clamp(1, 100).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let me = match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e),
};
let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id");
}
let path = format!("/user/{uid}/flow");
let extra = [("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
#[derive(Debug, Deserialize)]
struct OauthRecoReq {
access_token: String,
index: Option<u32>,
limit: Option<u32>,
}
async fn oauth_reco_playlists(Json(req): Json<OauthRecoReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let index = req.index.unwrap_or(0).to_string();
let limit = req.limit.unwrap_or(20).clamp(1, 50).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let me = match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e),
};
let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id");
}
let path = format!("/user/{uid}/recommendations/playlists");
let extra = [("index", index), ("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
async fn oauth_reco_albums(Json(req): Json<OauthRecoReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let index = req.index.unwrap_or(0).to_string();
let limit = req.limit.unwrap_or(20).clamp(1, 50).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let me = match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e),
};
let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id");
}
let path = format!("/user/{uid}/recommendations/albums");
let extra = [("index", index), ("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
async fn oauth_reco_artists(Json(req): Json<OauthRecoReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let index = req.index.unwrap_or(0).to_string();
let limit = req.limit.unwrap_or(20).clamp(1, 50).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let me = match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e),
};
let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id");
}
let path = format!("/user/{uid}/recommendations/artists");
let extra = [("index", index), ("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
async fn oauth_reco_tracks(Json(req): Json<OauthRecoReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let index = req.index.unwrap_or(0).to_string();
let limit = req.limit.unwrap_or(40).clamp(1, 100).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let me = match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e),
};
let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id");
}
let path = format!("/user/{uid}/recommendations/tracks");
let extra = [("index", index), ("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
#[derive(Debug, Deserialize)]
struct OauthHistoryReq {
access_token: String,
index: Option<u32>,
limit: Option<u32>,
}
async fn oauth_history(Json(req): Json<OauthHistoryReq>) -> impl IntoResponse {
let token = req.access_token.trim().to_string();
if token.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "access_token required");
}
let index = req.index.unwrap_or(0).to_string();
let limit = req.limit.unwrap_or(50).clamp(1, 100).to_string();
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();
let me = match api_deezer_get(&client, "/user/me", &token, &[]).await {
Ok(v) => v,
Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e),
};
let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
if uid <= 0 {
return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id");
}
let path = format!("/user/{uid}/history");
let extra = [("index", index), ("limit", limit)];
match api_deezer_get(&client, &path, &token, &extra).await {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e),
}
}
#[derive(Debug, Deserialize)]
struct WarmArlReq {
arl: String,
}
async fn warm_arl(State(state): State<AppState>, Json(req): Json<WarmArlReq>) -> impl IntoResponse {
let arl = req.arl.trim().to_string();
if arl.len() < 20 {
return json_error(StatusCode::BAD_REQUEST, "arl required");
}
state.api_by_arl.write().await.remove(&arl);
let client = api_client_for_arl(&state, Some(arl.clone())).await;
let session = {
let mut c = client.lock().await;
if c.license_token.is_empty() {
if let Err(e) = c.force_renew().await {
return json_error(StatusCode::SERVICE_UNAVAILABLE, format!("warm:renew:{e}"));
}
}
(c.check_form.clone(), c.license_token.clone())
};
store_arl_session(&state, &arl, &session.0, &session.1).await;
state.api_by_arl.write().await.remove(&arl);
(StatusCode::OK, Json(json!({ "ok": true }))).into_response()
}
#[tokio::main]
async fn main() {
let bind_addr = std::env::var("BIND_ADDR").unwrap_or("[::]".to_string());
let port = std::env::var("PORT").unwrap_or("8000".to_string());
let port: u16 = port.parse().unwrap_or(8000);
let state = AppState {
api: Arc::new(Mutex::new(APIClient::new())),
api_by_arl: Arc::new(RwLock::new(HashMap::new())),
arl_sessions: Arc::new(RwLock::new(HashMap::new())),
pair: Arc::new(RwLock::new(HashMap::new())),
pair_store_path: std::env::var("PAIR_STORE_PATH").ok().unwrap_or_else(|| "pair_store.json".to_string()),
};
pair_store_load(&state).await;
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD])
.allow_headers([CONTENT_TYPE]);
tracing_subscriber::fmt::init();
let app = Router::new()
.route("/", get(root))
.route("/get_url", post(get_url))
.route("/fetch", get(fetch))
.route("/stream", get(stream))
.route("/stream_info", get(stream_info))
.route("/user_data", post(user_data))
.route("/playlists", post(playlists))
.route("/playlist", post(playlist_tracks))
.route("/login", post(login))
.route("/pair/start", get(pair_start))
.route("/pair/status", get(pair_status))
.route("/pair", get(pair_page))
.route("/pair/channel", get(pair_channel))
.route("/pair/oauth", post(pair_oauth))
.route("/pair/oauth_cb", get(pair_oauth_cb))
.route("/pair/complete", post(pair_complete))
.route("/gw/page", post(gw_page))
.route("/gw/flow", post(gw_flow))
.route("/oauth/me", post(oauth_me))
.route("/oauth/playlists", post(oauth_playlists))
.route("/oauth/playlist_tracks", post(oauth_playlist_tracks))
.route("/oauth/arl", post(oauth_arl))
.route("/session/warm", post(warm_arl))
.route("/oauth/flow", post(oauth_flow))
.route("/oauth/reco_playlists", post(oauth_reco_playlists))
.route("/oauth/reco_albums", post(oauth_reco_albums))
.route("/oauth/reco_artists", post(oauth_reco_artists))
.route("/oauth/reco_tracks", post(oauth_reco_tracks))
.route("/oauth/history", post(oauth_history))
.with_state(state)
.layer(cors)
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http());
let bind_addr = format!("{bind_addr}:{port}");
println!("Listening on {bind_addr}");
let listener = tokio::net::TcpListener::bind(bind_addr).await.unwrap();
axum::serve(listener, app)
.await
.unwrap();
}