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; mod api; use api::{APIClient, APIError, Format}; #[derive(Clone)] struct ArlSession { check_form: String, license_token: String, } #[derive(Clone)] struct AppState { api: Arc>, api_by_arl: Arc>>>>, arl_sessions: Arc>>, pair: Arc>>, 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) -> 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) -> Arc> { 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 { let v = v.trim(); let (_, rest) = v.split_once('/')?; if rest.trim() == "*" { return None; } rest.trim().parse::().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 { 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, ) -> Result { if client.license_token.is_empty() { if let Err(e) = client.force_renew().await { return Err(format!("renew:{e}")); } } let resp: Result = 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 { 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, ids: &Vec) -> Result { 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) -> Result { 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 { 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 } #[derive(Deserialize)] #[allow(non_snake_case)] struct DeezerTrack { TRACK_TOKEN: String, DURATION: Option } async fn root() -> &'static str { "marecchione gay af" } #[derive(Debug, Deserialize)] struct RequestParams { formats: Vec, ids: Vec, arl: Option, } async fn get_url(State(state): State, Json(req): Json) -> 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 = 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) -> impl IntoResponse { let parsed = match q.url.parse::() { 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::::new_from_slices(key, &iv).map_err(|_| ())?; dec.decrypt_padded_mut::(block).map_err(|_| ())?; Ok(()) } #[derive(Debug, Deserialize)] struct StreamParams { id: u32, format: Option, arl: Option, } async fn stream_info(State(state): State, Query(q): Query) -> 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 = 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::(&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::().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, Query(q): Query, 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 = None; let mut last_err: Option = 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)> { 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::().ok()?; let end = if b.is_empty() { None } else { let e = b.parse::().ok()?; Some(e) }; Some((start, end)) } fn parse_total_from_content_range(v: &str) -> Option { let v = v.trim(); let (_, rest) = v.split_once('/')?; if rest.trim() == "*" { return None; } rest.trim().parse::().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 = 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 = 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::(Box::new(e)); break; } }; carry.extend_from_slice(&chunk); while carry.len() >= 2048 { let mut block = carry.drain(0..2048).collect::>(); 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::from(block)); done = true; break; } else { remaining = Some(rem - block.len() as u64); } } yield Ok::(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::from(tail)); } } else { yield Ok::(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, Json(req): Json) -> 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, nb: Option, } async fn playlists(State(state): State, Json(req): Json) -> 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 = 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, nb: Option, } async fn playlist_tracks( State(state): State, Json(req): Json, ) -> 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 = { 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::().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 { 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::>() .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 { mobile_gw(client, "deezer.getUserData", "null", json!({})).await } /// Логин по email+md5(password) через мобильный API — возвращает ARL async fn mobile_login(email: &str, password_md5: &str) -> Result { 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, 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::().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, Option, Option) { let jar = Arc::new(Jar::default()); let url_deezer = match "https://www.deezer.com".parse::() { 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 { 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::().unwrap(); let url_api = "https://api.deezer.com".parse::().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 { let jar = Arc::new(Jar::default()); let url_deezer = "https://www.deezer.com".parse::().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 { 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, error: Option, } async fn worker_login(email: &str, password_md5: &str) -> Result { 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, method: &str, api_token: &str, params: serde_json::Value, ) -> Result { let url = "https://www.deezer.com/ajax/gw-light.php"; let url_deezer = "https://www.deezer.com".parse::().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 { 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, ¶ms, 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, ¶ms, false, None).await { Ok(v) => Ok(v), Err(e1) => match do_req(client, &url_deezer, url, method, api_token, ¶ms, 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 { 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, Json(req): Json) -> 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, token: Option, user_id: Option, email: Option, arl: Option, error: Option, } 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::>(&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::() } 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) -> 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, Query(q): Query) -> 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, } #[derive(Debug, Deserialize)] struct PairOauthReq { code: String, access_token: String, user_id: Option, } async fn pair_oauth(State(state): State, Json(req): Json) -> 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::(); 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 = ""; (StatusCode::OK, Html(html.to_string())).into_response() } async fn pair_page(Host(host): Host, headers: HeaderMap, Query(q): Query) -> 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#""#); html.push_str(r#"Deezer Login"#); html.push_str(r#""#); html.push_str(r#"
"#); html.push_str(r#"

Deezer: вход для ТВ

"#); html.push_str(r#"
Код пары: "#); html.push_str(&safe_code); html.push_str(r#"
"#); html.push_str(r#"
Нажми кнопку, войди в Deezer и подтверди доступ. После успеха вернись на телевизор.
"#); html.push_str(r#""#); html.push_str(r#"
"#); html.push_str(r#"
Рекомендуется OAuth (кнопка выше). Вход по логину/паролю ниже может блокироваться на некоторых прокси/хостингах (403 Access Denied).
"#); html.push_str(r#"
"#); html.push_str(r#""#); html.push_str(r#"
"#); html.push_str(r#"
"#); html.push_str(r#"
"#); html.push_str(r#"
"#); html.push_str(r#"
Redirect URI для Deezer приложения:
"#); html.push_str(r#"
"#); html.push_str(&redirect_url_txt); html.push_str(r#"
"#); html.push_str(r#""#); html.push_str(r#"

Если Deezer пишет про неверный redirect/domain — нужно указать свой DEEZER_APP_ID для домена этого сервера.

"#); html.push_str(r#"
"#); Html(html) } #[derive(Deserialize)] struct PairOauthCbQuery { code: Option, state: Option, } async fn pair_oauth_cb(Query(q): Query) -> 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#""#); html.push_str(r#"Deezer Login"#); html.push_str(r#""#); html.push_str(r#"
"#); html.push_str(r#"

Deezer: вход для ТВ

"#); html.push_str(r#"
Код пары: "#); html.push_str(&safe_code); html.push_str(r#"
"#); html.push_str(r#"
Получаю токен…
"#); html.push_str(r#""#); html.push_str(r#"
"#); (StatusCode::OK, Html(html)).into_response() } #[derive(Deserialize)] struct PairCompleteForm { code: String, email: String, password: String, } fn htmlesc(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } async fn api_deezer_get( client: &reqwest::Client, path: &str, access_token: &str, extra: &[(&str, String)], ) -> Result { 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, Form(f): Form) -> 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("

Нужны code/email/password

".to_string()).into_response(); } { let map = state.pair.read().await; if !map.contains_key(&code) { return Html("

Код не найден или истёк

".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!("

Ошибка входа

{}
", 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() { "

Готово

Вернись на ТВ — плагин подключится автоматически.

" } else { "

Вход выполнен

Библиотека доступна. ARL не получен — полные треки могут не работать, попробуй кнопку «Войти через Deezer».

" }; Html(msg.to_string()).into_response() } } } #[derive(Debug, Deserialize)] struct OauthMeReq { access_token: String, } async fn oauth_me(Json(req): Json) -> 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, limit: Option, } async fn oauth_playlists(Json(req): Json) -> 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, limit: Option, } async fn oauth_playlist_tracks(Json(req): Json) -> 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, Json(req): Json) -> 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::().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, user_id: Option, page: Option, lang: Option, } async fn gw_page(Json(req): Json) -> 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::().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::(); 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::(); 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, user_id: String, config_id: Option, } async fn gw_flow(Json(req): Json) -> 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::().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, ¶ms, 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, } async fn oauth_flow(Json(req): Json) -> 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, limit: Option, } async fn oauth_reco_playlists(Json(req): Json) -> 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) -> 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) -> 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) -> 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, limit: Option, } async fn oauth_history(Json(req): Json) -> 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, Json(req): Json) -> 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(); }