diff --git "a/src/main.rs" "b/src/main.rs" --- "a/src/main.rs" +++ "b/src/main.rs" @@ -1,3251 +1,3250 @@ -#![allow(dead_code)] - -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}; -use tokio::time::{timeout, Duration}; - -type BoxErr = Box; - -mod api; -use api::{APIClient, APIError, Format}; - -#[derive(Clone, Serialize, Deserialize)] -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, - arl_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(), - }, - ); - arl_store_save(state).await; -} - -async fn arl_store_save(state: &AppState) { - let path = state.arl_store_path.trim(); - if path.is_empty() { - return; - } - let map = state.arl_sessions.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() { - let _ = tokio::fs::rename(&tmp, path).await; - } -} - -async fn arl_store_load(state: &AppState) { - let path = state.arl_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.arl_sessions.write().await = map; - } - } -} - -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, usize) { - let data = media_json.get("data").and_then(|v| v.as_array()); - let Some(data_arr) = data else { - return (String::new(), 0); - }; - for (i, data_item) in data_arr.iter().enumerate() { - let media_arr = data_item.get("media").and_then(|v| v.as_array()); - let Some(media_arr) = media_arr else { - continue; - }; - for m in media_arr { - if let Some(sources) = m.get("sources").and_then(|v| v.as_array()) { - let mut preferred = Vec::new(); - if let Some(s1) = sources.get(1).and_then(|s| s.get("url")).and_then(|u| u.as_str()) { - preferred.push(s1); - } - for s in sources { - if let Some(u) = s.get("url").and_then(|v| v.as_str()) { - if u.contains("dzcdn.net/media/") { - preferred.push(u); - } - } - } - if let Some(s0) = sources.get(0).and_then(|s| s.get("url")).and_then(|u| u.as_str()) { - preferred.push(s0); - } - for s in sources { - if let Some(u) = s.get("url").and_then(|v| v.as_str()) { - preferred.push(u); - } - } - for p in preferred { - let p = p.trim(); - if !p.is_empty() { - return (p.to_string(), i); - } - } - } - } - } - (String::new(), 0) -} - -fn extract_media_format(media_json: &serde_json::Value) -> String { - let data = media_json.get("data").and_then(|v| v.as_array()); - let Some(data_arr) = data else { - return String::new(); - }; - for data_item in data_arr { - let media_arr = data_item.get("media").and_then(|v| v.as_array()); - let Some(media_arr) = media_arr else { - continue; - }; - 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_tokens: Vec<&str>, -) -> Result<(String, usize), String> { - if client.license_token.is_empty() { - return Err("license_token empty".to_string()); - } - - let media_resp = client - .get_media(&formats.to_vec(), track_tokens) - .await - .map_err(|e| e.to_string())?; - - let status = media_resp.status(); - let media_text = media_resp - .text() - .await - .map_err(|_| "media:read".to_string())?; - - let media_json: serde_json::Value = serde_json::from_str(&media_text).map_err(|_| { - let snip: String = media_text.chars().take(200).collect(); - format!("media_bad_json:{}:{}", status.as_u16(), snip) - })?; - - let (url, idx) = extract_media_url(&media_json); - if !url.is_empty() { - return Ok((url, idx)); - } - - Err(format!( - "media:{}:{}", - status.as_u16(), - media_response_err(&media_json) - )) -} - -async fn media_url_for_track( - client: &mut APIClient, - id: u64, - formats: &Vec, -) -> Result<(String, u64), String> { - 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","FALLBACK"]}), - ) - .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_deref().unwrap_or(""); - let fallback_token = track_list.data[0] - .FALLBACK - .as_ref() - .and_then(|f| f.TRACK_TOKEN.as_deref()) - .unwrap_or(""); - - let fallback_id = track_list.data[0] - .FALLBACK - .as_ref() - .and_then(|f| f.SNG_ID.as_ref().and_then(|s| s.parse::().ok())) - .unwrap_or(0); - - let mut tokens = Vec::new(); - let mut token_ids = Vec::new(); - if !track_token.is_empty() { - tokens.push(track_token); - token_ids.push(id); - } - if !fallback_token.is_empty() { - tokens.push(fallback_token); - let fid = if fallback_id > 0 { fallback_id } else { id }; - token_ids.push(fid); - } - - if tokens.is_empty() { - return Err("empty TRACK_TOKEN and FALLBACK".to_string()); - } - - let (url, idx) = fetch_media_url(client, formats, tokens).await?; - let id_to_use = *token_ids.get(idx).unwrap_or(&id); - Ok((url, id_to_use)) -} - -fn upstream_bases() -> Vec { - let mut out: Vec = Vec::new(); - - let env = std::env::var("DZMEDIA_UPSTREAM").ok().unwrap_or_default(); - let env = env.trim(); - if env == "-" { - return out; - } - - if !env.is_empty() { - for part in env.split(',') { - let s = part.trim(); - if s.is_empty() { - continue; - } - out.push(s.trim_end_matches('/').to_string()); - } - } - - out -} - -fn upstream_get_url_endpoint(base: &str) -> String { - let b = base.trim_end_matches('/'); - if b.ends_with("/get_url") { - b.to_string() - } else { - format!("{b}/get_url") - } -} - -async fn upstream_get_url_text(formats: &Vec, ids: &Vec) -> Result { - let bases = upstream_bases(); - if bases.is_empty() { - return Err("upstream:disabled".to_string()); - } - - let client = reqwest::Client::builder() - .no_proxy() - .timeout(std::time::Duration::from_secs(20)) - .build() - .map_err(|_| "upstream:client".to_string())?; - - let mut last_err = String::new(); - for base in bases { - let url = upstream_get_url_endpoint(&base); - let r = match client - .post(url) - .json(&json!({ "formats": formats, "ids": ids })) - .send() - .await - { - Ok(v) => v, - Err(e) => { - last_err = format!("upstream:network:{base}:{e}"); - continue; - } - }; - - let status = r.status(); - let text = match r.text().await { - Ok(v) => v, - Err(e) => { - last_err = format!("upstream:read:{base}:{e}"); - continue; - } - }; - - if !status.is_success() { - let snip: String = text.chars().take(200).collect(); - last_err = format!("upstream:{}:{base}:{snip}", status.as_u16()); - continue; - } - - return Ok(text); - } - - Err(if last_err.is_empty() { - "upstream:failed".to_string() - } else { - last_err - }) -} - -async fn upstream_media_url(id: u64, 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: u64) -> 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: Option, - FALLBACK: Option, -} - -#[derive(Deserialize)] -#[allow(non_snake_case)] -struct DeezerTrackFallback { - TRACK_TOKEN: Option, - SNG_ID: 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 json_error(StatusCode::BAD_REQUEST, "Format list cannot be empty"); - } - if req.ids.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "ID list cannot be empty"); - } - - 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).into_response(); - } - } - - 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","FALLBACK"]}), - ) - .await; - let track_list = match resp { - Ok(t) => t, - Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), - }; - - if track_list.data.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "No valid IDs found"); - } - - let mut track_tokens: Vec = Vec::new(); - for t in &track_list.data { - if let Some(ref tk) = t.TRACK_TOKEN { - if !tk.is_empty() { - track_tokens.push(tk.clone()); - } - } - if let Some(ref fb) = t.FALLBACK { - if let Some(ref tk) = fb.TRACK_TOKEN { - if !tk.is_empty() { - track_tokens.push(tk.clone()); - } - } - } - } - - if track_tokens.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "No tokens found in list data"); - } - - let track_tokens_refs: Vec<&str> = track_tokens.iter().map(|s| s.as_str()).collect(); - - match client.get_media(&req.formats, track_tokens_refs).await { - Ok(r) => r, - Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), - } - }; - - match media_resp.text().await { - Ok(t) => (StatusCode::OK, t).into_response(), - Err(e) => json_error(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: u64) -> [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: u64, - 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).0; - 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 decrypt_id = q.id; - let mut last_err: Option = None; - - if arl_key.is_none() { - tracing::error!("stream {}: arl missing", q.id); - return (StatusCode::SERVICE_UNAVAILABLE, "arl required").into_response(); - } - - for attempt in 0..2u8 { - 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; - } - } - match timeout(Duration::from_secs(12), media_url_for_track(&mut c, q.id, &formats)).await { - Ok(v) => v, - Err(_) => Err("timeout".to_string()), - } - }; - - match res { - Ok((u, id_used)) => { url = Some(u); decrypt_id = id_used; break; } - Err(e) => { last_err = Some(e); } - } - } - - let url = match url { - Some(u) => u, - None => { - let err = last_err.unwrap_or_else(|| "stream:no_url".to_string()); - tracing::error!("stream {} failed: {}", q.id, err); - // Return 404 so the browser audio element gracefully fails without triggering demuxer errors - return (StatusCode::NOT_FOUND, "Not Found").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::NOT_FOUND, "Upstream request failed".to_string()) - .into_response() - } - }; - - if !upstream.status().is_success() { - return ( - StatusCode::NOT_FOUND, - "Upstream status not OK".to_string(), - ) - .into_response(); - } - - let key = blowfish_key(decrypt_id); - let is_preview = url.contains("/preview/"); - 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 !is_preview && 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 upstream_content_type_str = upstream_headers - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("audio/mpeg"); - - let body = Body::from_stream(out); - let mut resp = axum::response::Response::new(body); - resp.headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_str(upstream_content_type_str).unwrap()); - 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; - } - } - } else { - if let Some(total) = upstream_total { - let _ = resp - .headers_mut() - .insert(CONTENT_LENGTH, HeaderValue::from_str(&total.to_string()).unwrap()); - } - } - - 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, - None, - ) - .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, - None, - ) - .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, - None, - ) - .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, - lang: Option<&str>, -) -> 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", format!("{},en;q=0.9", lang.unwrap_or("en-US"))) - .header("Content-Language", lang.unwrap_or("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 api_deezer_post( - client: &reqwest::Client, - path: &str, - access_token: &str, - form: &[(&str, String)], -) -> Result { - let url = format!("https://api.deezer.com{path}"); - let mut req = client - .post(url) - .header("Authorization", format!("Bearer {}", access_token)) - .query(&[("access_token", access_token)]); - if !form.is_empty() { - req = req.form(form); - } - 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())?; - - if text.trim() == "true" { - return Ok(json!(true)); - } - if text.trim() == "false" { - return Ok(json!(false)); - } - - 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 api_deezer_delete( - client: &reqwest::Client, - path: &str, - access_token: &str, - query: &[(&str, String)], -) -> Result { - let url = format!("https://api.deezer.com{path}"); - let mut req = client - .delete(url) - .header("Authorization", format!("Bearer {}", access_token)) - .query(&[("access_token", access_token)]); - if !query.is_empty() { - req = req.query(query); - } - 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())?; - - if text.trim() == "true" { - return Ok(json!(true)); - } - if text.trim() == "false" { - return Ok(json!(false)); - } - - 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; - - let html_head = r#"Deezer Login
"#; - let html_tail = r#"
"#; - - if code.is_empty() || email.is_empty() || password.is_empty() { - return Html(format!("{html_head}

