Spaces:
Running
Running
| 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 lofty::{ | |
| config::WriteOptions, | |
| picture::{Picture, PictureType}, | |
| tag::{Accessor, Tag, TagExt, TagType}, | |
| }; | |
| use std::io::Cursor; | |
| use std::time::{SystemTime, UNIX_EPOCH}; | |
| use tokio::sync::{Mutex, RwLock}; | |
| use tokio::time::{timeout, Duration}; | |
| type BoxErr = Box<dyn std::error::Error + Send + Sync + 'static>; | |
| mod api; | |
| use api::{APIClient, APIError, Format}; | |
| mod soundcloud; | |
| struct ArlSession { | |
| check_form: String, | |
| license_token: String, | |
| } | |
| struct CdnCacheEntry { | |
| cdn_url: String, | |
| decrypt_id: u64, | |
| format: String, | |
| expires_at: std::time::Instant, | |
| } | |
| struct AppState { | |
| api: Arc<Mutex<APIClient>>, | |
| api_by_arl: Arc<RwLock<HashMap<String, Arc<Mutex<APIClient>>>>>, | |
| arl_sessions: Arc<RwLock<HashMap<String, ArlSession>>>, | |
| pair: Arc<RwLock<HashMap<String, PairSession>>>, | |
| pair_store_path: String, | |
| arl_store_path: String, | |
| /// CDN URL кэш для предзагрузки. Ключ: track_id. TTL 20 мин. | |
| cdn_cache: Arc<RwLock<HashMap<u64, CdnCacheEntry>>>, | |
| } | |
| 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::<HashMap<String, ArlSession>>(&txt) { | |
| *state.arl_sessions.write().await = map; | |
| } | |
| } | |
| } | |
| fn json_error(status: StatusCode, message: impl Into<String>) -> axum::response::Response { | |
| (status, Json(json!({ "error": message.into() }))).into_response() | |
| } | |
| fn now_ms() -> u128 { | |
| SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|d| d.as_millis()) | |
| .unwrap_or(0) | |
| } | |
| async fn api_client_for_arl(state: &AppState, arl: Option<String>) -> Arc<Mutex<APIClient>> { | |
| let Some(arl) = arl.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) else { | |
| return state.api.clone(); | |
| }; | |
| let mut map = state.api_by_arl.write().await; | |
| if let Some(c) = map.get(&arl) { | |
| return c.clone(); | |
| } | |
| if map.len() >= 32 { | |
| map.clear(); | |
| } | |
| let session = state.arl_sessions.read().await.get(&arl).cloned(); | |
| let client = if let Some(s) = session { | |
| APIClient::new_with_arl_session(arl.clone(), s.check_form, s.license_token) | |
| } else { | |
| APIClient::new_with_arl(arl.clone()) | |
| }; | |
| let c = Arc::new(Mutex::new(client)); | |
| map.insert(arl, c.clone()); | |
| c | |
| } | |
| fn extract_media_url_and_format(json: &serde_json::Value, formats: &Vec<&str>) -> (String, String, usize) { | |
| use std::io::Write; | |
| let mut log_file = std::fs::OpenOptions::new().create(true).append(true).open("debug_dzmedia.log").unwrap(); | |
| let _ = writeln!(log_file, "--- MEDIA JSON RESPONSE ---"); | |
| let _ = writeln!(log_file, "{}", serde_json::to_string_pretty(json).unwrap_or_default()); | |
| let data = 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 mut m_format = m.get("format").and_then(|v| v.as_str()).unwrap_or(""); | |
| if m_format.is_empty() { | |
| m_format = m.get("format_name").and_then(|v| v.as_str()).unwrap_or(""); | |
| } | |
| let mut matched = false; | |
| if m_format.eq_ignore_ascii_case(*f_str) { | |
| matched = true; | |
| } else if m_format.is_empty() { | |
| if let Some(n) = m.get("format").and_then(|v| v.as_i64()) { | |
| let n_str = match n { | |
| 9 => "FLAC", | |
| 3 => "MP3_320", | |
| 1 => "MP3_128", | |
| _ => "", | |
| }; | |
| if n_str.eq_ignore_ascii_case(*f_str) { | |
| matched = true; | |
| m_format = n_str; | |
| } | |
| } | |
| } | |
| if matched { | |
| 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 mut m_format = m.get("format").and_then(|v| v.as_str()).unwrap_or(""); | |
| if m_format.is_empty() { | |
| m_format = m.get("format_name").and_then(|v| v.as_str()).unwrap_or(""); | |
| } | |
| if m_format.is_empty() { | |
| if let Some(n) = m.get("format").and_then(|v| v.as_i64()) { | |
| m_format = match n { | |
| 9 => "FLAC", | |
| 3 => "MP3_320", | |
| 1 => "MP3_128", | |
| _ => "", | |
| }; | |
| } | |
| } | |
| 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<u64> { | |
| let v = v.trim(); | |
| let (_, rest) = v.split_once('/')?; | |
| if rest.trim() == "*" { | |
| return None; | |
| } | |
| rest.trim().parse::<u64>().ok() | |
| } | |
| fn media_response_err(json: &serde_json::Value) -> String { | |
| if let Some(errs) = json.get("errors").and_then(|v| v.as_array()) { | |
| if let Some(first) = errs.first() { | |
| let s = first.to_string(); | |
| return s.chars().take(160).collect(); | |
| } | |
| } | |
| json.to_string().chars().take(160).collect() | |
| } | |
| async fn fetch_media_url( | |
| client: &APIClient, | |
| formats: &[Format], | |
| track_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_strings: Vec<String> = formats.iter().map(|f| format!("{:?}", f)).collect(); | |
| let format_strs: Vec<&str> = format_strings.iter().map(|s| s.as_str()).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 resolve_fallback_id(client: &mut APIClient, id: u64) -> u64 { | |
| if client.license_token.is_empty() { | |
| let _ = client.force_renew().await; | |
| } | |
| let q_json = serde_json::json!({"sng_ids":[id],"array_default":["SNG_ID","FALLBACK"]}); | |
| if let Ok(r) = client.api_call::<serde_json::Value, serde_json::Value>("song.getListData", &q_json).await { | |
| if let Some(item) = r.pointer("/data/0") { | |
| if let Some(fallback) = item.get("FALLBACK") { | |
| if let Some(fid) = fallback.get("SNG_ID").and_then(|v| { | |
| if let Some(s) = v.as_str() { s.parse::<u64>().ok() } | |
| else if let Some(n) = v.as_u64() { Some(n) } | |
| else { None } | |
| }) { | |
| if fid > 0 { return fid; } | |
| } | |
| } | |
| } | |
| } | |
| id | |
| } | |
| async fn media_url_for_track( | |
| client: &mut APIClient, | |
| id: u64, | |
| formats: &Vec<Format>, | |
| ) -> Result<(String, String, u64), String> { | |
| use std::io::Write; | |
| let mut log_file = std::fs::OpenOptions::new().create(true).append(true).open("debug_dzmedia.log").unwrap(); | |
| let _ = writeln!(log_file, "--- NEW REQUEST FOR ID {} ---", id); | |
| if client.license_token.is_empty() { | |
| if let Err(e) = client.force_renew().await { | |
| let _ = writeln!(log_file, "force_renew err: {}", e); | |
| return Err(format!("renew:{e}")); | |
| } | |
| } | |
| let q_json = serde_json::json!({"sng_ids":[id],"array_default":["SNG_ID","TRACK_TOKEN","FALLBACK"]}); | |
| let r: serde_json::Value = client.api_call("song.getListData", &q_json).await.map_err(|e| { | |
| let _ = writeln!(log_file, "api_call err: {}", e); | |
| e.to_string() | |
| })?; | |
| let item = r.pointer("/data/0").ok_or_else(|| { | |
| let _ = writeln!(log_file, "No valid ID"); | |
| "No valid ID".to_string() | |
| })?; | |
| let track_token = item.get("TRACK_TOKEN").and_then(|v| v.as_str()).unwrap_or("").to_string(); | |
| let fallback = item.get("FALLBACK"); | |
| let fallback_token = fallback.and_then(|f| f.get("TRACK_TOKEN")).and_then(|v| v.as_str()).unwrap_or("").to_string(); | |
| let fallback_id = fallback.and_then(|f| { | |
| f.get("SNG_ID").and_then(|v| { | |
| if let Some(s) = v.as_str() { s.parse::<u64>().ok() } | |
| else if let Some(n) = v.as_u64() { Some(n) } | |
| else { None } | |
| }) | |
| }).unwrap_or(0); | |
| let _ = writeln!(log_file, "track_token: {}, fallback_id: {}, fallback_token: {}", track_token, fallback_id, fallback_token); | |
| if fallback_id > 0 { | |
| let q_fb = serde_json::json!({"sng_ids":[fallback_id],"array_default":["SNG_ID","TRACK_TOKEN","FALLBACK"]}); | |
| let _ = writeln!(log_file, "Querying fallback ID: {}", fallback_id); | |
| if let Ok(r_fb) = client.api_call::<serde_json::Value, serde_json::Value>("song.getListData", &q_fb).await { | |
| if let Some(item_fb) = r_fb.pointer("/data/0") { | |
| if let Some(real_tk) = item_fb.get("TRACK_TOKEN").and_then(|v| v.as_str()) { | |
| let _ = writeln!(log_file, "Fetched real_tk for fallback: {}", real_tk); | |
| let tokens = vec![real_tk]; | |
| match fetch_media_url(client, formats, tokens).await { | |
| Ok((url, fmt, _)) => { | |
| let _ = writeln!(log_file, "fetch_media_url SUCCESS for fallback: fmt={}", fmt); | |
| return Ok((url, fmt, fallback_id)); | |
| }, | |
| Err(e) => { | |
| let _ = writeln!(log_file, "fetch_media_url FAILED for fallback: {}", e); | |
| } | |
| } | |
| } else { | |
| let _ = writeln!(log_file, "NO TRACK_TOKEN in fallback response"); | |
| } | |
| } else { | |
| let _ = writeln!(log_file, "Empty data array in fallback response"); | |
| } | |
| } else { | |
| let _ = writeln!(log_file, "api_call for fallback ID FAILED"); | |
| } | |
| } | |
| let mut tokens = Vec::new(); | |
| let mut token_ids = Vec::new(); | |
| if !track_token.is_empty() { | |
| tokens.push(track_token.as_str()); | |
| token_ids.push(id); | |
| } | |
| if !fallback_token.is_empty() { | |
| tokens.push(fallback_token.as_str()); | |
| let fid = if fallback_id > 0 { fallback_id } else { id }; | |
| token_ids.push(fid); | |
| } | |
| if tokens.is_empty() { | |
| let _ = writeln!(log_file, "empty TRACK_TOKEN and FALLBACK"); | |
| return Err("empty TRACK_TOKEN and FALLBACK".to_string()); | |
| } | |
| let _ = writeln!(log_file, "Falling back to old logic. Tokens: {:?}", tokens); | |
| let res = fetch_media_url(client, formats, tokens).await; | |
| match res { | |
| Ok((url, fmt, idx)) => { | |
| let _ = writeln!(log_file, "Old logic SUCCESS: fmt={}", fmt); | |
| let id_to_use = *token_ids.get(idx).unwrap_or(&id); | |
| Ok((url, fmt, id_to_use)) | |
| }, | |
| Err(e) => { | |
| let _ = writeln!(log_file, "Old logic FAILED: {}", e); | |
| Err(e) | |
| } | |
| } | |
| } | |
| fn upstream_bases() -> Vec<String> { | |
| let mut out: Vec<String> = 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<Format>, ids: &Vec<u64>) -> Result<String, String> { | |
| 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<Format>) -> Result<String, String> { | |
| let text = upstream_get_url_text(formats, &vec![id]).await?; | |
| let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "upstream:parse".to_string())?; | |
| let format_strings: Vec<String> = formats.iter().map(|f| format!("{:?}", f)).collect(); | |
| let format_strs: Vec<&str> = format_strings.iter().map(|s| s.as_str()).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<u32> { | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(10)) | |
| .build() | |
| .ok()?; | |
| let r = client | |
| .get(format!("https://api.deezer.com/track/{id}")) | |
| .send() | |
| .await | |
| .ok()?; | |
| if !r.status().is_success() { | |
| return None; | |
| } | |
| let v: serde_json::Value = r.json().await.ok()?; | |
| v.get("duration").and_then(|d| d.as_u64()).map(|d| d as u32) | |
| } | |
| struct DeezerTrackList { | |
| data: Vec<DeezerTrack> | |
| } | |
| struct DeezerTrack { | |
| TRACK_TOKEN: Option<String>, | |
| FALLBACK: Option<DeezerTrackFallback>, | |
| } | |
| struct DeezerTrackFallback { | |
| TRACK_TOKEN: Option<String>, | |
| SNG_ID: Option<String>, | |
| } | |
| async fn root() -> &'static str { | |
| "marecchione gay af (v2.1)" | |
| } | |
| struct RequestParams { | |
| formats: Vec<Format>, | |
| ids: Vec<u64>, | |
| arl: Option<String>, | |
| } | |
| async fn get_url(State(state): State<AppState>, Json(req): Json<RequestParams>) -> 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<DeezerTrackList, APIError> = 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<String> = 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()), | |
| } | |
| } | |
| struct FetchParams { | |
| url: String, | |
| } | |
| async fn fetch(Query(q): Query<FetchParams>) -> impl IntoResponse { | |
| let parsed = match q.url.parse::<Url>() { | |
| Ok(u) => u, | |
| Err(_) => return (StatusCode::BAD_REQUEST, "Invalid url".to_string()).into_response(), | |
| }; | |
| let host = parsed.host_str().unwrap_or(""); | |
| if !(host.ends_with(".dzcdn.net")) { | |
| return (StatusCode::FORBIDDEN, "Host not allowed".to_string()).into_response(); | |
| } | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(60)) | |
| .build() | |
| .unwrap(); | |
| let resp = match client.get(parsed).send().await { | |
| Ok(r) => r, | |
| Err(_) => return (StatusCode::BAD_GATEWAY, "Upstream request failed".to_string()).into_response(), | |
| }; | |
| let status = resp.status(); | |
| if !status.is_success() { | |
| return (StatusCode::BAD_GATEWAY, format!("Upstream status: {}", status)).into_response(); | |
| } | |
| let bytes = match resp.bytes().await { | |
| Ok(b) => b, | |
| Err(_) => return (StatusCode::BAD_GATEWAY, "Upstream read failed".to_string()).into_response(), | |
| }; | |
| ( | |
| StatusCode::OK, | |
| [(CONTENT_TYPE, "audio/mpeg")], | |
| Bytes::from(bytes), | |
| ) | |
| .into_response() | |
| } | |
| const SECRET: &str = "g4el58wc0zvf9na1"; | |
| fn blowfish_key(track_id: 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 | |
| } | |
| /// Синхронная (CPU-bound) расшифровка блочного blowfish-шифра. Вынесена в | |
| /// отдельную функцию, чтобы вызывать её через tokio::task::spawn_blocking — | |
| /// иначе на слабом контейнере (0.25 vCPU) расшифровка крупного FLAC блокирует | |
| /// единственный воркер-поток раннера на секунды, и в это время сервис не | |
| /// отвечает вообще ни на что, включая health-check от прокси (отсюда 502 | |
| /// "Host: Error" при живом и не упавшем процессе). | |
| fn decrypt_track_sync(enc: &[u8], decrypt_id: u64) -> Vec<u8> { | |
| let key = blowfish_key(decrypt_id); | |
| let mut dec = enc.to_vec(); | |
| let block_size = 2048usize; | |
| let mut bi = 0usize; | |
| let mut pos = 0usize; | |
| while pos + block_size <= dec.len() { | |
| if bi % 3 == 0 { | |
| let _ = decrypt_stripe(&mut dec[pos..pos + block_size], &key); | |
| } | |
| bi += 1; | |
| pos += block_size; | |
| } | |
| dec | |
| } | |
| fn decrypt_stripe(block: &mut [u8], key: &[u8; 16]) -> Result<(), ()> { | |
| let iv = [0u8, 1, 2, 3, 4, 5, 6, 7]; | |
| let dec = Decryptor::<Blowfish>::new_from_slices(key, &iv).map_err(|_| ())?; | |
| dec.decrypt_padded_mut::<NoPadding>(block).map_err(|_| ())?; | |
| Ok(()) | |
| } | |
| struct StreamParams { | |
| id: u64, | |
| format: Option<String>, | |
| arl: Option<String>, | |
| title: Option<String>, | |
| performer: Option<String>, | |
| duration: Option<u64>, | |
| album: Option<String>, | |
| cover_url: Option<String>, | |
| } | |
| struct AudioTagMeta { | |
| title: Option<String>, | |
| performer: Option<String>, | |
| album: Option<String>, | |
| cover_url: Option<String>, | |
| } | |
| fn normalized_opt(value: &Option<String>) -> Option<String> { | |
| value | |
| .as_ref() | |
| .map(|s| s.trim().to_string()) | |
| .filter(|s| !s.is_empty()) | |
| } | |
| impl From<&StreamParams> for AudioTagMeta { | |
| fn from(params: &StreamParams) -> Self { | |
| Self { | |
| title: normalized_opt(¶ms.title), | |
| performer: normalized_opt(¶ms.performer), | |
| album: normalized_opt(¶ms.album), | |
| cover_url: normalized_opt(¶ms.cover_url), | |
| } | |
| } | |
| } | |
| async fn stream_info(State(state): State<AppState>, Query(q): Query<StreamParams>) -> impl IntoResponse { | |
| let _ = std::fs::remove_file("debug_dzmedia.log"); | |
| let formats = match q.format.as_deref().unwrap_or("AUTO") { | |
| "FLAC" => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| "MP3_320" => vec![Format::MP3_320, Format::MP3_128], | |
| "MP3_MISC" => vec![Format::MP3_MISC, Format::MP3_128], | |
| "MP3_128" => vec![Format::MP3_128], | |
| _ => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| }; | |
| let requested = q.format.clone().unwrap_or_else(|| "AUTO".to_string()); | |
| let mut url: String = String::new(); | |
| let mut used: String = String::new(); | |
| let mut last_err: Option<String> = None; | |
| let client = api_client_for_arl(&state, q.arl.clone()).await; | |
| let target_id = { | |
| let mut c = client.lock().await; | |
| resolve_fallback_id(&mut c, q.id).await | |
| }; | |
| // Upstream (опционально — если DZMEDIA_UPSTREAM задан) | |
| if let Ok(text) = upstream_get_url_text(&formats, &vec![target_id]).await { | |
| if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) { | |
| let format_strings: Vec<String> = formats.iter().map(|f| format!("{:?}", f)).collect(); | |
| let format_strs: Vec<&str> = format_strings.iter().map(|s| s.as_str()).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::<u64>().unwrap_or(0); | |
| } | |
| } | |
| let bitrate_kbps = if duration > 0 && total_bytes > 0 { | |
| ((total_bytes as f64) * 8.0 / (duration as f64) / 1000.0).round() as u64 | |
| } else { 0 }; | |
| let log_data = std::fs::read_to_string("debug_dzmedia.log").unwrap_or_default(); | |
| (StatusCode::OK, Json(json!({ | |
| "id": q.id, | |
| "requested": requested, | |
| "used": used, | |
| "duration": duration, | |
| "bytes": total_bytes, | |
| "bitrate_kbps": bitrate_kbps, | |
| "mime": mime, | |
| "log": log_data | |
| }))).into_response() | |
| } | |
| /// /send_audio — HuggingFace скачивает трек и сам загружает его в Telegram через multipart. | |
| /// Render к аудио не прикасается, только получает обратно file_id для кэша. | |
| /// Требует: BOT_TOKEN env var на HuggingFace Space. | |
| struct SendAudioParams { | |
| id: u64, | |
| format: Option<String>, | |
| arl: Option<String>, | |
| /// Telegram chat_id куда отправить аудио | |
| chat_id: String, | |
| /// Опциональные метаданные | |
| title: Option<String>, | |
| performer: Option<String>, | |
| duration: Option<u64>, | |
| album: Option<String>, | |
| cover_url: Option<String>, | |
| } | |
| impl From<&SendAudioParams> for AudioTagMeta { | |
| fn from(params: &SendAudioParams) -> Self { | |
| Self { | |
| title: normalized_opt(¶ms.title), | |
| performer: normalized_opt(¶ms.performer), | |
| album: normalized_opt(¶ms.album), | |
| cover_url: normalized_opt(¶ms.cover_url), | |
| } | |
| } | |
| } | |
| async fn fetch_cover_picture(cover_url: &str) -> Option<Picture> { | |
| let http = reqwest::Client::builder() | |
| .timeout(Duration::from_secs(20)) | |
| .build() | |
| .ok()?; | |
| let resp = http.get(cover_url).send().await.ok()?; | |
| if !resp.status().is_success() { | |
| return None; | |
| } | |
| let bytes = resp.bytes().await.ok()?; | |
| let mut picture = Picture::from_reader(&mut Cursor::new(bytes.to_vec())).ok()?; | |
| picture.set_pic_type(PictureType::CoverFront); | |
| Some(picture) | |
| } | |
| /// Строит имя файла вида "Исполнитель - Название.ext" из метаданных трека, | |
| /// с фоллбэком на "track_{id}.{ext}", если метаданных нет. Общая логика для | |
| /// /download и /send_audio — раньше send_audio использовал только id, из-за | |
| /// чего в Telegram трек отображался как "track_123456.flac" вместо названия. | |
| fn build_track_filename(id: u64, ext: &str, meta: &AudioTagMeta) -> String { | |
| let safe = |s: &str, limit: usize| { | |
| s.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_") | |
| .chars() | |
| .take(limit) | |
| .collect::<String>() | |
| }; | |
| match (meta.title.as_deref(), meta.performer.as_deref()) { | |
| (Some(title), Some(performer)) if !title.is_empty() && !performer.is_empty() => { | |
| format!("{} - {}.{}", safe(performer, 50), safe(title, 100), ext) | |
| } | |
| (Some(title), _) if !title.is_empty() => { | |
| format!("{}.{}", safe(title, 100), ext) | |
| } | |
| _ => format!("track_{}.{}", id, ext), | |
| } | |
| } | |
| /// Синхронная часть тегирования (временный файл + lofty save) — уносится в | |
| /// spawn_blocking отдельно от inject_audio_metadata, чтобы не блокировать | |
| /// раннер (см. decrypt_track_sync выше — та же причина). | |
| fn tag_bytes_sync( | |
| bytes: Vec<u8>, | |
| ext: &str, | |
| title: Option<String>, | |
| performer: Option<String>, | |
| album: Option<String>, | |
| cover: Option<Picture>, | |
| ) -> Vec<u8> { | |
| let tag_type = if ext.eq_ignore_ascii_case("flac") { | |
| TagType::VorbisComments | |
| } else { | |
| TagType::Id3v2 | |
| }; | |
| let mut tag = Tag::new(tag_type); | |
| if let Some(title) = title { | |
| tag.set_title(title); | |
| } | |
| if let Some(performer) = performer { | |
| tag.set_artist(performer); | |
| } | |
| if let Some(album) = album { | |
| tag.set_album(album); | |
| } | |
| if let Some(picture) = cover { | |
| tag.push_picture(picture); | |
| } | |
| let unique = SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|d| d.as_nanos()) | |
| .unwrap_or(0); | |
| let temp_path = std::env::temp_dir().join(format!("dzmedia_tagged_{}.{}", unique, ext)); | |
| if let Err(e) = std::fs::write(&temp_path, &bytes) { | |
| tracing::warn!("metadata temp write failed: {}", e); | |
| return bytes; | |
| } | |
| let write_options = if ext.eq_ignore_ascii_case("mp3") { | |
| WriteOptions::new().use_id3v23(true) | |
| } else { | |
| WriteOptions::new() | |
| }; | |
| let write_result = tag.save_to_path(&temp_path, write_options); | |
| if let Err(e) = write_result { | |
| let _ = std::fs::remove_file(&temp_path); | |
| tracing::warn!("metadata tagging failed: {}", e); | |
| return bytes; | |
| } | |
| let tagged = match std::fs::read(&temp_path) { | |
| Ok(tagged) => tagged, | |
| Err(e) => { | |
| tracing::warn!("metadata temp read failed: {}", e); | |
| bytes | |
| } | |
| }; | |
| let _ = std::fs::remove_file(&temp_path); | |
| tagged | |
| } | |
| async fn inject_audio_metadata(bytes: Vec<u8>, ext: &str, meta: &AudioTagMeta) -> Vec<u8> { | |
| let has_text = meta.title.is_some() || meta.performer.is_some() || meta.album.is_some(); | |
| let cover = match meta.cover_url.as_deref() { | |
| Some(url) => fetch_cover_picture(url).await, | |
| None => None, | |
| }; | |
| if !has_text && cover.is_none() { | |
| return bytes; | |
| } | |
| let ext_owned = ext.to_string(); | |
| let title = meta.title.clone(); | |
| let performer = meta.performer.clone(); | |
| let album = meta.album.clone(); | |
| // Клонируем как страховку: если поток запаникует, JoinError не отдаёт | |
| // обратно moved bytes — а мы обязаны вернуть хоть что-то воспроизводимое. | |
| let fallback = bytes.clone(); | |
| match tokio::task::spawn_blocking(move || { | |
| tag_bytes_sync(bytes, &ext_owned, title, performer, album, cover) | |
| }) | |
| .await | |
| { | |
| Ok(tagged) => tagged, | |
| Err(e) => { | |
| tracing::warn!("metadata tagging task panicked: {}", e); | |
| fallback | |
| } | |
| } | |
| } | |
| async fn send_audio( | |
| State(state): State<AppState>, | |
| Query(q): Query<SendAudioParams>, | |
| ) -> impl IntoResponse { | |
| let bot_token = match std::env::var("BOT_TOKEN").ok().filter(|s| !s.is_empty()) { | |
| Some(t) => t, | |
| None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "BOT_TOKEN not configured"), | |
| }; | |
| let fmt_str = q.format.as_deref().unwrap_or("MP3_320"); | |
| let formats = match fmt_str { | |
| "FLAC" => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| "MP3_320" => vec![Format::MP3_320, Format::MP3_128], | |
| "MP3_128" => vec![Format::MP3_128], | |
| _ => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| }; | |
| let arl_key = q.arl.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) | |
| .or_else(|| std::env::var("DEEZER_ARL").ok().filter(|s| !s.is_empty())); | |
| if arl_key.is_none() { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "arl required"); | |
| } | |
| // Resolve FALLBACK_ID | |
| let client = api_client_for_arl(&state, arl_key.clone()).await; | |
| let target_id = { | |
| let mut c = client.lock().await; | |
| resolve_fallback_id(&mut c, q.id).await | |
| }; | |
| let mut decrypt_id = target_id; | |
| let mut cdn_url_opt: Option<String> = None; | |
| let mut used_fmt = String::new(); | |
| let mut last_err: Option<String> = None; | |
| // Try upstream first | |
| if let Ok(text) = upstream_get_url_text(&formats, &vec![target_id]).await { | |
| if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) { | |
| let fstrs: Vec<String> = formats.iter().map(|f| format!("{:?}", f)).collect(); | |
| let fstrs_ref: Vec<&str> = fstrs.iter().map(|s| s.as_str()).collect(); | |
| let (u, f, _) = extract_media_url_and_format(&v, &fstrs_ref); | |
| if !u.is_empty() { cdn_url_opt = Some(u); used_fmt = f; } | |
| } | |
| } | |
| // Fallback: ARL | |
| for attempt in 0..2u8 { | |
| if cdn_url_opt.is_some() { break; } | |
| if attempt >= 1 { | |
| if let Some(ref arl) = arl_key { state.api_by_arl.write().await.remove(arl); } | |
| } | |
| 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(15), media_url_for_track(&mut c, target_id, &formats)).await { | |
| Ok(v) => v, | |
| Err(_) => Err("timeout".to_string()), | |
| } | |
| }; | |
| match res { | |
| Ok((u, f, id_used)) => { cdn_url_opt = Some(u); used_fmt = f; decrypt_id = id_used; break; } | |
| Err(e) => { last_err = Some(e); } | |
| } | |
| } | |
| let cdn_url = match cdn_url_opt { | |
| Some(u) => u, | |
| None => { | |
| let err = last_err.unwrap_or_else(|| "send_audio:no_url".to_string()); | |
| tracing::error!("send_audio {} failed: {}", q.id, err); | |
| return json_error(StatusCode::NOT_FOUND, "track not found"); | |
| } | |
| }; | |
| // Скачиваем зашифрованный файл с CDN | |
| let http = reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(120)).build().unwrap(); | |
| let resp = match http.get(&cdn_url).send().await { | |
| Ok(r) if r.status().is_success() => r, | |
| Ok(r) => return json_error(StatusCode::BAD_GATEWAY, format!("CDN {}", r.status())), | |
| Err(e) => return json_error(StatusCode::BAD_GATEWAY, format!("CDN fetch: {}", e)), | |
| }; | |
| let content_type = resp.headers() | |
| .get(reqwest::header::CONTENT_TYPE) | |
| .and_then(|v| v.to_str().ok()) | |
| .unwrap_or("audio/mpeg") | |
| .to_string(); | |
| let enc = match resp.bytes().await { | |
| Ok(b) => b, | |
| Err(e) => return json_error(StatusCode::BAD_GATEWAY, format!("CDN read: {}", e)), | |
| }; | |
| // Расшифровываем в отдельном блокирующем потоке — decrypt_track_sync это | |
| // чистый CPU-bound цикл, на нём нельзя держать async-воркер. | |
| let mut dec = match tokio::task::spawn_blocking(move || decrypt_track_sync(&enc, decrypt_id)).await { | |
| Ok(d) => d, | |
| Err(e) => { | |
| tracing::error!("send_audio {} decrypt task panicked: {}", q.id, e); | |
| return json_error(StatusCode::INTERNAL_SERVER_ERROR, "decrypt failed"); | |
| } | |
| }; | |
| let ext = if used_fmt.contains("FLAC") || content_type.contains("flac") { "flac" } else { "mp3" }; | |
| let meta = AudioTagMeta::from(&q); | |
| dec = inject_audio_metadata(dec, ext, &meta).await; | |
| let mime = if ext == "flac" { "audio/flac" } else { "audio/mpeg" }; | |
| let filename = build_track_filename(q.id, ext, &meta); | |
| // Загружаем в Telegram через отдельный клиент. | |
| let tg_url = format!("https://api.telegram.org/bot{}/sendAudio", bot_token); | |
| let tg_http = reqwest::Client::builder() | |
| .https_only(true) | |
| .connect_timeout(Duration::from_secs(10)) | |
| .timeout(Duration::from_secs(30)) | |
| .tcp_keepalive(Duration::from_secs(20)) | |
| .build() | |
| .unwrap(); | |
| let mut tg_json: Option<serde_json::Value> = None; | |
| let mut last_tg_err: Option<String> = None; | |
| for attempt in 1..=3u8 { | |
| tracing::info!("Telegram sendAudio attempt {} for track {} ({} bytes)", attempt, q.id, dec.len()); | |
| let part = reqwest::multipart::Part::bytes(dec.clone()) | |
| .file_name(filename.clone()) | |
| .mime_str(mime) | |
| .unwrap(); | |
| let mut form = reqwest::multipart::Form::new() | |
| .text("chat_id", q.chat_id.clone()) | |
| .part("audio", part); | |
| if let Some(ref t) = q.title { form = form.text("title", t.clone()); } | |
| if let Some(ref p) = q.performer { form = form.text("performer", p.clone()); } | |
| if let Some(d) = q.duration { form = form.text("duration", d.to_string()); } | |
| match tg_http.post(&tg_url).multipart(form).send().await { | |
| Ok(resp) => { | |
| let status = resp.status(); | |
| match resp.json::<serde_json::Value>().await { | |
| Ok(j) => { | |
| if status.is_success() && j.get("ok").and_then(|v| v.as_bool()) == Some(true) { | |
| tg_json = Some(j); | |
| tracing::info!("Telegram sendAudio success on attempt {}", attempt); | |
| break; | |
| } | |
| let desc = j | |
| .get("description") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or("unknown") | |
| .to_string(); | |
| tracing::warn!("Telegram sendAudio attempt {} failed: status={} desc={}", attempt, status, desc); | |
| last_tg_err = Some(format!("Telegram {}: {}", status, desc)); | |
| } | |
| Err(e) => { | |
| tracing::warn!("Telegram sendAudio attempt {} parse failed: {}", attempt, e); | |
| last_tg_err = Some(format!("Telegram parse: {}", e)); | |
| } | |
| } | |
| } | |
| Err(e) => { | |
| tracing::warn!("Telegram sendAudio attempt {} upload failed: {}", attempt, e); | |
| last_tg_err = Some(format!("Telegram upload: {}", e)); | |
| } | |
| } | |
| if attempt < 3 { | |
| tokio::time::sleep(Duration::from_secs(3)).await; | |
| } | |
| } | |
| let tg_json = match tg_json { | |
| Some(j) => j, | |
| None => { | |
| let err = last_tg_err.unwrap_or_else(|| "Telegram upload failed".to_string()); | |
| tracing::error!("Telegram sendAudio final failure: {}", err); | |
| return json_error(StatusCode::BAD_GATEWAY, err); | |
| } | |
| }; | |
| let file_id = tg_json.pointer("/result/audio/file_id") | |
| .and_then(|v| v.as_str()) | |
| .unwrap_or("") | |
| .to_string(); | |
| let message_id = tg_json.pointer("/result/message_id") | |
| .and_then(|v| v.as_i64()) | |
| .unwrap_or(0); | |
| (StatusCode::OK, Json(json!({ | |
| "ok": true, | |
| "file_id": file_id, | |
| "message_id": message_id, | |
| "format": ext, | |
| }))).into_response() | |
| } | |
| /// /download — like /stream but downloads full track into memory and returns with Content-Length. | |
| /// Telegram requires Content-Length for sendAudio by URL; this endpoint ensures it. | |
| async fn download( | |
| State(state): State<AppState>, | |
| Query(q): Query<StreamParams>, | |
| ) -> impl IntoResponse { | |
| let formats = match q.format.as_deref().unwrap_or("AUTO") { | |
| "FLAC" => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| "MP3_320" => vec![Format::MP3_320, Format::MP3_128], | |
| "MP3_MISC" => vec![Format::MP3_MISC, Format::MP3_128], | |
| "MP3_128" => vec![Format::MP3_128], | |
| _ => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| }; | |
| let arl_key = q.arl.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); | |
| if arl_key.is_none() { | |
| return (StatusCode::SERVICE_UNAVAILABLE, "arl required").into_response(); | |
| } | |
| // Resolve correct FALLBACK_ID for decryption | |
| let client = api_client_for_arl(&state, arl_key.clone()).await; | |
| let target_id = { | |
| let mut c = client.lock().await; | |
| resolve_fallback_id(&mut c, q.id).await | |
| }; | |
| let mut decrypt_id = target_id; | |
| let mut url: Option<String> = None; | |
| let mut used_fmt = String::new(); | |
| let mut last_err: Option<String> = None; | |
| // Try DZMEDIA_UPSTREAM first for highest quality | |
| if url.is_none() { | |
| if let Ok(text) = upstream_get_url_text(&formats, &vec![target_id]).await { | |
| if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) { | |
| let format_strings: Vec<String> = formats.iter().map(|f| format!("{:?}", f)).collect(); | |
| let format_strs: Vec<&str> = format_strings.iter().map(|s| s.as_str()).collect(); | |
| let (u, f, _) = extract_media_url_and_format(&v, &format_strs); | |
| if !u.is_empty() { | |
| url = Some(u); | |
| used_fmt = f; | |
| } | |
| } | |
| } | |
| } | |
| // Fallback: ARL path | |
| 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); | |
| } | |
| } | |
| 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(15), media_url_for_track(&mut c, target_id, &formats)).await { | |
| Ok(v) => v, | |
| Err(_) => Err("timeout".to_string()), | |
| } | |
| }; | |
| match res { | |
| Ok((u, f, id_used)) => { url = Some(u); used_fmt = f; decrypt_id = id_used; break; } | |
| Err(e) => { last_err = Some(e); } | |
| } | |
| } | |
| let cdn_url = match url { | |
| Some(u) => u, | |
| None => { | |
| let err = last_err.unwrap_or_else(|| "download:no_url".to_string()); | |
| tracing::error!("download {} failed: {}", q.id, err); | |
| return (StatusCode::NOT_FOUND, "Not Found").into_response(); | |
| } | |
| }; | |
| // Download the full encrypted file | |
| let http = reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(120)).build().unwrap(); | |
| let resp = match http.get(&cdn_url).send().await { | |
| Ok(r) => r, | |
| Err(e) => { | |
| tracing::error!("download {} cdn fetch failed: {}", q.id, e); | |
| return (StatusCode::BAD_GATEWAY, "CDN fetch failed").into_response(); | |
| } | |
| }; | |
| if !resp.status().is_success() { | |
| return (StatusCode::BAD_GATEWAY, "CDN error").into_response(); | |
| } | |
| let _content_type = resp.headers() | |
| .get(reqwest::header::CONTENT_TYPE) | |
| .and_then(|v| v.to_str().ok()) | |
| .unwrap_or("audio/mpeg") | |
| .to_string(); | |
| let enc_bytes = match resp.bytes().await { | |
| Ok(b) => b, | |
| Err(e) => { | |
| tracing::error!("download {} cdn read failed: {}", q.id, e); | |
| return (StatusCode::BAD_GATEWAY, "CDN read failed").into_response(); | |
| } | |
| }; | |
| // Decrypt in a blocking thread — decrypt_track_sync is a pure CPU-bound loop, | |
| // must not run inline on the async worker. | |
| let mut dec = match tokio::task::spawn_blocking(move || decrypt_track_sync(&enc_bytes, decrypt_id)).await { | |
| Ok(d) => d, | |
| Err(e) => { | |
| tracing::error!("download {} decrypt task panicked: {}", q.id, e); | |
| return (StatusCode::INTERNAL_SERVER_ERROR, "decrypt failed").into_response(); | |
| } | |
| }; | |
| // Determine file extension | |
| // Проверяем магические байты для определения РЕАЛЬНОГО формата файла | |
| let is_flac = dec.len() > 4 && &dec[0..4] == b"fLaC"; | |
| let is_mp3 = dec.len() > 3 && ( | |
| (&dec[0..3] == b"ID3") || // ID3 tag | |
| (dec[0] == 0xFF && (dec[1] & 0xE0) == 0xE0) // MP3 sync word | |
| ); | |
| let ext = if is_flac { | |
| "flac" | |
| } else if is_mp3 || used_fmt.contains("MP3") { | |
| "mp3" | |
| } else if used_fmt.contains("FLAC") { | |
| // Заявлен FLAC, но магических байтов нет - возможно битый файл | |
| tracing::warn!("Track {} claimed as FLAC but magic bytes not found, treating as mp3", q.id); | |
| "mp3" | |
| } else { | |
| "mp3" | |
| }; | |
| tracing::info!("Download track {}: claimed_format={}, detected_ext={}, size={} bytes", | |
| q.id, used_fmt, ext, dec.len()); | |
| let meta = AudioTagMeta::from(&q); | |
| dec = inject_audio_metadata(dec, ext, &meta).await; | |
| let filename = build_track_filename(q.id, ext, &meta); | |
| let ct = if ext == "flac" { "audio/flac" } else { "audio/mpeg" }; | |
| tracing::info!("Sending file: {} ({})", filename, ct); | |
| axum::response::Response::builder() | |
| .status(200) | |
| .header("Content-Type", ct) | |
| .header("Content-Length", dec.len().to_string()) | |
| .header("Content-Disposition", format!("inline; filename=\"{}\"", filename)) | |
| .header("Accept-Ranges", "bytes") | |
| .header("Access-Control-Allow-Origin", "*") | |
| .header("Cache-Control", "public, max-age=31536000") | |
| .body(axum::body::Body::from(dec)) | |
| .unwrap() | |
| .into_response() | |
| } | |
| /// /warm — прогревает CDN URL кэш для трека. | |
| /// Render вызывает этот эндпоинт ПЕРЕД отправкой sendAudio, | |
| /// чтобы к моменту Telegram-запроса кэш уже был горячим. | |
| async fn warm( | |
| State(state): State<AppState>, | |
| Query(q): Query<StreamParams>, | |
| ) -> impl IntoResponse { | |
| let fmt_str = q.format.as_deref().unwrap_or("AUTO"); | |
| let formats = match fmt_str { | |
| "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()) | |
| .or_else(|| std::env::var("DEEZER_ARL").ok().filter(|s| !s.is_empty())); | |
| if arl_key.is_none() { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "arl required"); | |
| } | |
| // Проверяем — может уже в кэше есть свежая запись | |
| { | |
| let cache = state.cdn_cache.read().await; | |
| if let Some(e) = cache.get(&q.id) { | |
| if e.expires_at > std::time::Instant::now() { | |
| tracing::debug!("warm {}: already cached", q.id); | |
| return (StatusCode::OK, Json(json!({ "ok": true, "cached": true }))).into_response(); | |
| } | |
| } | |
| } | |
| // Resolve fallback_id | |
| let client = api_client_for_arl(&state, arl_key.clone()).await; | |
| let target_id = { | |
| let mut c = client.lock().await; | |
| resolve_fallback_id(&mut c, q.id).await | |
| }; | |
| let mut decrypt_id = target_id; | |
| let mut url: Option<String> = None; | |
| // Try upstream first | |
| if let Ok(text) = upstream_get_url_text(&formats, &vec![target_id]).await { | |
| if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) { | |
| let fstrs: Vec<String> = formats.iter().map(|f| format!("{:?}", f)).collect(); | |
| let fstrs_ref: Vec<&str> = fstrs.iter().map(|s| s.as_str()).collect(); | |
| let (u, _, _) = extract_media_url_and_format(&v, &fstrs_ref); | |
| if !u.is_empty() { url = Some(u); } | |
| } | |
| } | |
| // Fallback: ARL | |
| 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); } | |
| } | |
| 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(_) = c.force_renew().await { continue; } | |
| } | |
| match timeout(Duration::from_secs(15), media_url_for_track(&mut c, target_id, &formats)).await { | |
| Ok(v) => v, | |
| Err(_) => Err("timeout".to_string()), | |
| } | |
| }; | |
| if let Ok((u, _, id_used)) = res { url = Some(u); decrypt_id = id_used; break; } | |
| } | |
| match url { | |
| Some(u) => { | |
| let mut cache = state.cdn_cache.write().await; | |
| if cache.len() > 200 { cache.retain(|_, e| e.expires_at > std::time::Instant::now()); } | |
| cache.insert(q.id, CdnCacheEntry { | |
| cdn_url: u, | |
| decrypt_id, | |
| format: fmt_str.to_string(), | |
| expires_at: std::time::Instant::now() + Duration::from_secs(20 * 60), | |
| }); | |
| tracing::info!("warm {}: cached OK", q.id); | |
| (StatusCode::OK, Json(json!({ "ok": true, "cached": false }))).into_response() | |
| } | |
| None => { | |
| tracing::error!("warm {}: failed to get CDN URL", q.id); | |
| json_error(StatusCode::NOT_FOUND, "track not found") | |
| } | |
| } | |
| } | |
| async fn stream( | |
| State(state): State<AppState>, | |
| Query(q): Query<StreamParams>, | |
| headers: HeaderMap, | |
| ) -> impl IntoResponse { | |
| let formats = match q.format.as_deref().unwrap_or("AUTO") { | |
| "FLAC" => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| "MP3_320" => vec![Format::MP3_320, Format::MP3_128], | |
| "MP3_MISC" => vec![Format::MP3_MISC, Format::MP3_128], | |
| "MP3_128" => vec![Format::MP3_128], | |
| _ => vec![Format::FLAC, Format::MP3_320, Format::MP3_128], | |
| }; | |
| let arl_key = q.arl.clone() | |
| .map(|s| s.trim().to_string()) | |
| .filter(|s| !s.is_empty()); | |
| let mut url: Option<String> = None; | |
| let mut decrypt_id: u64 = q.id; // overwritten after resolve_fallback_id | |
| let mut last_err: Option<String> = None; | |
| if arl_key.is_none() { | |
| tracing::error!("stream {}: arl missing", q.id); | |
| return (StatusCode::SERVICE_UNAVAILABLE, "arl required").into_response(); | |
| } | |
| // ── CDN кэш: если есть свежая запись — пропускаем все Deezer API вызовы ── | |
| { | |
| let cache = state.cdn_cache.read().await; | |
| if let Some(e) = cache.get(&q.id) { | |
| if e.expires_at > std::time::Instant::now() { | |
| tracing::debug!("stream {}: CDN cache hit ({})", q.id, e.format); | |
| url = Some(e.cdn_url.clone()); | |
| decrypt_id = e.decrypt_id; | |
| } | |
| } | |
| } | |
| if url.is_none() { | |
| // Resolve fallback_id for proper decryption key | |
| let client = api_client_for_arl(&state, arl_key.clone()).await; | |
| let target_id = { | |
| let mut c = client.lock().await; | |
| resolve_fallback_id(&mut c, q.id).await | |
| }; | |
| decrypt_id = target_id; | |
| // Try DZMEDIA_UPSTREAM first — gives highest available quality (FLAC/320) | |
| if url.is_none() { | |
| if let Ok(text) = upstream_get_url_text(&formats, &vec![target_id]).await { | |
| if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) { | |
| let format_strings: Vec<String> = formats.iter().map(|f| format!("{:?}", f)).collect(); | |
| let format_strs: Vec<&str> = format_strings.iter().map(|s| s.as_str()).collect(); | |
| let (u, _, _) = extract_media_url_and_format(&v, &format_strs); | |
| if !u.is_empty() { | |
| url = Some(u); | |
| } | |
| } | |
| } | |
| } | |
| // Fallback: use ARL to get CDN URL via Deezer API | |
| 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, target_id, &formats)).await { | |
| Ok(v) => v, | |
| Err(_) => Err("timeout".to_string()), | |
| } | |
| }; | |
| match res { | |
| Ok((u, fmt, id_used)) => { url = Some(u); decrypt_id = id_used; let _ = fmt; break; } | |
| Err(e) => { last_err = Some(e); } | |
| } | |
| } | |
| // ── Пишем в кэш если удалось получить URL ── | |
| if let Some(ref u) = url { | |
| let mut cache = state.cdn_cache.write().await; | |
| if cache.len() > 200 { | |
| cache.retain(|_, e| e.expires_at > std::time::Instant::now()); | |
| } | |
| cache.insert(q.id, CdnCacheEntry { | |
| cdn_url: u.clone(), | |
| decrypt_id, | |
| format: q.format.clone().unwrap_or_else(|| "AUTO".to_string()), | |
| expires_at: std::time::Instant::now() + Duration::from_secs(20 * 60), | |
| }); | |
| } | |
| } | |
| 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 (StatusCode::NOT_FOUND, "Not Found").into_response(); | |
| } | |
| }; | |
| fn parse_range_header(v: &str) -> Option<(u64, Option<u64>)> { | |
| let v = v.trim(); | |
| let v = v.strip_prefix("bytes=")?; | |
| let mut it = v.splitn(2, '-'); | |
| let a = it.next()?.trim(); | |
| let b = it.next().unwrap_or("").trim(); | |
| if a.is_empty() { | |
| return None; | |
| } | |
| let start = a.parse::<u64>().ok()?; | |
| let end = if b.is_empty() { | |
| None | |
| } else { | |
| let e = b.parse::<u64>().ok()?; | |
| Some(e) | |
| }; | |
| Some((start, end)) | |
| } | |
| fn parse_total_from_content_range(v: &str) -> Option<u64> { | |
| let v = v.trim(); | |
| let (_, rest) = v.split_once('/')?; | |
| if rest.trim() == "*" { | |
| return None; | |
| } | |
| rest.trim().parse::<u64>().ok() | |
| } | |
| let req_range = headers | |
| .get(RANGE) | |
| .and_then(|v| v.to_str().ok()) | |
| .and_then(parse_range_header); | |
| let http = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(60)) | |
| .build() | |
| .unwrap(); | |
| let block_size: u64 = 2048; | |
| let mut block_index: u64 = 0; | |
| let mut drop_bytes: usize = 0; | |
| let mut remaining: Option<u64> = None; | |
| let mut req = http.get(&url); | |
| if let Some((start, end)) = req_range { | |
| let aligned_start = start - (start % block_size); | |
| drop_bytes = (start - aligned_start) as usize; | |
| block_index = aligned_start / block_size; | |
| if let Some(e) = end { | |
| if e >= start { | |
| remaining = Some(e - start + 1); | |
| } | |
| req = req.header(reqwest::header::RANGE, format!("bytes={}-{}", aligned_start, e)); | |
| } else { | |
| req = req.header(reqwest::header::RANGE, format!("bytes={}-", aligned_start)); | |
| } | |
| } | |
| let upstream = match req.send().await { | |
| Ok(r) => r, | |
| Err(_) => { | |
| return (StatusCode::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<u8> = Vec::with_capacity(4096); | |
| let upstream_headers = upstream.headers().clone(); | |
| let upstream_total = upstream_headers | |
| .get(reqwest::header::CONTENT_RANGE) | |
| .and_then(|v| v.to_str().ok()) | |
| .and_then(parse_total_from_content_range) | |
| // For full (non-range) requests, CDN returns Content-Length directly | |
| .or_else(|| { | |
| upstream_headers | |
| .get(reqwest::header::CONTENT_LENGTH) | |
| .and_then(|v| v.to_str().ok()) | |
| .and_then(|v| v.parse::<u64>().ok()) | |
| }); | |
| let mut upstream = upstream.bytes_stream(); | |
| let out = async_stream::stream! { | |
| let mut drop_bytes = drop_bytes; | |
| let mut remaining = remaining; | |
| let mut done = false; | |
| while let Some(chunk_res) = upstream.next().await { | |
| let chunk = match chunk_res { | |
| Ok(c) => c, | |
| Err(e) => { | |
| yield Err::<Bytes, BoxErr>(Box::new(e)); | |
| break; | |
| } | |
| }; | |
| carry.extend_from_slice(&chunk); | |
| while carry.len() >= 2048 { | |
| let mut block = carry.drain(0..2048).collect::<Vec<u8>>(); | |
| if !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, BoxErr>(Bytes::from(block)); | |
| done = true; | |
| break; | |
| } else { | |
| remaining = Some(rem - block.len() as u64); | |
| } | |
| } | |
| yield Ok::<Bytes, BoxErr>(Bytes::from(block)); | |
| } | |
| if done { break; } | |
| } | |
| if !done && !carry.is_empty() { | |
| let mut tail = std::mem::take(&mut carry); | |
| if drop_bytes > 0 { | |
| if drop_bytes < tail.len() { | |
| tail = tail.split_off(drop_bytes); | |
| } else { | |
| tail.clear(); | |
| } | |
| } | |
| if !tail.is_empty() { | |
| if let Some(rem) = remaining { | |
| if rem > 0 { | |
| if (tail.len() as u64) > rem { | |
| tail.truncate(rem as usize); | |
| } | |
| yield Ok::<Bytes, BoxErr>(Bytes::from(tail)); | |
| } | |
| } else { | |
| yield Ok::<Bytes, BoxErr>(Bytes::from(tail)); | |
| } | |
| } | |
| } | |
| }; | |
| let 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 | |
| } | |
| struct UserDataReq { | |
| arl: String, | |
| } | |
| async fn user_data(State(state): State<AppState>, Json(req): Json<UserDataReq>) -> impl IntoResponse { | |
| let arl = req.arl.trim().to_string(); | |
| if arl.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "ARL required"); | |
| } | |
| let client = api_client_for_arl(&state, Some(arl)).await; | |
| let data = { | |
| let mut client = client.lock().await; | |
| match client.user_data().await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), | |
| } | |
| }; | |
| let token = data["checkForm"].as_str().unwrap_or("").to_string(); | |
| let uid = data["USER"]["USER_ID"].as_i64().map(|n| n.to_string()).unwrap_or_default(); | |
| (StatusCode::OK, Json(json!({ "token": token, "uid": uid }))).into_response() | |
| } | |
| struct PlaylistsReq { | |
| arl: String, | |
| start: Option<u32>, | |
| nb: Option<u32>, | |
| } | |
| async fn playlists(State(state): State<AppState>, Json(req): Json<PlaylistsReq>) -> impl IntoResponse { | |
| let arl = req.arl.trim().to_string(); | |
| if arl.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "ARL required"); | |
| } | |
| let client = api_client_for_arl(&state, Some(arl)).await; | |
| let res = { | |
| let mut client = client.lock().await; | |
| let ud = match client.user_data().await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), | |
| }; | |
| let uid = ud["USER"]["USER_ID"].as_i64().unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "No USER_ID"); | |
| } | |
| let start = req.start.unwrap_or(0); | |
| let nb = req.nb.unwrap_or(50); | |
| let res: Result<serde_json::Value, APIError> = client | |
| .api_call("playlist.getList", &json!({"user_id":uid,"nb":nb,"start":start})) | |
| .await; | |
| res | |
| }; | |
| match res { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), | |
| } | |
| } | |
| struct PlaylistTracksReq { | |
| arl: String, | |
| playlist_id: u64, | |
| start: Option<u32>, | |
| nb: Option<u32>, | |
| } | |
| async fn playlist_tracks( | |
| State(state): State<AppState>, | |
| Json(req): Json<PlaylistTracksReq>, | |
| ) -> impl IntoResponse { | |
| let arl = req.arl.trim().to_string(); | |
| if arl.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "ARL required"); | |
| } | |
| let client = api_client_for_arl(&state, Some(arl)).await; | |
| let start = req.start.unwrap_or(0); | |
| let nb = req.nb.unwrap_or(100); | |
| let res: Result<serde_json::Value, APIError> = { | |
| let mut client = client.lock().await; | |
| let _ = client.user_data().await; | |
| client | |
| .api_call( | |
| "playlist.getSongs", | |
| &json!({ | |
| "playlist_id": req.playlist_id, | |
| "start": start, | |
| "nb": nb | |
| }), | |
| ) | |
| .await | |
| }; | |
| match res { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e.to_string()), | |
| } | |
| } | |
| struct LoginReq { | |
| email: String, | |
| password_md5: String, | |
| } | |
| struct GwResp { | |
| error: serde_json::Value, | |
| results: serde_json::Value, | |
| } | |
| // ── Мобильный API Deezer ────────────────────────────────────────────────── | |
| // Использует api.deezer.com/1.0/gateway.php с мобильным UA — не блокируется CF | |
| const MOBILE_UA: &str = "Deezer/8.32.0.2 (iOS; 14.4; Mobile; en; iPhone10_5)"; | |
| const MOBILE_API_KEY: &str = "ZAIVAHCEISOHWAICUQUEXAEPICENGUAFAEZAIPHAELEEVAHPHUCUFONGUAPASUAY"; | |
| const MOBILE_GW: &str = "https://api.deezer.com/1.0/gateway.php"; | |
| /// Построить reqwest::Client с мобильным UA и нужными cookie | |
| fn mobile_client_with_arl(arl: &str) -> reqwest::Client { | |
| let jar = Arc::new(Jar::default()); | |
| let url = "https://api.deezer.com".parse::<Url>().unwrap(); | |
| if !arl.is_empty() { | |
| jar.add_cookie_str(&format!("arl={}; Domain=.deezer.com", arl), &url); | |
| jar.add_cookie_str(&format!("arl={}; Domain=.api.deezer.com", arl), &url); | |
| } | |
| reqwest::Client::builder() | |
| .no_proxy() | |
| .cookie_provider(jar) | |
| .timeout(std::time::Duration::from_secs(20)) | |
| .build() | |
| .unwrap() | |
| } | |
| /// Вызов мобильного gateway.php | |
| async fn mobile_gw( | |
| client: &reqwest::Client, | |
| method: &str, | |
| api_token: &str, | |
| body: serde_json::Value, | |
| ) -> Result<serde_json::Value, String> { | |
| let cid = SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|d| d.as_millis().to_string()) | |
| .unwrap_or_else(|_| "0".to_string()); | |
| let r = client | |
| .post(MOBILE_GW) | |
| .query(&[ | |
| ("method", method), | |
| ("api_version", "1.0"), | |
| ("api_token", api_token), | |
| ("input", "3"), | |
| ("output", "3"), | |
| ("cid", cid.as_str()), | |
| ("api_key", MOBILE_API_KEY), | |
| ]) | |
| .header("User-Agent", MOBILE_UA) | |
| .header("Content-Type", "application/json; charset=UTF-8") | |
| .header("Accept", "*/*") | |
| .header("Accept-Language", "en-US") | |
| .json(&body) | |
| .send() | |
| .await | |
| .map_err(|e| format!("mobile_gw:network:{e}"))?; | |
| let status = r.status(); | |
| let text = r.text().await.map_err(|_| "mobile_gw:read".to_string())?; | |
| if !status.is_success() { | |
| return Err(format!("mobile_gw:http:{}:{}", status.as_u16(), &text[..text.len().min(120)])); | |
| } | |
| let v: serde_json::Value = serde_json::from_str(&text) | |
| .map_err(|_| format!("mobile_gw:parse:{}", &text[..text.len().min(120)]))?; | |
| // Проверяем поле error | |
| if let Some(err) = v.get("error") { | |
| if let Some(obj) = err.as_object() { | |
| if !obj.is_empty() { | |
| let msg = obj.iter() | |
| .map(|(k, v)| format!("{k}:{}", v.as_str().unwrap_or("?"))) | |
| .collect::<Vec<_>>() | |
| .join(";"); | |
| return Err(format!("mobile_gw:deezer:{msg}")); | |
| } | |
| } | |
| } | |
| v.get("results") | |
| .cloned() | |
| .ok_or_else(|| format!("mobile_gw:no_results:{}", &text[..text.len().min(120)])) | |
| } | |
| /// Получить checkForm и SESSION через мобильный API (без ARL — анонимная сессия) | |
| async fn mobile_get_user_data(client: &reqwest::Client) -> Result<serde_json::Value, String> { | |
| mobile_gw(client, "deezer.getUserData", "null", json!({})).await | |
| } | |
| /// Логин по email+md5(password) через мобильный API — возвращает ARL | |
| async fn mobile_login(email: &str, password_md5: &str) -> Result<String, String> { | |
| let client = mobile_client_with_arl(""); | |
| // Шаг 1: получаем checkForm (анонимная сессия) | |
| let ud = mobile_get_user_data(&client).await | |
| .map_err(|e| format!("mobile:getUserData:{e}"))?; | |
| let check_form = ud["checkForm"].as_str().unwrap_or("").to_string(); | |
| if check_form.is_empty() { | |
| return Err("mobile:no_checkForm".to_string()); | |
| } | |
| // Шаг 2: checkCredentials | |
| let creds_result = mobile_gw(&client, "user.checkCredentials", &check_form, json!({ | |
| "login": email, | |
| "password": password_md5, | |
| "checkFormLogin": check_form | |
| })).await; | |
| // checkCredentials возвращает {} при успехе, ошибку при неверных данных | |
| if let Err(e) = &creds_result { | |
| // Неверный логин/пароль | |
| return Err(format!("mobile:checkCredentials:{e}")); | |
| } | |
| // Шаг 3: после checkCredentials сессия аутентифицирована — получаем свежий checkForm | |
| let ud2 = mobile_get_user_data(&client).await | |
| .map_err(|e| format!("mobile:getUserData2:{e}"))?; | |
| let check_form2 = ud2["checkForm"].as_str().unwrap_or("").to_string(); | |
| if check_form2.is_empty() { | |
| return Err("mobile:no_checkForm2".to_string()); | |
| } | |
| // Шаг 4: getArl | |
| let arl_result = mobile_gw(&client, "user.getArl", &check_form2, json!({})).await | |
| .map_err(|e| format!("mobile:getArl:{e}"))?; | |
| let arl = arl_result.as_str().unwrap_or("").to_string(); | |
| if arl.len() < 100 { | |
| return Err(format!("mobile:arl_too_short:{}", arl.len())); | |
| } | |
| Ok(arl) | |
| } | |
| const DEEZER_APP_SECRET: &str = "a83bf7f38ad2f137e444727cfc3775cf"; | |
| struct WebLoginResult { | |
| arl: Option<String>, | |
| token: String, | |
| uid: String, | |
| access_token: String, | |
| license_token: String, | |
| } | |
| fn gw_browser_ua() -> &'static str { | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" | |
| } | |
| fn gw_cid() -> String { | |
| SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|d| d.as_millis().to_string()) | |
| .unwrap_or_else(|_| "0".to_string()) | |
| } | |
| fn capture_sid_from_response(jar: &Jar, url: &Url, headers: &reqwest::header::HeaderMap) { | |
| for cookie_hdr in headers.get_all(reqwest::header::SET_COOKIE) { | |
| if let Ok(s) = cookie_hdr.to_str() { | |
| for part in s.split(',') { | |
| let part = part.trim(); | |
| let name_val = part.split(';').next().unwrap_or("").trim(); | |
| if name_val.starts_with("sid=") { | |
| jar.add_cookie_str( | |
| &format!("{name_val}; Domain=.deezer.com; Path=/"), | |
| url, | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /// Deezer выдаёт анонимный sid в Set-Cookie ответа на GET user.getArl — нужен до OAuth. | |
| async fn ensure_anonymous_sid(client: &reqwest::Client, jar: &Jar) -> Result<(), String> { | |
| let url_deezer = "https://www.deezer.com".parse::<Url>().map_err(|_| "sid:url".to_string())?; | |
| if jar_has_cookie(jar, &url_deezer, "sid=") { | |
| return Ok(()); | |
| } | |
| let cid = gw_cid(); | |
| let r = client | |
| .get("https://www.deezer.com/ajax/gw-light.php") | |
| .query(&[ | |
| ("method", "user.getArl"), | |
| ("input", "3"), | |
| ("output", "3"), | |
| ("api_version", "1.0"), | |
| ("api_token", "null"), | |
| ("cid", cid.as_str()), | |
| ]) | |
| .header(ACCEPT, "*/*") | |
| .header("Origin", "https://www.deezer.com") | |
| .header("Referer", "https://www.deezer.com/") | |
| .header("Sec-Fetch-Site", "same-origin") | |
| .header("Sec-Fetch-Mode", "cors") | |
| .header("Sec-Fetch-Dest", "empty") | |
| .header("User-Agent", gw_browser_ua()) | |
| .send() | |
| .await | |
| .map_err(|e| format!("sid:network:{e}"))?; | |
| capture_sid_from_response(jar, &url_deezer, r.headers()); | |
| if !jar_has_cookie(jar, &url_deezer, "sid=") { | |
| return Err("sid:not_set".to_string()); | |
| } | |
| Ok(()) | |
| } | |
| async fn gw_fetch_arl( | |
| client: &reqwest::Client, | |
| email: &str, | |
| password_md5: &str, | |
| user_id: Option<&str>, | |
| ) -> Result<(String, String, String, String), String> { | |
| let mut last_err = String::from("getArl:none"); | |
| for attempt in 0..3u8 { | |
| let ud = gw_light_call_custom( | |
| client, | |
| "deezer.getUserData", | |
| "null", | |
| None, | |
| &json!({}), | |
| None, | |
| attempt > 0, | |
| 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<String>, Option<String>, Option<String>) { | |
| let jar = Arc::new(Jar::default()); | |
| let url_deezer = match "https://www.deezer.com".parse::<Url>() { | |
| Ok(u) => u, | |
| Err(_) => return (None, None, None), | |
| }; | |
| jar.add_cookie_str("comeback=1; Domain=.deezer.com; Path=/", &url_deezer); | |
| let client = match reqwest::Client::builder() | |
| .no_proxy() | |
| .cookie_provider(jar.clone()) | |
| .timeout(std::time::Duration::from_secs(20)) | |
| .build() | |
| { | |
| Ok(c) => c, | |
| Err(_) => return (None, None, None), | |
| }; | |
| let _ = client | |
| .get("https://www.deezer.com/") | |
| .header(ACCEPT, "*/*") | |
| .header("User-Agent", gw_browser_ua()) | |
| .send() | |
| .await; | |
| if ensure_anonymous_sid(&client, &jar).await.is_err() { | |
| return (None, None, None); | |
| } | |
| establish_oauth_session(&client, &jar, access_token).await; | |
| let uid = if let Some(uid) = user_id.filter(|s| !s.trim().is_empty()) { | |
| Some(uid.to_string()) | |
| } else { | |
| api_deezer_get(&client, "/user/me", access_token, &[]) | |
| .await | |
| .ok() | |
| .and_then(|v| v.get("id").and_then(|x| x.as_i64())) | |
| .filter(|id| *id > 0) | |
| .map(|id| id.to_string()) | |
| }; | |
| match gw_fetch_arl(&client, "", "", uid.as_deref()).await { | |
| Ok((arl, token, _, license_token)) if arl.len() >= 20 => { | |
| let gw_token = if token.is_empty() { None } else { Some(token) }; | |
| let lic = if license_token.is_empty() { None } else { Some(license_token) }; | |
| (Some(arl), gw_token, lic) | |
| } | |
| _ => (None, None, None), | |
| } | |
| } | |
| async fn oauth_access_token( | |
| client: &reqwest::Client, | |
| email: &str, | |
| password_md5: &str, | |
| ) -> Result<String, String> { | |
| let app_id = deezer_app_id(); | |
| let hash_src = format!("{app_id}{email}{password_md5}{DEEZER_APP_SECRET}"); | |
| let hash = format!("{:x}", md5::compute(hash_src.as_bytes())); | |
| let r = client | |
| .get("https://connect.deezer.com/oauth/user_auth.php") | |
| .query(&[ | |
| ("app_id", app_id.as_str()), | |
| ("login", email), | |
| ("password", password_md5), | |
| ("hash", hash.as_str()), | |
| ]) | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header( | |
| "User-Agent", | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", | |
| ) | |
| .send() | |
| .await | |
| .map_err(|e| format!("network:{e}"))?; | |
| let status = r.status(); | |
| let text = r.text().await.map_err(|_| "read".to_string())?; | |
| let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| { | |
| let snip: String = text.chars().take(200).collect(); | |
| format!("parse:{}:{}", status.as_u16(), snip) | |
| })?; | |
| if let Some(err) = v.get("error") { | |
| return Err(err.as_str().unwrap_or("error").to_string()); | |
| } | |
| let token = v | |
| .get("access_token") | |
| .and_then(|t| t.as_str()) | |
| .unwrap_or("") | |
| .to_string(); | |
| if token.is_empty() { | |
| return Err("no_token".to_string()); | |
| } | |
| Ok(token) | |
| } | |
| async fn establish_oauth_session(client: &reqwest::Client, jar: &Jar, access_token: &str) { | |
| let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap(); | |
| let url_api = "https://api.deezer.com".parse::<Url>().unwrap(); | |
| let ua = gw_browser_ua(); | |
| let _ = client | |
| .get("https://api.deezer.com/user/me") | |
| .query(&[("access_token", access_token)]) | |
| .header("Authorization", format!("Bearer {access_token}")) | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header("User-Agent", ua) | |
| .send() | |
| .await; | |
| let _ = client | |
| .get("https://api.deezer.com/platform/generic/track/80085") | |
| .header("Authorization", format!("Bearer {access_token}")) | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header("User-Agent", ua) | |
| .send() | |
| .await; | |
| if !jar_has_cookie(jar, &url_deezer, "sid=") && !jar_has_cookie(jar, &url_api, "sid=") { | |
| let _ = client | |
| .get("https://api.deezer.com/platform/generic/track/3135556") | |
| .header("Authorization", format!("Bearer {access_token}")) | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header("User-Agent", ua) | |
| .send() | |
| .await; | |
| } | |
| } | |
| async fn web_login(email: &str, password_md5: &str) -> Result<WebLoginResult, String> { | |
| let jar = Arc::new(Jar::default()); | |
| let url_deezer = "https://www.deezer.com".parse::<Url>().map_err(|_| "web:url".to_string())?; | |
| jar.add_cookie_str("comeback=1; Domain=.deezer.com; Path=/", &url_deezer); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .cookie_provider(jar.clone()) | |
| .timeout(std::time::Duration::from_secs(25)) | |
| .build() | |
| .map_err(|_| "web:client".to_string())?; | |
| let _ = client | |
| .get("https://www.deezer.com/") | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header("User-Agent", gw_browser_ua()) | |
| .send() | |
| .await; | |
| // sid нужен ДО OAuth (как в официальном клиенте Deezer) | |
| ensure_anonymous_sid(&client, &jar) | |
| .await | |
| .map_err(|e| format!("web:{e}"))?; | |
| let access_token = oauth_access_token(&client, email, password_md5) | |
| .await | |
| .map_err(|e| format!("oauth:{e}"))?; | |
| establish_oauth_session(&client, &jar, &access_token).await; | |
| let _ = client | |
| .get("https://www.deezer.com/") | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header("User-Agent", gw_browser_ua()) | |
| .send() | |
| .await; | |
| let uid_from_api = api_deezer_get( | |
| &client, | |
| "/user/me", | |
| &access_token, | |
| &[], | |
| ) | |
| .await | |
| .ok() | |
| .and_then(|v| v.get("id").and_then(|x| x.as_i64())) | |
| .filter(|id| *id > 0) | |
| .map(|id| id.to_string()); | |
| let (arl, token, uid, license_token) = match gw_fetch_arl( | |
| &client, | |
| email, | |
| password_md5, | |
| uid_from_api.as_deref(), | |
| ) | |
| .await | |
| { | |
| Ok(v) => v, | |
| Err(_e) => { | |
| if let Ok(mobile_arl) = mobile_login(email, password_md5).await { | |
| ( | |
| mobile_arl, | |
| String::new(), | |
| uid_from_api.clone().unwrap_or_default(), | |
| String::new(), | |
| ) | |
| } else { | |
| return Ok(WebLoginResult { | |
| arl: None, | |
| token: String::new(), | |
| uid: uid_from_api.unwrap_or_default(), | |
| access_token, | |
| license_token: String::new(), | |
| }); | |
| } | |
| } | |
| }; | |
| Ok(WebLoginResult { | |
| arl: Some(arl.clone()), | |
| token, | |
| uid: if uid.is_empty() { | |
| uid_from_api.unwrap_or_default() | |
| } else { | |
| uid | |
| }, | |
| access_token, | |
| license_token, | |
| }) | |
| } | |
| fn worker_base() -> Option<String> { | |
| let env = std::env::var("DZMEDIA_WORKER").ok().unwrap_or_default(); | |
| let base = env.trim(); | |
| if base.is_empty() || base == "-" { | |
| return None; | |
| } | |
| Some(base.trim_end_matches('/').to_string()) | |
| } | |
| struct WorkerLoginReq<'a> { | |
| email: &'a str, | |
| password_md5: &'a str, | |
| } | |
| struct WorkerLoginResp { | |
| arl: Option<String>, | |
| error: Option<String>, | |
| } | |
| async fn worker_login(email: &str, password_md5: &str) -> Result<String, String> { | |
| let Some(base) = worker_base() else { | |
| return Err("worker:disabled".to_string()); | |
| }; | |
| let url = format!("{base}/login"); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(25)) | |
| .build() | |
| .map_err(|_| "worker:client".to_string())?; | |
| let r = client | |
| .post(url) | |
| .json(&WorkerLoginReq { email, password_md5 }) | |
| .send() | |
| .await | |
| .map_err(|e| format!("worker:network:{e}"))?; | |
| let status = r.status(); | |
| let text = r.text().await.map_err(|_| "worker:read".to_string())?; | |
| if !status.is_success() { | |
| let snip: String = text.chars().take(200).collect(); | |
| return Err(format!("worker:http:{}:{}", status.as_u16(), snip)); | |
| } | |
| let v: WorkerLoginResp = serde_json::from_str(&text).map_err(|_| { | |
| let snip: String = text.chars().take(200).collect(); | |
| format!("worker:parse:{snip}") | |
| })?; | |
| if let Some(arl) = v.arl { | |
| if arl.len() >= 100 { | |
| return Ok(arl); | |
| } | |
| return Err(format!("worker:arl_too_short:{}", arl.len())); | |
| } | |
| Err(format!("worker:error:{}", v.error.unwrap_or_else(|| "unknown".to_string()))) | |
| } | |
| async fn gw_light_call( | |
| client: &reqwest::Client, | |
| _jar: &Arc<Jar>, | |
| method: &str, | |
| api_token: &str, | |
| params: serde_json::Value, | |
| ) -> Result<serde_json::Value, String> { | |
| let url = "https://www.deezer.com/ajax/gw-light.php"; | |
| let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap(); | |
| fn cid() -> String { | |
| SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|d| d.as_millis().to_string()) | |
| .unwrap_or_else(|_| "0".to_string()) | |
| } | |
| async fn do_req( | |
| client: &reqwest::Client, | |
| _url_deezer: &Url, | |
| url: &str, | |
| method: &str, | |
| api_token: &str, | |
| params: &serde_json::Value, | |
| use_get: bool, | |
| user_id: Option<&str>, | |
| ) -> Result<serde_json::Value, String> { | |
| let cid = cid(); | |
| let mut req = if use_get || method == "user.getArl" { | |
| client.get(url) | |
| } else { | |
| client | |
| .post(url) | |
| .header("Content-Type", "text/plain;charset=UTF-8") | |
| .body(params.to_string()) | |
| }; | |
| if let Some(uid) = user_id.filter(|s| !s.trim().is_empty()) { | |
| req = req.header("x-deezer-user", uid); | |
| } | |
| let r = req | |
| .query(&[ | |
| ("method", method), | |
| ("input", "3"), | |
| ("output", "3"), | |
| ("api_version", "1.0"), | |
| ("api_token", api_token), | |
| ("cid", cid.as_str()), | |
| ]) | |
| .header(ACCEPT, "*/*") | |
| .header("Cache-Control", "max-age=0") | |
| .header("Origin", "https://www.deezer.com") | |
| .header("Referer", "https://www.deezer.com/") | |
| .header("Sec-Fetch-Site", "same-origin") | |
| .header("Sec-Fetch-Mode", "cors") | |
| .header("Sec-Fetch-Dest", "empty") | |
| .header("X-Requested-With", "XMLHttpRequest") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header("Content-Language", "en-US") | |
| .header( | |
| "User-Agent", | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", | |
| ) | |
| .header("sec-ch-ua", "\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"") | |
| .header("sec-ch-ua-mobile", "?0") | |
| .header("sec-ch-ua-platform", "\"Windows\"") | |
| .send() | |
| .await | |
| .map_err(|_| "network".to_string())?; | |
| let status = r.status(); | |
| let text = r.text().await.map_err(|_| "read".to_string())?; | |
| if status.as_u16() == 403 && text.to_ascii_lowercase().contains("access denied") { | |
| return Err("blocked:403".to_string()); | |
| } | |
| let json: GwResp = serde_json::from_str(&text).map_err(|_| { | |
| let snip: String = text.chars().take(200).collect(); | |
| format!("parse:{}:{}", status.as_u16(), snip) | |
| })?; | |
| if let Some(error) = json.error.as_object() { | |
| for (code, message) in error { | |
| let msg = message.as_str().unwrap_or(""); | |
| return Err(format!("{}:{}", code, msg)); | |
| } | |
| } | |
| Ok(json.results) | |
| } | |
| if method == "deezer.getUserData" { | |
| // Только POST — GET вариант блокируется как бот | |
| match do_req(client, &url_deezer, url, method, api_token, ¶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<serde_json::Value, String> { | |
| fn cid() -> String { | |
| SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|d| d.as_millis().to_string()) | |
| .unwrap_or_else(|_| "0".to_string()) | |
| } | |
| let cid = cid(); | |
| let mut req = if use_get || method == "user.getArl" { | |
| client.get("https://www.deezer.com/ajax/gw-light.php") | |
| } else { | |
| client | |
| .post("https://www.deezer.com/ajax/gw-light.php") | |
| .header("Content-Type", "text/plain;charset=UTF-8") | |
| .body(params.to_string()) | |
| }; | |
| if let Some(uid) = user_id.filter(|s| !s.trim().is_empty()) { | |
| req = req.header("x-deezer-user", uid); | |
| } | |
| let mut qp: Vec<(&str, &str)> = vec![ | |
| ("method", method), | |
| ("input", "3"), | |
| ("output", "3"), | |
| ("api_version", "1.0"), | |
| ("api_token", api_token), | |
| ("cid", cid.as_str()), | |
| ]; | |
| if let Some(gi) = gateway_input.filter(|s| !s.trim().is_empty()) { | |
| qp.push(("gateway_input", gi)); | |
| } | |
| let r = req | |
| .query(&qp) | |
| .header(ACCEPT, "*/*") | |
| .header("Cache-Control", "max-age=0") | |
| .header("Origin", "https://www.deezer.com") | |
| .header("Referer", "https://www.deezer.com/") | |
| .header("Sec-Fetch-Site", "same-origin") | |
| .header("Sec-Fetch-Mode", "cors") | |
| .header("Sec-Fetch-Dest", "empty") | |
| .header("X-Requested-With", "XMLHttpRequest") | |
| .header("Accept-Language", 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<AppState>, Json(req): Json<LoginReq>) -> impl IntoResponse { | |
| let email = req.email.trim().to_string(); | |
| let pass = req.password_md5.trim().to_string(); | |
| if email.is_empty() || pass.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "Email and password_md5 required"); | |
| } | |
| match web_login(&email, &pass).await { | |
| Ok(res) => { | |
| if let Some(ref arl) = res.arl { | |
| store_arl_session(&state, arl, &res.token, &res.license_token).await; | |
| state.api_by_arl.write().await.remove(arl); | |
| } | |
| (StatusCode::OK, Json(json!({ | |
| "arl": res.arl, | |
| "token": res.token, | |
| "uid": res.uid, | |
| "access_token": res.access_token | |
| }))).into_response() | |
| } | |
| Err(e) => json_error( | |
| StatusCode::UNAUTHORIZED, | |
| format!("login_failed; {e}"), | |
| ), | |
| } | |
| } | |
| struct PairSession { | |
| created_ms: u128, | |
| access_token: Option<String>, | |
| token: Option<String>, | |
| user_id: Option<String>, | |
| email: Option<String>, | |
| arl: Option<String>, | |
| error: Option<String>, | |
| } | |
| async fn pair_store_load(state: &AppState) { | |
| let path = state.pair_store_path.trim(); | |
| if path.is_empty() { | |
| return; | |
| } | |
| if let Ok(txt) = tokio::fs::read_to_string(path).await { | |
| if let Ok(map) = serde_json::from_str::<HashMap<String, PairSession>>(&txt) { | |
| *state.pair.write().await = map; | |
| } | |
| } | |
| } | |
| async fn pair_store_save(state: &AppState) { | |
| let path = state.pair_store_path.trim(); | |
| if path.is_empty() { | |
| return; | |
| } | |
| let map = state.pair.read().await.clone(); | |
| let Ok(txt) = serde_json::to_string(&map) else { return }; | |
| let tmp = format!("{path}.tmp"); | |
| if tokio::fs::write(&tmp, txt).await.is_ok() { | |
| if tokio::fs::rename(&tmp, path).await.is_err() { | |
| let _ = tokio::fs::remove_file(path).await; | |
| let _ = tokio::fs::rename(&tmp, path).await; | |
| } | |
| } | |
| } | |
| fn gen_pair_code() -> String { | |
| let ms = now_ms(); | |
| let hex = format!("{:x}", md5::compute(format!("pair:{ms}:{SECRET}").as_bytes())); | |
| hex.chars().take(10).collect::<String>() | |
| } | |
| fn deezer_app_id() -> String { | |
| std::env::var("DEEZER_APP_ID") | |
| .ok() | |
| .unwrap_or_else(|| "447462".to_string()) | |
| .trim() | |
| .to_string() | |
| } | |
| fn forwarded_proto(headers: &HeaderMap) -> String { | |
| headers | |
| .get("x-forwarded-proto") | |
| .and_then(|v| v.to_str().ok()) | |
| .unwrap_or("http") | |
| .trim() | |
| .to_string() | |
| } | |
| fn urlenc(s: &str) -> String { | |
| let mut out = String::with_capacity(s.len() + 16); | |
| for &b in s.as_bytes() { | |
| let c = b as char; | |
| let ok = matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~'); | |
| if ok { | |
| out.push(c); | |
| } else { | |
| out.push('%'); | |
| out.push_str(&format!("{:02X}", b)); | |
| } | |
| } | |
| out | |
| } | |
| struct PairStatusQuery { | |
| code: String, | |
| } | |
| async fn pair_start(State(state): State<AppState>) -> impl IntoResponse { | |
| let code = gen_pair_code(); | |
| let created_ms = now_ms(); | |
| { | |
| let mut map = state.pair.write().await; | |
| let now = created_ms; | |
| let ttl_ms: u128 = 10 * 60 * 1000; | |
| map.retain(|_, v| now.saturating_sub(v.created_ms) <= ttl_ms); | |
| if map.len() >= 512 { | |
| map.clear(); | |
| } | |
| map.insert( | |
| code.clone(), | |
| PairSession { | |
| created_ms, | |
| access_token: None, | |
| token: None, | |
| user_id: None, | |
| email: None, | |
| arl: None, | |
| error: None, | |
| }, | |
| ); | |
| } | |
| pair_store_save(&state).await; | |
| (StatusCode::OK, Json(json!({ "code": code }))).into_response() | |
| } | |
| async fn pair_status(State(state): State<AppState>, Query(q): Query<PairStatusQuery>) -> impl IntoResponse { | |
| let code = q.code.trim().to_string(); | |
| if code.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "code required"); | |
| } | |
| let now = now_ms(); | |
| let ttl_ms: u128 = 10 * 60 * 1000; | |
| let mut map = state.pair.write().await; | |
| let Some(sess) = map.get(&code) else { | |
| return json_error(StatusCode::NOT_FOUND, "no such code"); | |
| }; | |
| if now.saturating_sub(sess.created_ms) > ttl_ms { | |
| map.remove(&code); | |
| drop(map); | |
| pair_store_save(&state).await; | |
| return json_error(StatusCode::NOT_FOUND, "code expired"); | |
| } | |
| if let Some(err) = &sess.error { | |
| return (StatusCode::OK, Json(json!({ "status": "error", "error": err }))).into_response(); | |
| } | |
| if let Some(tok) = &sess.access_token { | |
| return ( | |
| StatusCode::OK, | |
| Json(json!({ "status": "ok", "access_token": tok, "token": sess.token, "user_id": sess.user_id, "email": sess.email, "arl": sess.arl })), | |
| ) | |
| .into_response(); | |
| } | |
| if sess.arl.as_deref().unwrap_or("").len() > 20 { | |
| return ( | |
| StatusCode::OK, | |
| Json(json!({ "status": "ok", "access_token": null, "token": sess.token, "user_id": sess.user_id, "email": sess.email, "arl": sess.arl })), | |
| ) | |
| .into_response(); | |
| } | |
| if sess.token.as_deref().unwrap_or("").trim().len() > 0 { | |
| return ( | |
| StatusCode::OK, | |
| Json(json!({ "status": "ok", "access_token": null, "token": sess.token, "user_id": sess.user_id, "email": sess.email, "arl": sess.arl })), | |
| ) | |
| .into_response(); | |
| } | |
| (StatusCode::OK, Json(json!({ "status": "pending" }))).into_response() | |
| } | |
| struct PairPageQuery { | |
| code: Option<String>, | |
| } | |
| struct PairOauthReq { | |
| code: String, | |
| access_token: String, | |
| user_id: Option<String>, | |
| } | |
| async fn pair_oauth(State(state): State<AppState>, Json(req): Json<PairOauthReq>) -> impl IntoResponse { | |
| let code = req.code.trim().to_string(); | |
| let access_token = req.access_token.trim().to_string(); | |
| let user_id = req | |
| .user_id | |
| .unwrap_or_default() | |
| .trim() | |
| .to_string() | |
| .chars() | |
| .filter(|c| c.is_ascii_digit()) | |
| .collect::<String>(); | |
| if code.is_empty() || access_token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "code/access_token required"); | |
| } | |
| let http = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let email = api_deezer_get(&http, "/user/me", &access_token, &[]) | |
| .await | |
| .ok() | |
| .and_then(|v| v.get("email").and_then(|x| x.as_str()).map(|s| s.to_string())); | |
| let uid_from_api = if user_id.is_empty() { | |
| api_deezer_get(&http, "/user/me", &access_token, &[]) | |
| .await | |
| .ok() | |
| .and_then(|v| v.get("id").and_then(|x| x.as_i64())) | |
| .filter(|id| *id > 0) | |
| .map(|id| id.to_string()) | |
| } else { | |
| Some(user_id.clone()) | |
| }; | |
| let (arl_opt, token_opt, license_opt) = try_fetch_arl_for_token( | |
| &access_token, | |
| uid_from_api.as_deref(), | |
| ) | |
| .await; | |
| if let (Some(ref arl), Some(ref token), Some(ref lic)) = (&arl_opt, &token_opt, &license_opt) { | |
| store_arl_session(&state, arl, token, lic).await; | |
| state.api_by_arl.write().await.remove(arl); | |
| } else if let (Some(ref arl), Some(ref token)) = (&arl_opt, &token_opt) { | |
| store_arl_session(&state, arl, token, "").await; | |
| state.api_by_arl.write().await.remove(arl); | |
| } | |
| let mut map = state.pair.write().await; | |
| let Some(sess) = map.get_mut(&code) else { | |
| return json_error(StatusCode::NOT_FOUND, "no such code"); | |
| }; | |
| sess.access_token = Some(access_token); | |
| if let Some(uid) = uid_from_api { | |
| sess.user_id = Some(uid); | |
| } | |
| if let Some(arl) = arl_opt { | |
| sess.arl = Some(arl); | |
| } | |
| if let Some(token) = token_opt { | |
| sess.token = Some(token); | |
| } | |
| if let Some(email) = email { | |
| sess.email = Some(email); | |
| } | |
| sess.error = None; | |
| drop(map); | |
| pair_store_save(&state).await; | |
| (StatusCode::OK, Json(json!({ "ok": true }))).into_response() | |
| } | |
| async fn pair_channel() -> impl IntoResponse { | |
| let html = "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body></body></html>"; | |
| (StatusCode::OK, Html(html.to_string())).into_response() | |
| } | |
| async fn pair_page(Host(host): Host, headers: HeaderMap, Query(q): Query<PairPageQuery>) -> impl IntoResponse { | |
| let code = q.code.unwrap_or_default().trim().to_string(); | |
| let safe_code = htmlesc(&code); | |
| let scheme = forwarded_proto(&headers); | |
| let app_id = deezer_app_id(); | |
| let redirect_url = format!("{scheme}://{host}/pair/oauth_cb"); | |
| let oauth_url = format!( | |
| "https://connect.deezer.com/oauth/auth.php?app_id={}&redirect_uri={}&perms={}&response_type=token&state={}", | |
| urlenc(&app_id), | |
| urlenc(&redirect_url), | |
| urlenc("basic_access,email,manage_library,listening_history"), | |
| urlenc(&code) | |
| ); | |
| let code_js = serde_json::to_string(&code).unwrap_or_else(|_| "\"\"".to_string()); | |
| let oauth_url_js = serde_json::to_string(&oauth_url).unwrap_or_else(|_| "\"\"".to_string()); | |
| let redirect_url_txt = htmlesc(&redirect_url); | |
| let mut html = String::with_capacity(6000); | |
| html.push_str(r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">"#); | |
| html.push_str(r#"<title>Deezer Login</title>"#); | |
| html.push_str(r#"<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0b0b;color:#fff;margin:0;padding:20px}.box{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}button{width:100%;padding:12px 14px;border-radius:10px;border:0;background:#a238ff;color:#fff;font-weight:800;font-size:16px}.muted{color:#aaa;font-size:13px;line-height:1.35}.ok{color:#1db954}.err{color:#ff5a5a}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:#101010;border-radius:8px;padding:10px;word-break:break-all}a{color:#a238ff}input{width:100%;box-sizing:border-box;padding:12px 12px;border-radius:10px;border:1px solid #2b2b2b;background:#0f0f0f;color:#fff;outline:none}input:focus{border-color:#a238ff}.row{margin:10px 0 0}</style>"#); | |
| html.push_str(r#"</head><body><div class="box">"#); | |
| html.push_str(r#"<h2 style="margin:0 0 6px">Deezer: вход для ТВ</h2>"#); | |
| html.push_str(r#"<div class="muted">Код пары: <b>"#); | |
| html.push_str(&safe_code); | |
| html.push_str(r#"</b></div>"#); | |
| html.push_str(r#"<div class="muted" style="margin:10px 0 14px">Нажми кнопку, войди в Deezer и подтверди доступ. После успеха вернись на телевизор.</div>"#); | |
| html.push_str(r#"<button id="dz-login" type="button">Войти через Deezer</button>"#); | |
| html.push_str(r#"<div id="msg" class="muted" style="margin:12px 0 0"></div>"#); | |
| html.push_str(r#"<div class="muted" style="margin:12px 0 8px">Рекомендуется OAuth (кнопка выше). Вход по логину/паролю ниже может блокироваться на некоторых прокси/хостингах (403 Access Denied).</div>"#); | |
| html.push_str(r#"<form method="POST" action="/pair/complete" autocomplete="on" style="margin:10px 0 0" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='Вход... Пожалуйста, подождите';">"#); | |
| html.push_str(r#"<input type="hidden" name="code" value="" id="pair-code-hidden">"#); | |
| html.push_str(r#"<div class="row"><input name="email" type="email" placeholder="Email" autocomplete="username" required></div>"#); | |
| html.push_str(r#"<div class="row"><input name="password" type="password" placeholder="Пароль" autocomplete="current-password" required></div>"#); | |
| html.push_str(r#"<div class="row"><button type="submit">Войти по логину и паролю</button></div>"#); | |
| html.push_str(r#"</form>"#); | |
| html.push_str(r#"<div class="muted" style="margin:14px 0 8px">Redirect URI для Deezer приложения:</div>"#); | |
| html.push_str(r#"<div class="mono">"#); | |
| html.push_str(&redirect_url_txt); | |
| html.push_str(r#"</div>"#); | |
| html.push_str(r#"<script>(function(){"#); | |
| html.push_str("var code="); | |
| html.push_str(&code_js); | |
| html.push_str(r#";var oauthUrl="#); | |
| html.push_str(&oauth_url_js); | |
| html.push_str(r#";var msg=document.getElementById('msg');function setMsg(cls,text){msg.className=cls?(cls+' muted'):'muted';msg.textContent=text;}if(!code){setMsg('err','Нет кода пары. Открой ссылку заново с QR.');return;}var hidden=document.getElementById('pair-code-hidden');if(hidden){hidden.value=code;}document.getElementById('dz-login').addEventListener('click',function(){setMsg('','Открываю Deezer…');if(!oauthUrl){setMsg('err','Не удалось собрать Deezer OAuth URL');return;}window.location.href=oauthUrl;});})();</script>"#); | |
| html.push_str(r#"<p class="muted" style="margin:14px 0 0">Если Deezer пишет про неверный redirect/domain — нужно указать свой <b>DEEZER_APP_ID</b> для домена этого сервера.</p>"#); | |
| html.push_str(r#"</div></body></html>"#); | |
| Html(html) | |
| } | |
| struct PairOauthCbQuery { | |
| code: Option<String>, | |
| state: Option<String>, | |
| } | |
| async fn pair_oauth_cb(Query(q): Query<PairOauthCbQuery>) -> impl IntoResponse { | |
| let code = q | |
| .code | |
| .or(q.state) | |
| .unwrap_or_default() | |
| .trim() | |
| .to_string(); | |
| let safe_code = htmlesc(&code); | |
| let code_js = serde_json::to_string(&code).unwrap_or_else(|_| "\"\"".to_string()); | |
| let mut html = String::with_capacity(3500); | |
| html.push_str(r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">"#); | |
| html.push_str(r#"<title>Deezer Login</title>"#); | |
| html.push_str(r#"<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0b0b;color:#fff;margin:0;padding:20px}.box{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}.muted{color:#aaa;font-size:13px;line-height:1.35}.ok{color:#1db954}.err{color:#ff5a5a}</style>"#); | |
| html.push_str(r#"</head><body><div class="box">"#); | |
| html.push_str(r#"<h2 style="margin:0 0 6px">Deezer: вход для ТВ</h2>"#); | |
| html.push_str(r#"<div class="muted">Код пары: <b>"#); | |
| html.push_str(&safe_code); | |
| html.push_str(r#"</b></div>"#); | |
| html.push_str(r#"<div id="msg" class="muted" style="margin:12px 0 0">Получаю токен…</div>"#); | |
| html.push_str(r#"<script>(function(){"#); | |
| html.push_str("var codeFromQuery="); | |
| html.push_str(&code_js); | |
| html.push_str(r#";var msg=document.getElementById('msg');function setMsg(cls,text){msg.className=cls?(cls+' muted'):'muted';msg.textContent=text;}function parseHash(){var h=window.location.hash||'';if(h.charAt(0)==='#')h=h.slice(1);var out={};h.split('&').forEach(function(p){if(!p)return;var i=p.indexOf('=');var k=i>=0?p.slice(0,i):p;var v=i>=0?p.slice(i+1):'';try{k=decodeURIComponent(k);}catch(e){}try{v=decodeURIComponent(v);}catch(e){}out[k]=v;});return out;}var h=parseHash();var code=(codeFromQuery||h.state||'');if(!code){setMsg('err','Нет кода пары. Вернись на телевизор и обнови QR/ссылку.');return;}var at=h.access_token||'';if(!at){setMsg('err','Deezer не вернул access_token. Попробуй ещё раз.');return;}try{history.replaceState(null,'',window.location.pathname+window.location.search);}catch(e){}fetch('/pair/oauth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:code,access_token:at})}).then(function(r){return r.json().catch(function(){return {};}).then(function(j){return {ok:r.ok,json:j};});}).then(function(r){if(r.ok&&!r.json.error){setMsg('ok','Готово ✓ Вернись на телевизор.');}else{setMsg('err','Ошибка сохранения: '+(r.json.error||'error'));}}).catch(function(){setMsg('err','Ошибка сети при сохранении');});})();</script>"#); | |
| html.push_str(r#"</div></body></html>"#); | |
| (StatusCode::OK, Html(html)).into_response() | |
| } | |
| 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<serde_json::Value, String> { | |
| let url = format!("https://api.deezer.com{path}"); | |
| let mut req = client | |
| .get(url) | |
| .header("Authorization", format!("Bearer {}", access_token)) | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header( | |
| "User-Agent", | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", | |
| ) | |
| .query(&[("access_token", access_token)]); | |
| if !extra.is_empty() { | |
| req = req.query(extra); | |
| } | |
| let r = req.send().await.map_err(|_| "api:network".to_string())?; | |
| let status = r.status(); | |
| let text = r.text().await.map_err(|_| "api:read".to_string())?; | |
| let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| { | |
| let snip: String = text.chars().take(200).collect(); | |
| format!("api:parse:{}:{}", status.as_u16(), snip) | |
| })?; | |
| if let Some(e) = v.get("error") { | |
| if e.is_object() { | |
| let code = e.get("code").and_then(|x| x.as_i64()).unwrap_or(0); | |
| let msg = e.get("message").and_then(|x| x.as_str()).unwrap_or("error"); | |
| return Err(format!("api:{code}:{msg}")); | |
| } | |
| return Err("api:error".to_string()); | |
| } | |
| Ok(v) | |
| } | |
| async fn api_deezer_post( | |
| client: &reqwest::Client, | |
| path: &str, | |
| access_token: &str, | |
| form: &[(&str, String)], | |
| ) -> Result<serde_json::Value, String> { | |
| 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<serde_json::Value, String> { | |
| 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<AppState>, Form(f): Form<PairCompleteForm>) -> impl IntoResponse { | |
| let code = f.code.trim().to_string(); | |
| let email = f.email.trim().to_string(); | |
| let password = f.password; | |
| let html_head = r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Deezer Login</title><style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0b0b;color:#fff;margin:0;padding:20px}.box{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}.muted{color:#aaa;font-size:13px;line-height:1.35}</style></head><body><div class="box">"#; | |
| let html_tail = r#"</div></body></html>"#; | |
| if code.is_empty() || email.is_empty() || password.is_empty() { | |
| return Html(format!("{html_head}<h3>Нужны code/email/password</h3>{html_tail}")).into_response(); | |
| } | |
| { | |
| let map = state.pair.read().await; | |
| if !map.contains_key(&code) { | |
| return Html(format!("{html_head}<h3>Код не найден или истёк</h3>{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}<h3>Ошибка входа</h3><pre style='white-space:pre-wrap;word-break:break-all;color:#ff5a5a'>{}</pre>{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() { | |
| "<h3>Готово ✓</h3><p class='muted'>Вернись на ТВ — плагин подключится автоматически.</p>" | |
| } else { | |
| "<h3>Вход выполнен</h3><p class='muted'>Библиотека доступна. ARL не получен — полные треки могут не работать, попробуй кнопку «Войти через Deezer».</p>" | |
| }; | |
| Html(format!("{html_head}{msg}{html_tail}")).into_response() | |
| } | |
| } | |
| } | |
| struct OauthMeReq { | |
| access_token: String, | |
| } | |
| async fn oauth_me(Json(req): Json<OauthMeReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| struct OauthPlaylistsReq { | |
| access_token: String, | |
| index: Option<u32>, | |
| limit: Option<u32>, | |
| } | |
| async fn oauth_playlists(Json(req): Json<OauthPlaylistsReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let index = req.index.unwrap_or(0).to_string(); | |
| let limit = req.limit.unwrap_or(50).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let me = match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| }; | |
| let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id"); | |
| } | |
| let path = format!("/user/{uid}/playlists"); | |
| let extra = [("index", index), ("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| struct OauthPlaylistTracksReq { | |
| access_token: String, | |
| playlist_id: u64, | |
| index: Option<u32>, | |
| limit: Option<u32>, | |
| } | |
| async fn oauth_playlist_tracks(Json(req): Json<OauthPlaylistTracksReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| if req.playlist_id == 0 { | |
| return json_error(StatusCode::BAD_REQUEST, "playlist_id required"); | |
| } | |
| let index = req.index.unwrap_or(0).to_string(); | |
| let limit = req.limit.unwrap_or(100).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let path = format!("/playlist/{}/tracks", req.playlist_id); | |
| let extra = [("index", index), ("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| struct OauthArlReq { | |
| access_token: String, | |
| } | |
| async fn oauth_arl(State(state): State<AppState>, Json(req): Json<OauthArlReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let jar = Arc::new(Jar::default()); | |
| let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap(); | |
| jar.add_cookie_str("comeback=1; Domain=.deezer.com", &url_deezer); | |
| jar.add_cookie_str("dzr_uniq_id=; Domain=.deezer.com", &url_deezer); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .cookie_provider(jar.clone()) | |
| .timeout(std::time::Duration::from_secs(20)) | |
| .build() | |
| .unwrap(); | |
| let _ = client | |
| .get("https://www.deezer.com/") | |
| .header(ACCEPT, "*/*") | |
| .header("Accept-Language", "en-US,en;q=0.9") | |
| .header("User-Agent", gw_browser_ua()) | |
| .send() | |
| .await; | |
| if let Err(e) = ensure_anonymous_sid(&client, &jar).await { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, format!("oauth_arl:{e}")); | |
| } | |
| establish_oauth_session(&client, &jar, &token).await; | |
| let uid = api_deezer_get(&client, "/user/me", &token, &[]) | |
| .await | |
| .ok() | |
| .and_then(|v| v.get("id").and_then(|x| x.as_i64())) | |
| .filter(|id| *id > 0) | |
| .map(|id| id.to_string()); | |
| match gw_fetch_arl(&client, "", "", uid.as_deref()).await { | |
| Ok((arl, check_form, _, license_token)) if arl.len() >= 20 => { | |
| store_arl_session(&state, &arl, &check_form, &license_token).await; | |
| state.api_by_arl.write().await.remove(&arl); | |
| (StatusCode::OK, Json(json!({ "arl": arl }))).into_response() | |
| } | |
| Ok((arl, _, _, _)) => { | |
| json_error( | |
| StatusCode::SERVICE_UNAVAILABLE, | |
| format!("oauth_arl:arl_too_short:{}", arl.len()), | |
| ) | |
| } | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, format!("oauth_arl:{e}")), | |
| } | |
| } | |
| struct GwPageReq { | |
| arl: String, | |
| token: Option<String>, | |
| user_id: Option<String>, | |
| page: Option<String>, | |
| lang: Option<String>, | |
| } | |
| async fn gw_page(Json(req): Json<GwPageReq>) -> impl IntoResponse { | |
| let arl = req.arl.trim().to_string(); | |
| if arl.len() < 20 { | |
| return json_error(StatusCode::BAD_REQUEST, "arl required"); | |
| } | |
| let page = req.page.unwrap_or_else(|| "home".to_string()).trim().to_string(); | |
| if page.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "page required"); | |
| } | |
| let lang = req | |
| .lang | |
| .unwrap_or_else(|| "en".to_string()) | |
| .trim() | |
| .to_string(); | |
| let lang = if lang.is_empty() { "en".to_string() } else { lang }; | |
| let jar = Arc::new(Jar::default()); | |
| let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap(); | |
| jar.add_cookie_str("comeback=1; Domain=.deezer.com", &url_deezer); | |
| jar.add_cookie_str(&format!("arl={arl}; Domain=.deezer.com; Path=/"), &url_deezer); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .cookie_provider(jar.clone()) | |
| .timeout(std::time::Duration::from_secs(20)) | |
| .build() | |
| .unwrap(); | |
| let ud = match gw_light_call_custom( | |
| &client, | |
| "deezer.getUserData", | |
| "null", | |
| None, | |
| &json!({}), | |
| None, | |
| true, | |
| 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::<String>(); | |
| let api_token = if !token.is_empty() { | |
| token | |
| } else { | |
| ud.get("checkForm") | |
| .and_then(|x| x.as_str()) | |
| .unwrap_or("") | |
| .trim() | |
| .to_string() | |
| }; | |
| if api_token.is_empty() { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "gw:no_checkForm"); | |
| } | |
| let user_id = req | |
| .user_id | |
| .unwrap_or_default() | |
| .trim() | |
| .to_string() | |
| .chars() | |
| .filter(|c| c.is_ascii_digit()) | |
| .collect::<String>(); | |
| let user_id = if !user_id.is_empty() { | |
| Some(user_id) | |
| } else { | |
| ud.get("USER") | |
| .and_then(|u| u.get("USER_ID")) | |
| .and_then(|x| x.as_i64()) | |
| .filter(|n| *n > 0) | |
| .map(|n| n.to_string()) | |
| }; | |
| let support = json!({ | |
| "ads": [], | |
| "deeplink-list": ["deeplink"], | |
| "event-card": ["live-event"], | |
| "grid-preview-one": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"], | |
| "grid-preview-two": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"], | |
| "grid": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"], | |
| "horizontal-grid": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"], | |
| "horizontal-list": ["track","song"], | |
| "item-highlight": ["radio"], | |
| "large-card": ["album","external-link","playlist","show","video-link"], | |
| "list": ["episode"], | |
| "mini-banner": ["external-link"], | |
| "slideshow": ["album","artist","channel","external-link","flow","livestream","playlist","show","smarttracklist","user","video-link"], | |
| "small-horizontal-grid": ["flow"], | |
| "long-card-horizontal-grid": ["album","artist","artistLineUp","channel","livestream","flow","playlist","radio","show","smarttracklist","track","user","video-link","external-link"], | |
| "filterable-grid": ["flow"] | |
| }); | |
| let gateway_input = json!({ | |
| "PAGE": page, | |
| "VERSION": "2.5", | |
| "SUPPORT": support, | |
| "LANG": lang, | |
| "OPTIONS": ["deeplink_newsandentertainment", "deeplink_subscribeoffer"] | |
| }) | |
| .to_string(); | |
| let results = match gw_light_call_custom( | |
| &client, | |
| "page.get", | |
| &api_token, | |
| Some(&gateway_input), | |
| &json!({}), | |
| user_id.as_deref(), | |
| false, | |
| 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() | |
| } | |
| struct GwFlowReq { | |
| arl: String, | |
| token: Option<String>, | |
| user_id: String, | |
| config_id: Option<String>, | |
| } | |
| async fn gw_flow(Json(req): Json<GwFlowReq>) -> impl IntoResponse { | |
| let arl = req.arl.trim().to_string(); | |
| if arl.len() < 20 { | |
| return json_error(StatusCode::BAD_REQUEST, "arl required"); | |
| } | |
| let user_id = req.user_id.trim().to_string(); | |
| if user_id.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "user_id required"); | |
| } | |
| let jar = Arc::new(Jar::default()); | |
| let url_deezer = "https://www.deezer.com".parse::<Url>().unwrap(); | |
| jar.add_cookie_str("comeback=1; Domain=.deezer.com", &url_deezer); | |
| jar.add_cookie_str(&format!("arl={arl}; Domain=.deezer.com; Path=/"), &url_deezer); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .cookie_provider(jar.clone()) | |
| .timeout(std::time::Duration::from_secs(20)) | |
| .build() | |
| .unwrap(); | |
| let ud = match gw_light_call_custom( | |
| &client, | |
| "deezer.getUserData", | |
| "null", | |
| None, | |
| &json!({}), | |
| None, | |
| true, | |
| 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() | |
| } | |
| struct OauthFlowReq { | |
| access_token: String, | |
| limit: Option<u32>, | |
| } | |
| async fn oauth_flow(Json(req): Json<OauthFlowReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let limit = req.limit.unwrap_or(40).clamp(1, 100).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let me = match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| }; | |
| let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id"); | |
| } | |
| let path = format!("/user/{uid}/flow"); | |
| let extra = [("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| struct OauthActionReq { | |
| access_token: String, | |
| track_id: Option<String>, | |
| playlist_id: Option<String>, | |
| title: Option<String>, | |
| } | |
| async fn oauth_favorite_add(Json(req): Json<OauthActionReq>) -> 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<OauthActionReq>) -> 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<OauthActionReq>) -> 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_remove_track(Json(req): Json<OauthActionReq>) -> 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_delete(&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<OauthActionReq>) -> 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<OauthActionReq>) -> 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<OauthActionReq>) -> 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), | |
| } | |
| } | |
| struct OauthRecoReq { | |
| access_token: String, | |
| index: Option<u32>, | |
| limit: Option<u32>, | |
| } | |
| async fn oauth_reco_playlists(Json(req): Json<OauthRecoReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let index = req.index.unwrap_or(0).to_string(); | |
| let limit = req.limit.unwrap_or(20).clamp(1, 50).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let me = match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| }; | |
| let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id"); | |
| } | |
| let path = format!("/user/{uid}/recommendations/playlists"); | |
| let extra = [("index", index), ("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| async fn oauth_reco_albums(Json(req): Json<OauthRecoReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let index = req.index.unwrap_or(0).to_string(); | |
| let limit = req.limit.unwrap_or(20).clamp(1, 50).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let me = match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| }; | |
| let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id"); | |
| } | |
| let path = format!("/user/{uid}/recommendations/albums"); | |
| let extra = [("index", index), ("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| async fn oauth_reco_artists(Json(req): Json<OauthRecoReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let index = req.index.unwrap_or(0).to_string(); | |
| let limit = req.limit.unwrap_or(20).clamp(1, 50).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let me = match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| }; | |
| let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id"); | |
| } | |
| let path = format!("/user/{uid}/recommendations/artists"); | |
| let extra = [("index", index), ("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| async fn oauth_reco_tracks(Json(req): Json<OauthRecoReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let index = req.index.unwrap_or(0).to_string(); | |
| let limit = req.limit.unwrap_or(40).clamp(1, 100).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let me = match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| }; | |
| let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id"); | |
| } | |
| let path = format!("/user/{uid}/recommendations/tracks"); | |
| let extra = [("index", index), ("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| struct OauthHistoryReq { | |
| access_token: String, | |
| index: Option<u32>, | |
| limit: Option<u32>, | |
| } | |
| async fn oauth_history(Json(req): Json<OauthHistoryReq>) -> impl IntoResponse { | |
| let token = req.access_token.trim().to_string(); | |
| if token.is_empty() { | |
| return json_error(StatusCode::BAD_REQUEST, "access_token required"); | |
| } | |
| let index = req.index.unwrap_or(0).to_string(); | |
| let limit = req.limit.unwrap_or(50).clamp(1, 100).to_string(); | |
| let client = reqwest::Client::builder() | |
| .no_proxy() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build() | |
| .unwrap(); | |
| let me = match api_deezer_get(&client, "/user/me", &token, &[]).await { | |
| Ok(v) => v, | |
| Err(e) => return json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| }; | |
| let uid = me.get("id").and_then(|x| x.as_i64()).unwrap_or(0); | |
| if uid <= 0 { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, "api:no_user_id"); | |
| } | |
| let path = format!("/user/{uid}/history"); | |
| let extra = [("index", index), ("limit", limit)]; | |
| match api_deezer_get(&client, &path, &token, &extra).await { | |
| Ok(v) => (StatusCode::OK, Json(v)).into_response(), | |
| Err(e) => json_error(StatusCode::SERVICE_UNAVAILABLE, e), | |
| } | |
| } | |
| struct WarmArlReq { | |
| arl: String, | |
| } | |
| async fn warm_arl(State(state): State<AppState>, Json(req): Json<WarmArlReq>) -> impl IntoResponse { | |
| let arl = req.arl.trim().to_string(); | |
| if arl.len() < 20 { | |
| return json_error(StatusCode::BAD_REQUEST, "arl required"); | |
| } | |
| state.api_by_arl.write().await.remove(&arl); | |
| let client = api_client_for_arl(&state, Some(arl.clone())).await; | |
| let session = { | |
| let mut c = client.lock().await; | |
| if c.license_token.is_empty() { | |
| if let Err(e) = c.force_renew().await { | |
| return json_error(StatusCode::SERVICE_UNAVAILABLE, format!("warm:renew:{e}")); | |
| } | |
| } | |
| c.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() | |
| } | |
| 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()), | |
| cdn_cache: Arc::new(RwLock::new(HashMap::new())), | |
| }; | |
| 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("/download", get(download)) | |
| .route("/send_audio", get(send_audio)) | |
| .route("/warm", get(warm)) | |
| .route("/stream_info", get(stream_info)) | |
| // SoundCloud endpoints | |
| .route("/soundcloud/search", get(soundcloud::search)) | |
| .route("/soundcloud/download", get(soundcloud::download)) | |
| // Deezer OAuth/user endpoints | |
| .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_remove_track", post(oauth_playlist_remove_track)) | |
| .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(); | |
| } | |