Нужны code/email/password

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

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

{html_tail}")).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!("{html_head}

Ошибка входа

{}
{html_tail}", 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(format!("{html_head}{msg}{html_tail}")).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, - Some(&lang), - ) - .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, - Some(&lang), - ) - .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, - None, - ) - .await - { - Ok(v) => v, - Err(e) => return json_error(StatusCode::UNAUTHORIZED, format!("gw:getUserData:{e}")), - }; - - // Ignore the token passed from frontend because it might be stale. - // Always use the freshly fetched checkForm from getUserData. - let api_token = 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, - None, - ) - .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 OauthActionReq { - access_token: String, - track_id: Option, - playlist_id: Option, - title: Option, -} - -async fn oauth_favorite_add(Json(req): Json) -> impl IntoResponse { - let token = req.access_token.trim().to_string(); - let track_id = req.track_id.unwrap_or_default().trim().to_string(); - if token.is_empty() || track_id.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "access_token and track_id required"); - } - 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); - - let path = format!("/user/{uid}/tracks"); - match api_deezer_post(&client, &path, &token, &[("track_id", track_id)]).await { - Ok(v) => (StatusCode::OK, Json(v)).into_response(), - Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), - } -} - -async fn oauth_favorite_remove(Json(req): Json) -> impl IntoResponse { - let token = req.access_token.trim().to_string(); - let track_id = req.track_id.unwrap_or_default().trim().to_string(); - if token.is_empty() || track_id.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "access_token and track_id required"); - } - 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); - - let path = format!("/user/{uid}/tracks"); - match api_deezer_delete(&client, &path, &token, &[("track_id", track_id)]).await { - Ok(v) => (StatusCode::OK, Json(v)).into_response(), - Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), - } -} - -async fn oauth_playlist_add(Json(req): Json) -> impl IntoResponse { - let token = req.access_token.trim().to_string(); - let track_id = req.track_id.unwrap_or_default().trim().to_string(); - let playlist_id = req.playlist_id.unwrap_or_default().trim().to_string(); - if token.is_empty() || track_id.is_empty() || playlist_id.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "access_token, track_id, playlist_id required"); - } - let client = reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(15)).build().unwrap(); - let path = format!("/playlist/{playlist_id}/tracks"); - match api_deezer_post(&client, &path, &token, &[("songs", track_id)]).await { - Ok(v) => (StatusCode::OK, Json(v)).into_response(), - Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), - } -} - -async fn oauth_playlist_create(Json(req): Json) -> impl IntoResponse { - let token = req.access_token.trim().to_string(); - let title = req.title.unwrap_or_default().trim().to_string(); - if token.is_empty() || title.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "access_token and title required"); - } - 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); - - let path = format!("/user/{uid}/playlists"); - match api_deezer_post(&client, &path, &token, &[("title", title)]).await { - Ok(v) => (StatusCode::OK, Json(v)).into_response(), - Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), - } -} - -async fn oauth_playlist_update(Json(req): Json) -> impl IntoResponse { - let token = req.access_token.trim().to_string(); - let title = req.title.unwrap_or_default().trim().to_string(); - let playlist_id = req.playlist_id.unwrap_or_default().trim().to_string(); - if token.is_empty() || title.is_empty() || playlist_id.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "access_token, title, playlist_id required"); - } - let client = reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(15)).build().unwrap(); - let path = format!("/playlist/{playlist_id}"); - match api_deezer_post(&client, &path, &token, &[("title", title)]).await { - Ok(v) => (StatusCode::OK, Json(v)).into_response(), - Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), - } -} - -async fn oauth_playlist_delete(Json(req): Json) -> impl IntoResponse { - let token = req.access_token.trim().to_string(); - let playlist_id = req.playlist_id.unwrap_or_default().trim().to_string(); - if token.is_empty() || playlist_id.is_empty() { - return json_error(StatusCode::BAD_REQUEST, "access_token and playlist_id required"); - } - let client = reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(15)).build().unwrap(); - let path = format!("/playlist/{playlist_id}"); - match api_deezer_delete(&client, &path, &token, &[]).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.session_tokens() - }; - - 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("0.0.0.0".to_string()); - let port = std::env::var("PORT").unwrap_or("7860".to_string()); - let port: u16 = port.parse().unwrap_or(7860); - - 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()), - arl_store_path: std::env::var("ARL_STORE_PATH").ok().unwrap_or_else(|| "arl_store.json".to_string()), - }; - arl_store_load(&state).await; - 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)) - .route("/oauth/favorite_add", post(oauth_favorite_add)) - .route("/oauth/favorite_remove", post(oauth_favorite_remove)) - .route("/oauth/playlist_add", post(oauth_playlist_add)) - .route("/oauth/playlist_create", post(oauth_playlist_create)) - .route("/oauth/playlist_update", post(oauth_playlist_update)) - .route("/oauth/playlist_delete", post(oauth_playlist_delete)) - .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(); -} +#![allow(dead_code)] + +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}; +use tokio::time::{timeout, Duration}; + +type BoxErr = Box; + +mod api; +use api::{APIClient, APIError, Format}; + +#[derive(Clone, Serialize, Deserialize)] +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, + arl_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(), + }, + ); + arl_store_save(state).await; +} + +async fn arl_store_save(state: &AppState) { + let path = state.arl_store_path.trim(); + if path.is_empty() { + return; + } + let map = state.arl_sessions.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() { + let _ = tokio::fs::rename(&tmp, path).await; + } +} + +async fn arl_store_load(state: &AppState) { + let path = state.arl_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.arl_sessions.write().await = map; + } + } +} + +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_and_format(media_json: &serde_json::Value, formats: &[&str]) -> (String, String, usize) { + let data = media_json.get("data").and_then(|v| v.as_array()); + let Some(data_arr) = data else { + return (String::new(), String::new(), 0); + }; + + for f_str in formats { + for (i, data_item) in data_arr.iter().enumerate() { + if let Some(errs) = data_item.get("errors").and_then(|v| v.as_array()) { + if !errs.is_empty() { continue; } + } + let media_arr = data_item.get("media").and_then(|v| v.as_array()); + let Some(media_arr) = media_arr else { continue; }; + for m in media_arr { + let m_format = m.get("format").and_then(|v| v.as_str()).unwrap_or(""); + if m_format == *f_str { + if let Some(sources) = m.get("sources").and_then(|v| v.as_array()) { + let mut preferred = Vec::new(); + if let Some(s1) = sources.get(1).and_then(|s| s.get("url")).and_then(|u| u.as_str()) { preferred.push(s1); } + for s in sources { + if let Some(u) = s.get("url").and_then(|v| v.as_str()) { + if u.contains("dzcdn.net/media/") { preferred.push(u); } + } + } + if let Some(s0) = sources.get(0).and_then(|s| s.get("url")).and_then(|u| u.as_str()) { preferred.push(s0); } + for s in sources { + if let Some(u) = s.get("url").and_then(|v| v.as_str()) { preferred.push(u); } + } + for p in preferred { + let p = p.trim(); + if !p.is_empty() { + return (p.to_string(), m_format.to_string(), i); + } + } + } + } + } + } + } + + for (i, data_item) in data_arr.iter().enumerate() { + if let Some(errs) = data_item.get("errors").and_then(|v| v.as_array()) { + if !errs.is_empty() { continue; } + } + let media_arr = data_item.get("media").and_then(|v| v.as_array()); + let Some(media_arr) = media_arr else { continue; }; + for m in media_arr { + let m_format = m.get("format").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(sources) = m.get("sources").and_then(|v| v.as_array()) { + for s in sources { + if let Some(u) = s.get("url").and_then(|v| v.as_str()) { + let p = u.trim(); + if !p.is_empty() { + return (p.to_string(), m_format.to_string(), i); + } + } + } + } + } + } + + (String::new(), String::new(), 0) +} + +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_tokens: Vec<&str>, +) -> Result<(String, String, usize), String> { + if client.license_token.is_empty() { + return Err("license_token empty".to_string()); + } + + let media_resp = client + .get_media(&formats.to_vec(), track_tokens) + .await + .map_err(|e| e.to_string())?; + + let status = media_resp.status(); + let media_text = media_resp + .text() + .await + .map_err(|_| "media:read".to_string())?; + + let media_json: serde_json::Value = serde_json::from_str(&media_text).map_err(|_| { + let snip: String = media_text.chars().take(200).collect(); + format!("media_bad_json:{}:{}", status.as_u16(), snip) + })?; + + let format_strs: Vec<&str> = formats.iter().map(|f| match f { + Format::FLAC => "FLAC", + Format::MP3_320 => "MP3_320", + Format::MP3_MISC => "MP3_MISC", + Format::MP3_128 => "MP3_128", + }).collect(); + + let (url, fmt, idx) = extract_media_url_and_format(&media_json, &format_strs); + if !url.is_empty() { + return Ok((url, fmt, idx)); + } + + Err(format!( + "media:{}:{}", + status.as_u16(), + media_response_err(&media_json) + )) +} + +async fn media_url_for_track( + client: &mut APIClient, + id: u64, + formats: &Vec, +) -> Result<(String, String, u64), String> { + 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","FALLBACK"]}), + ) + .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_deref().unwrap_or(""); + let fallback_token = track_list.data[0] + .FALLBACK + .as_ref() + .and_then(|f| f.TRACK_TOKEN.as_deref()) + .unwrap_or(""); + + let fallback_id = track_list.data[0] + .FALLBACK + .as_ref() + .and_then(|f| f.SNG_ID.as_ref().and_then(|s| s.parse::().ok())) + .unwrap_or(0); + + let mut tokens = Vec::new(); + let mut token_ids = Vec::new(); + if !track_token.is_empty() { + tokens.push(track_token); + token_ids.push(id); + } + if !fallback_token.is_empty() { + tokens.push(fallback_token); + let fid = if fallback_id > 0 { fallback_id } else { id }; + token_ids.push(fid); + } + + if tokens.is_empty() { + return Err("empty TRACK_TOKEN and FALLBACK".to_string()); + } + + let (url, fmt, idx) = fetch_media_url(client, formats, tokens).await?; + let id_to_use = *token_ids.get(idx).unwrap_or(&id); + Ok((url, fmt, id_to_use)) +} + +fn upstream_bases() -> Vec { + let mut out: Vec = Vec::new(); + + let env = std::env::var("DZMEDIA_UPSTREAM").ok().unwrap_or_default(); + let env = env.trim(); + if env == "-" { + return out; + } + + if !env.is_empty() { + for part in env.split(',') { + let s = part.trim(); + if s.is_empty() { + continue; + } + out.push(s.trim_end_matches('/').to_string()); + } + } + + out +} + +fn upstream_get_url_endpoint(base: &str) -> String { + let b = base.trim_end_matches('/'); + if b.ends_with("/get_url") { + b.to_string() + } else { + format!("{b}/get_url") + } +} + +async fn upstream_get_url_text(formats: &Vec, ids: &Vec) -> Result { + let bases = upstream_bases(); + if bases.is_empty() { + return Err("upstream:disabled".to_string()); + } + + let client = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(20)) + .build() + .map_err(|_| "upstream:client".to_string())?; + + let mut last_err = String::new(); + for base in bases { + let url = upstream_get_url_endpoint(&base); + let r = match client + .post(url) + .json(&json!({ "formats": formats, "ids": ids })) + .send() + .await + { + Ok(v) => v, + Err(e) => { + last_err = format!("upstream:network:{base}:{e}"); + continue; + } + }; + + let status = r.status(); + let text = match r.text().await { + Ok(v) => v, + Err(e) => { + last_err = format!("upstream:read:{base}:{e}"); + continue; + } + }; + + if !status.is_success() { + let snip: String = text.chars().take(200).collect(); + last_err = format!("upstream:{}:{base}:{snip}", status.as_u16()); + continue; + } + + return Ok(text); + } + + Err(if last_err.is_empty() { + "upstream:failed".to_string() + } else { + last_err + }) +} + +async fn upstream_media_url(id: u64, 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 format_strs: Vec<&str> = formats.iter().map(|f| match f { + Format::FLAC => "FLAC", + Format::MP3_320 => "MP3_320", + Format::MP3_MISC => "MP3_MISC", + Format::MP3_128 => "MP3_128", + }).collect(); + let (url, _, _) = extract_media_url_and_format(&v, &format_strs); + if url.is_empty() { + return Err("upstream:no_url".to_string()); + } + Ok(url) +} + +async fn public_track_duration(id: u64) -> 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: Option, + FALLBACK: Option, +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct DeezerTrackFallback { + TRACK_TOKEN: Option, + SNG_ID: 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 json_error(StatusCode::BAD_REQUEST, "Format list cannot be empty"); + } + if req.ids.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "ID list cannot be empty"); + } + + 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).into_response(); + } + } + + 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","FALLBACK"]}), + ) + .await; + let track_list = match resp { + Ok(t) => t, + Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), + }; + + if track_list.data.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "No valid IDs found"); + } + + let mut track_tokens: Vec = Vec::new(); + for t in &track_list.data { + if let Some(ref tk) = t.TRACK_TOKEN { + if !tk.is_empty() { + track_tokens.push(tk.clone()); + } + } + if let Some(ref fb) = t.FALLBACK { + if let Some(ref tk) = fb.TRACK_TOKEN { + if !tk.is_empty() { + track_tokens.push(tk.clone()); + } + } + } + } + + if track_tokens.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "No tokens found in list data"); + } + + let track_tokens_refs: Vec<&str> = track_tokens.iter().map(|s| s.as_str()).collect(); + + match client.get_media(&req.formats, track_tokens_refs).await { + Ok(r) => r, + Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), + } + }; + + match media_resp.text().await { + Ok(t) => (StatusCode::OK, t).into_response(), + Err(e) => json_error(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: u64) -> [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: u64, + 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) { + let format_strs: Vec<&str> = formats.iter().map(|f| match f { + Format::FLAC => "FLAC", + Format::MP3_320 => "MP3_320", + Format::MP3_MISC => "MP3_MISC", + Format::MP3_128 => "MP3_128", + }).collect(); + let (u, f, _) = extract_media_url_and_format(&v, &format_strs); + url = u; + used = f; + } + } + + // 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, f, _)) => { url = u; used = f; 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 decrypt_id = q.id; + let mut last_err: Option = None; + + if arl_key.is_none() { + tracing::error!("stream {}: arl missing", q.id); + return (StatusCode::SERVICE_UNAVAILABLE, "arl required").into_response(); + } + + for attempt in 0..2u8 { + 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; + } + } + match timeout(Duration::from_secs(12), media_url_for_track(&mut c, q.id, &formats)).await { + Ok(v) => v, + Err(_) => Err("timeout".to_string()), + } + }; + + match res { + Ok((u, id_used)) => { url = Some(u); decrypt_id = id_used; break; } + Err(e) => { last_err = Some(e); } + } + } + + let url = match url { + Some(u) => u, + None => { + let err = last_err.unwrap_or_else(|| "stream:no_url".to_string()); + tracing::error!("stream {} failed: {}", q.id, err); + // Return 404 so the browser audio element gracefully fails without triggering demuxer errors + return (StatusCode::NOT_FOUND, "Not Found").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::NOT_FOUND, "Upstream request failed".to_string()) + .into_response() + } + }; + + if !upstream.status().is_success() { + return ( + StatusCode::NOT_FOUND, + "Upstream status not OK".to_string(), + ) + .into_response(); + } + + let key = blowfish_key(decrypt_id); + let is_preview = url.contains("/preview/"); + 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 !is_preview && 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 upstream_content_type_str = upstream_headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("audio/mpeg"); + + let body = Body::from_stream(out); + let mut resp = axum::response::Response::new(body); + resp.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_str(upstream_content_type_str).unwrap()); + 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; + } + } + } else { + if let Some(total) = upstream_total { + let _ = resp + .headers_mut() + .insert(CONTENT_LENGTH, HeaderValue::from_str(&total.to_string()).unwrap()); + } + } + + 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, + None, + ) + .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, + None, + ) + .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, + None, + ) + .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, + lang: Option<&str>, +) -> 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", format!("{},en;q=0.9", lang.unwrap_or("en-US"))) + .header("Content-Language", lang.unwrap_or("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 api_deezer_post( + client: &reqwest::Client, + path: &str, + access_token: &str, + form: &[(&str, String)], +) -> Result { + let url = format!("https://api.deezer.com{path}"); + let mut req = client + .post(url) + .header("Authorization", format!("Bearer {}", access_token)) + .query(&[("access_token", access_token)]); + if !form.is_empty() { + req = req.form(form); + } + 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())?; + + if text.trim() == "true" { + return Ok(json!(true)); + } + if text.trim() == "false" { + return Ok(json!(false)); + } + + 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 api_deezer_delete( + client: &reqwest::Client, + path: &str, + access_token: &str, + query: &[(&str, String)], +) -> Result { + let url = format!("https://api.deezer.com{path}"); + let mut req = client + .delete(url) + .header("Authorization", format!("Bearer {}", access_token)) + .query(&[("access_token", access_token)]); + if !query.is_empty() { + req = req.query(query); + } + 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())?; + + if text.trim() == "true" { + return Ok(json!(true)); + } + if text.trim() == "false" { + return Ok(json!(false)); + } + + 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; + + let html_head = r#"Deezer Login
"#; + let html_tail = r#"
"#; + + if code.is_empty() || email.is_empty() || password.is_empty() { + return Html(format!("{html_head}

Нужны code/email/password

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

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

{html_tail}")).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!("{html_head}

Ошибка входа

{}
{html_tail}", 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(format!("{html_head}{msg}{html_tail}")).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, + Some(&lang), + ) + .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, + Some(&lang), + ) + .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, + None, + ) + .await + { + Ok(v) => v, + Err(e) => return json_error(StatusCode::UNAUTHORIZED, format!("gw:getUserData:{e}")), + }; + + // Ignore the token passed from frontend because it might be stale. + // Always use the freshly fetched checkForm from getUserData. + let api_token = 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, + None, + ) + .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 OauthActionReq { + access_token: String, + track_id: Option, + playlist_id: Option, + title: Option, +} + +async fn oauth_favorite_add(Json(req): Json) -> impl IntoResponse { + let token = req.access_token.trim().to_string(); + let track_id = req.track_id.unwrap_or_default().trim().to_string(); + if token.is_empty() || track_id.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "access_token and track_id required"); + } + 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); + + let path = format!("/user/{uid}/tracks"); + match api_deezer_post(&client, &path, &token, &[("track_id", track_id)]).await { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), + } +} + +async fn oauth_favorite_remove(Json(req): Json) -> impl IntoResponse { + let token = req.access_token.trim().to_string(); + let track_id = req.track_id.unwrap_or_default().trim().to_string(); + if token.is_empty() || track_id.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "access_token and track_id required"); + } + 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); + + let path = format!("/user/{uid}/tracks"); + match api_deezer_delete(&client, &path, &token, &[("track_id", track_id)]).await { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), + } +} + +async fn oauth_playlist_add(Json(req): Json) -> impl IntoResponse { + let token = req.access_token.trim().to_string(); + let track_id = req.track_id.unwrap_or_default().trim().to_string(); + let playlist_id = req.playlist_id.unwrap_or_default().trim().to_string(); + if token.is_empty() || track_id.is_empty() || playlist_id.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "access_token, track_id, playlist_id required"); + } + let client = reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(15)).build().unwrap(); + let path = format!("/playlist/{playlist_id}/tracks"); + match api_deezer_post(&client, &path, &token, &[("songs", track_id)]).await { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), + } +} + +async fn oauth_playlist_create(Json(req): Json) -> impl IntoResponse { + let token = req.access_token.trim().to_string(); + let title = req.title.unwrap_or_default().trim().to_string(); + if token.is_empty() || title.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "access_token and title required"); + } + 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); + + let path = format!("/user/{uid}/playlists"); + match api_deezer_post(&client, &path, &token, &[("title", title)]).await { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), + } +} + +async fn oauth_playlist_update(Json(req): Json) -> impl IntoResponse { + let token = req.access_token.trim().to_string(); + let title = req.title.unwrap_or_default().trim().to_string(); + let playlist_id = req.playlist_id.unwrap_or_default().trim().to_string(); + if token.is_empty() || title.is_empty() || playlist_id.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "access_token, title, playlist_id required"); + } + let client = reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(15)).build().unwrap(); + let path = format!("/playlist/{playlist_id}"); + match api_deezer_post(&client, &path, &token, &[("title", title)]).await { + Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), + } +} + +async fn oauth_playlist_delete(Json(req): Json) -> impl IntoResponse { + let token = req.access_token.trim().to_string(); + let playlist_id = req.playlist_id.unwrap_or_default().trim().to_string(); + if token.is_empty() || playlist_id.is_empty() { + return json_error(StatusCode::BAD_REQUEST, "access_token and playlist_id required"); + } + let client = reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(15)).build().unwrap(); + let path = format!("/playlist/{playlist_id}"); + match api_deezer_delete(&client, &path, &token, &[]).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.session_tokens() + }; + + 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("0.0.0.0".to_string()); + let port = std::env::var("PORT").unwrap_or("7860".to_string()); + let port: u16 = port.parse().unwrap_or(7860); + + 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()), + arl_store_path: std::env::var("ARL_STORE_PATH").ok().unwrap_or_else(|| "arl_store.json".to_string()), + }; + arl_store_load(&state).await; + 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)) + .route("/oauth/favorite_add", post(oauth_favorite_add)) + .route("/oauth/favorite_remove", post(oauth_favorite_remove)) + .route("/oauth/playlist_add", post(oauth_playlist_add)) + .route("/oauth/playlist_create", post(oauth_playlist_create)) + .route("/oauth/playlist_update", post(oauth_playlist_update)) + .route("/oauth/playlist_delete", post(oauth_playlist_delete)) + .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(); +}