Spaces:
Running
Running
Upload 2 files
Browse files- src/api.rs +39 -8
- src/main.rs +193 -64
src/api.rs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
use serde::{Serialize, Deserialize, de::DeserializeOwned};
|
| 2 |
use std::{env, marker::Sized, time::Instant, sync::Arc};
|
| 3 |
-
use std::time::{SystemTime, UNIX_EPOCH};
|
| 4 |
use thiserror::Error;
|
| 5 |
use serde_json::{json, value::from_value};
|
| 6 |
use reqwest::{Client, Response, cookie::Jar, Url, header::ACCEPT};
|
|
@@ -73,22 +73,48 @@ impl APIClient {
|
|
| 73 |
}
|
| 74 |
|
| 75 |
pub fn new_with_arl(arl: String) -> Self {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
let builder = Client::builder().no_proxy();
|
| 77 |
|
| 78 |
-
let cookie = format!("arl={}; Domain=.deezer.com", arl);
|
| 79 |
-
let comeback = "comeback=1; Domain=.deezer.com";
|
| 80 |
let url = "https://www.deezer.com".parse::<Url>().unwrap();
|
| 81 |
|
| 82 |
let jar = Jar::default();
|
| 83 |
jar.add_cookie_str(&cookie, &url);
|
| 84 |
jar.add_cookie_str(comeback, &url);
|
| 85 |
-
let builder = builder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
Self {
|
| 88 |
client: builder.build().unwrap(),
|
| 89 |
-
license_token
|
| 90 |
-
check_form
|
| 91 |
-
renew_instant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
}
|
| 94 |
|
|
@@ -222,7 +248,12 @@ impl APIClient {
|
|
| 222 |
"track_tokens": track_tokens
|
| 223 |
});
|
| 224 |
|
| 225 |
-
self.client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
.json(&req)
|
| 227 |
.send()
|
| 228 |
.await
|
|
|
|
| 1 |
use serde::{Serialize, Deserialize, de::DeserializeOwned};
|
| 2 |
use std::{env, marker::Sized, time::Instant, sync::Arc};
|
| 3 |
+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
| 4 |
use thiserror::Error;
|
| 5 |
use serde_json::{json, value::from_value};
|
| 6 |
use reqwest::{Client, Response, cookie::Jar, Url, header::ACCEPT};
|
|
|
|
| 73 |
}
|
| 74 |
|
| 75 |
pub fn new_with_arl(arl: String) -> Self {
|
| 76 |
+
Self::new_with_arl_session(arl, String::new(), String::new())
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
pub fn new_with_arl_session(
|
| 80 |
+
arl: String,
|
| 81 |
+
check_form: String,
|
| 82 |
+
license_token: String,
|
| 83 |
+
) -> Self {
|
| 84 |
let builder = Client::builder().no_proxy();
|
| 85 |
|
| 86 |
+
let cookie = format!("arl={}; Domain=.deezer.com; Path=/", arl);
|
| 87 |
+
let comeback = "comeback=1; Domain=.deezer.com; Path=/";
|
| 88 |
let url = "https://www.deezer.com".parse::<Url>().unwrap();
|
| 89 |
|
| 90 |
let jar = Jar::default();
|
| 91 |
jar.add_cookie_str(&cookie, &url);
|
| 92 |
jar.add_cookie_str(comeback, &url);
|
| 93 |
+
let builder = builder
|
| 94 |
+
.cookie_provider(Arc::new(jar))
|
| 95 |
+
.timeout(std::time::Duration::from_secs(30));
|
| 96 |
+
|
| 97 |
+
let renew_instant = if !license_token.is_empty() && !check_form.is_empty() {
|
| 98 |
+
Some(Instant::now())
|
| 99 |
+
} else {
|
| 100 |
+
None
|
| 101 |
+
};
|
| 102 |
|
| 103 |
Self {
|
| 104 |
client: builder.build().unwrap(),
|
| 105 |
+
license_token,
|
| 106 |
+
check_form,
|
| 107 |
+
renew_instant,
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
pub fn seed_session(&mut self, check_form: String, license_token: String) {
|
| 112 |
+
if !check_form.is_empty() {
|
| 113 |
+
self.check_form = check_form;
|
| 114 |
+
}
|
| 115 |
+
if !license_token.is_empty() {
|
| 116 |
+
self.license_token = license_token;
|
| 117 |
+
self.renew_instant = Some(Instant::now());
|
| 118 |
}
|
| 119 |
}
|
| 120 |
|
|
|
|
| 248 |
"track_tokens": track_tokens
|
| 249 |
});
|
| 250 |
|
| 251 |
+
self.client
|
| 252 |
+
.post("https://media.deezer.com/v1/get_url")
|
| 253 |
+
.header(ACCEPT, "application/json")
|
| 254 |
+
.header("Content-Type", "application/json")
|
| 255 |
+
.header("Origin", "https://www.deezer.com")
|
| 256 |
+
.header("Referer", "https://www.deezer.com/")
|
| 257 |
.json(&req)
|
| 258 |
.send()
|
| 259 |
.await
|
src/main.rs
CHANGED
|
@@ -30,14 +30,46 @@ type BoxErr = Box<dyn std::error::Error + Send + Sync + 'static>;
|
|
| 30 |
mod api;
|
| 31 |
use api::{APIClient, APIError, Format};
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
#[derive(Clone)]
|
| 34 |
struct AppState {
|
| 35 |
api: Arc<Mutex<APIClient>>,
|
| 36 |
api_by_arl: Arc<RwLock<HashMap<String, Arc<Mutex<APIClient>>>>>,
|
|
|
|
| 37 |
pair: Arc<RwLock<HashMap<String, PairSession>>>,
|
| 38 |
pair_store_path: String,
|
| 39 |
}
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
fn json_error(status: StatusCode, message: impl Into<String>) -> axum::response::Response {
|
| 42 |
(status, Json(json!({ "error": message.into() }))).into_response()
|
| 43 |
}
|
|
@@ -61,7 +93,13 @@ async fn api_client_for_arl(state: &AppState, arl: Option<String>) -> Arc<Mutex<
|
|
| 61 |
if map.len() >= 32 {
|
| 62 |
map.clear();
|
| 63 |
}
|
| 64 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
map.insert(arl, c.clone());
|
| 66 |
c
|
| 67 |
}
|
|
@@ -139,11 +177,62 @@ fn total_from_content_range(v: &str) -> Option<u64> {
|
|
| 139 |
rest.trim().parse::<u64>().ok()
|
| 140 |
}
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
async fn media_url_for_track(
|
| 143 |
client: &mut APIClient,
|
| 144 |
id: u32,
|
| 145 |
formats: &Vec<Format>,
|
| 146 |
) -> Result<String, String> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
let resp: Result<DeezerTrackList, APIError> = client
|
| 148 |
.api_call(
|
| 149 |
"song.getListData",
|
|
@@ -157,21 +246,26 @@ async fn media_url_for_track(
|
|
| 157 |
}
|
| 158 |
|
| 159 |
let track_token = track_list.data[0].TRACK_TOKEN.as_str();
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
.map_err(|e| e.to_string())?;
|
| 164 |
|
| 165 |
-
let
|
| 166 |
-
.json()
|
| 167 |
-
.await
|
| 168 |
-
.map_err(|_| "Bad media response".to_string())?;
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
}
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
fn upstream_dzmedia_base() -> Option<String> {
|
|
@@ -417,28 +511,12 @@ async fn stream_info(State(state): State<AppState>, Query(q): Query<StreamParams
|
|
| 417 |
state.api_by_arl.write().await.remove(&arl);
|
| 418 |
}
|
| 419 |
let client = api_client_for_arl(&state, Some(arl.clone())).await;
|
| 420 |
-
let res =
|
| 421 |
let mut c = client.lock().await;
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
.api_call(
|
| 425 |
-
"song.getListData",
|
| 426 |
-
&json!({"sng_ids":[q.id],"array_default":["SNG_ID","TRACK_TOKEN","DURATION"]}),
|
| 427 |
-
)
|
| 428 |
-
.await;
|
| 429 |
-
let track_list = resp.map_err(|e| e.to_string())?;
|
| 430 |
-
if track_list.data.is_empty() { return Err("No valid ID".to_string()); }
|
| 431 |
-
let token = track_list.data[0].TRACK_TOKEN.as_str();
|
| 432 |
-
let media_resp = c.get_media(&formats, vec![token]).await.map_err(|e| e.to_string())?;
|
| 433 |
-
let media_json: serde_json::Value = media_resp.json().await
|
| 434 |
-
.map_err(|_| "Bad media response".to_string())?;
|
| 435 |
-
let u = extract_media_url(&media_json);
|
| 436 |
-
let f = extract_media_format(&media_json);
|
| 437 |
-
if u.is_empty() { return Err("No url in media response".to_string()); }
|
| 438 |
-
Ok::<(String, String), String>((u, f))
|
| 439 |
-
}.await;
|
| 440 |
match res {
|
| 441 |
-
Ok(
|
| 442 |
Err(e) => { last_err = Some(e); }
|
| 443 |
}
|
| 444 |
}
|
|
@@ -525,12 +603,11 @@ async fn stream(
|
|
| 525 |
}
|
| 526 |
}
|
| 527 |
|
| 528 |
-
// До 3 попыток: 0 —
|
| 529 |
for attempt in 0..3u8 {
|
| 530 |
if url.is_some() { break; }
|
| 531 |
|
| 532 |
-
|
| 533 |
-
if attempt == 1 {
|
| 534 |
if let Some(ref arl) = arl_key {
|
| 535 |
state.api_by_arl.write().await.remove(arl);
|
| 536 |
} else {
|
|
@@ -541,11 +618,10 @@ async fn stream(
|
|
| 541 |
let client = api_client_for_arl(&state, arl_key.clone()).await;
|
| 542 |
let res = {
|
| 543 |
let mut c = client.lock().await;
|
| 544 |
-
|
| 545 |
-
if attempt == 2 {
|
| 546 |
if let Err(e) = c.force_renew().await {
|
| 547 |
last_err = Some(format!("renew:{}", e));
|
| 548 |
-
|
| 549 |
}
|
| 550 |
}
|
| 551 |
media_url_for_track(&mut c, q.id, &formats).await
|
|
@@ -1027,6 +1103,7 @@ struct WebLoginResult {
|
|
| 1027 |
token: String,
|
| 1028 |
uid: String,
|
| 1029 |
access_token: String,
|
|
|
|
| 1030 |
}
|
| 1031 |
|
| 1032 |
fn gw_browser_ua() -> &'static str {
|
|
@@ -1099,7 +1176,7 @@ async fn gw_fetch_arl(
|
|
| 1099 |
email: &str,
|
| 1100 |
password_md5: &str,
|
| 1101 |
user_id: Option<&str>,
|
| 1102 |
-
) -> Result<(String, String, String), String> {
|
| 1103 |
let mut last_err = String::from("getArl:none");
|
| 1104 |
|
| 1105 |
for attempt in 0..3u8 {
|
|
@@ -1151,7 +1228,8 @@ async fn gw_fetch_arl(
|
|
| 1151 |
Ok(v) => {
|
| 1152 |
let arl = v.as_str().unwrap_or("").to_string();
|
| 1153 |
if arl.len() >= 100 {
|
| 1154 |
-
|
|
|
|
| 1155 |
}
|
| 1156 |
last_err = format!("arl_too_short:{}", arl.len());
|
| 1157 |
}
|
|
@@ -1183,11 +1261,11 @@ async fn gw_fetch_arl(
|
|
| 1183 |
async fn try_fetch_arl_for_token(
|
| 1184 |
access_token: &str,
|
| 1185 |
user_id: Option<&str>,
|
| 1186 |
-
) -> (Option<String>, Option<String>) {
|
| 1187 |
let jar = Arc::new(Jar::default());
|
| 1188 |
let url_deezer = match "https://www.deezer.com".parse::<Url>() {
|
| 1189 |
Ok(u) => u,
|
| 1190 |
-
Err(_) => return (None, None),
|
| 1191 |
};
|
| 1192 |
jar.add_cookie_str("comeback=1; Domain=.deezer.com; Path=/", &url_deezer);
|
| 1193 |
|
|
@@ -1198,7 +1276,7 @@ async fn try_fetch_arl_for_token(
|
|
| 1198 |
.build()
|
| 1199 |
{
|
| 1200 |
Ok(c) => c,
|
| 1201 |
-
Err(_) => return (None, None),
|
| 1202 |
};
|
| 1203 |
|
| 1204 |
let _ = client
|
|
@@ -1209,7 +1287,7 @@ async fn try_fetch_arl_for_token(
|
|
| 1209 |
.await;
|
| 1210 |
|
| 1211 |
if ensure_anonymous_sid(&client, &jar).await.is_err() {
|
| 1212 |
-
return (None, None);
|
| 1213 |
}
|
| 1214 |
|
| 1215 |
establish_oauth_session(&client, &jar, access_token).await;
|
|
@@ -1226,11 +1304,12 @@ async fn try_fetch_arl_for_token(
|
|
| 1226 |
};
|
| 1227 |
|
| 1228 |
match gw_fetch_arl(&client, "", "", uid.as_deref()).await {
|
| 1229 |
-
Ok((arl, token, _)) if arl.len() >= 20 => {
|
| 1230 |
let gw_token = if token.is_empty() { None } else { Some(token) };
|
| 1231 |
-
(Some(
|
|
|
|
| 1232 |
}
|
| 1233 |
-
_ => (None, None),
|
| 1234 |
}
|
| 1235 |
}
|
| 1236 |
|
|
@@ -1370,7 +1449,7 @@ async fn web_login(email: &str, password_md5: &str) -> Result<WebLoginResult, St
|
|
| 1370 |
.filter(|id| *id > 0)
|
| 1371 |
.map(|id| id.to_string());
|
| 1372 |
|
| 1373 |
-
let (arl, token, uid) = match gw_fetch_arl(
|
| 1374 |
&client,
|
| 1375 |
email,
|
| 1376 |
password_md5,
|
|
@@ -1380,12 +1459,12 @@ async fn web_login(email: &str, password_md5: &str) -> Result<WebLoginResult, St
|
|
| 1380 |
{
|
| 1381 |
Ok(v) => v,
|
| 1382 |
Err(_e) => {
|
| 1383 |
-
// ARL не обязателен — OAuth уже даёт доступ к библиотеке
|
| 1384 |
if let Ok(mobile_arl) = mobile_login(email, password_md5).await {
|
| 1385 |
(
|
| 1386 |
mobile_arl,
|
| 1387 |
String::new(),
|
| 1388 |
uid_from_api.clone().unwrap_or_default(),
|
|
|
|
| 1389 |
)
|
| 1390 |
} else {
|
| 1391 |
return Ok(WebLoginResult {
|
|
@@ -1393,13 +1472,14 @@ async fn web_login(email: &str, password_md5: &str) -> Result<WebLoginResult, St
|
|
| 1393 |
token: String::new(),
|
| 1394 |
uid: uid_from_api.unwrap_or_default(),
|
| 1395 |
access_token,
|
|
|
|
| 1396 |
});
|
| 1397 |
}
|
| 1398 |
}
|
| 1399 |
};
|
| 1400 |
|
| 1401 |
Ok(WebLoginResult {
|
| 1402 |
-
arl: Some(arl),
|
| 1403 |
token,
|
| 1404 |
uid: if uid.is_empty() {
|
| 1405 |
uid_from_api.unwrap_or_default()
|
|
@@ -1407,6 +1487,7 @@ async fn web_login(email: &str, password_md5: &str) -> Result<WebLoginResult, St
|
|
| 1407 |
uid
|
| 1408 |
},
|
| 1409 |
access_token,
|
|
|
|
| 1410 |
})
|
| 1411 |
}
|
| 1412 |
|
|
@@ -1521,7 +1602,6 @@ async fn gw_light_call(
|
|
| 1521 |
])
|
| 1522 |
.header(ACCEPT, "*/*")
|
| 1523 |
.header("Cache-Control", "max-age=0")
|
| 1524 |
-
.header("Connection", "keep-alive")
|
| 1525 |
.header("Origin", "https://www.deezer.com")
|
| 1526 |
.header("Referer", "https://www.deezer.com/")
|
| 1527 |
.header("Sec-Fetch-Site", "same-origin")
|
|
@@ -1624,7 +1704,6 @@ async fn gw_light_call_custom(
|
|
| 1624 |
.query(&qp)
|
| 1625 |
.header(ACCEPT, "*/*")
|
| 1626 |
.header("Cache-Control", "max-age=0")
|
| 1627 |
-
.header("Connection", "keep-alive")
|
| 1628 |
.header("Origin", "https://www.deezer.com")
|
| 1629 |
.header("Referer", "https://www.deezer.com/")
|
| 1630 |
.header("Sec-Fetch-Site", "same-origin")
|
|
@@ -1667,7 +1746,7 @@ fn jar_has_cookie(jar: &Jar, url: &Url, cookie_prefix: &str) -> bool {
|
|
| 1667 |
s.split(';').any(|p| p.trim_start().starts_with(cookie_prefix))
|
| 1668 |
}
|
| 1669 |
|
| 1670 |
-
async fn login(Json(req): Json<LoginReq>) -> impl IntoResponse {
|
| 1671 |
let email = req.email.trim().to_string();
|
| 1672 |
let pass = req.password_md5.trim().to_string();
|
| 1673 |
|
|
@@ -1676,12 +1755,18 @@ async fn login(Json(req): Json<LoginReq>) -> impl IntoResponse {
|
|
| 1676 |
}
|
| 1677 |
|
| 1678 |
match web_login(&email, &pass).await {
|
| 1679 |
-
Ok(res) =>
|
| 1680 |
-
|
| 1681 |
-
|
| 1682 |
-
|
| 1683 |
-
|
| 1684 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1685 |
Err(e) => json_error(
|
| 1686 |
StatusCode::UNAUTHORIZED,
|
| 1687 |
format!("login_failed; {e}"),
|
|
@@ -1894,12 +1979,20 @@ async fn pair_oauth(State(state): State<AppState>, Json(req): Json<PairOauthReq>
|
|
| 1894 |
Some(user_id.clone())
|
| 1895 |
};
|
| 1896 |
|
| 1897 |
-
let (arl_opt, token_opt) = try_fetch_arl_for_token(
|
| 1898 |
&access_token,
|
| 1899 |
uid_from_api.as_deref(),
|
| 1900 |
)
|
| 1901 |
.await;
|
| 1902 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1903 |
let mut map = state.pair.write().await;
|
| 1904 |
let Some(sess) = map.get_mut(&code) else {
|
| 1905 |
return json_error(StatusCode::NOT_FOUND, "no such code");
|
|
@@ -2131,6 +2224,10 @@ async fn pair_complete(State(state): State<AppState>, Form(f): Form<PairComplete
|
|
| 2131 |
}
|
| 2132 |
drop(map);
|
| 2133 |
pair_store_save(&state).await;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2134 |
let msg = if login.arl.is_some() {
|
| 2135 |
"<h3>Готово</h3><p>Вернись на ТВ — плагин подключится автоматически.</p>"
|
| 2136 |
} else {
|
|
@@ -2233,7 +2330,7 @@ struct OauthArlReq {
|
|
| 2233 |
access_token: String,
|
| 2234 |
}
|
| 2235 |
|
| 2236 |
-
async fn oauth_arl(Json(req): Json<OauthArlReq>) -> impl IntoResponse {
|
| 2237 |
let token = req.access_token.trim().to_string();
|
| 2238 |
if token.is_empty() {
|
| 2239 |
return json_error(StatusCode::BAD_REQUEST, "access_token required");
|
|
@@ -2272,10 +2369,12 @@ async fn oauth_arl(Json(req): Json<OauthArlReq>) -> impl IntoResponse {
|
|
| 2272 |
.map(|id| id.to_string());
|
| 2273 |
|
| 2274 |
match gw_fetch_arl(&client, "", "", uid.as_deref()).await {
|
| 2275 |
-
Ok((arl,
|
|
|
|
|
|
|
| 2276 |
(StatusCode::OK, Json(json!({ "arl": arl }))).into_response()
|
| 2277 |
}
|
| 2278 |
-
Ok((arl, _, _)) => {
|
| 2279 |
json_error(
|
| 2280 |
StatusCode::SERVICE_UNAVAILABLE,
|
| 2281 |
format!("oauth_arl:arl_too_short:{}", arl.len()),
|
|
@@ -2701,6 +2800,34 @@ async fn oauth_history(Json(req): Json<OauthHistoryReq>) -> impl IntoResponse {
|
|
| 2701 |
}
|
| 2702 |
}
|
| 2703 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2704 |
#[tokio::main]
|
| 2705 |
async fn main() {
|
| 2706 |
let bind_addr = std::env::var("BIND_ADDR").unwrap_or("[::]".to_string());
|
|
@@ -2710,6 +2837,7 @@ async fn main() {
|
|
| 2710 |
let state = AppState {
|
| 2711 |
api: Arc::new(Mutex::new(APIClient::new())),
|
| 2712 |
api_by_arl: Arc::new(RwLock::new(HashMap::new())),
|
|
|
|
| 2713 |
pair: Arc::new(RwLock::new(HashMap::new())),
|
| 2714 |
pair_store_path: std::env::var("PAIR_STORE_PATH").ok().unwrap_or_else(|| "pair_store.json".to_string()),
|
| 2715 |
};
|
|
@@ -2745,6 +2873,7 @@ async fn main() {
|
|
| 2745 |
.route("/oauth/playlists", post(oauth_playlists))
|
| 2746 |
.route("/oauth/playlist_tracks", post(oauth_playlist_tracks))
|
| 2747 |
.route("/oauth/arl", post(oauth_arl))
|
|
|
|
| 2748 |
.route("/oauth/flow", post(oauth_flow))
|
| 2749 |
.route("/oauth/reco_playlists", post(oauth_reco_playlists))
|
| 2750 |
.route("/oauth/reco_albums", post(oauth_reco_albums))
|
|
|
|
| 30 |
mod api;
|
| 31 |
use api::{APIClient, APIError, Format};
|
| 32 |
|
| 33 |
+
#[derive(Clone)]
|
| 34 |
+
struct ArlSession {
|
| 35 |
+
check_form: String,
|
| 36 |
+
license_token: String,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
#[derive(Clone)]
|
| 40 |
struct AppState {
|
| 41 |
api: Arc<Mutex<APIClient>>,
|
| 42 |
api_by_arl: Arc<RwLock<HashMap<String, Arc<Mutex<APIClient>>>>>,
|
| 43 |
+
arl_sessions: Arc<RwLock<HashMap<String, ArlSession>>>,
|
| 44 |
pair: Arc<RwLock<HashMap<String, PairSession>>>,
|
| 45 |
pair_store_path: String,
|
| 46 |
}
|
| 47 |
|
| 48 |
+
fn license_token_from_ud(ud: &serde_json::Value) -> String {
|
| 49 |
+
ud.pointer("/USER/OPTIONS/license_token")
|
| 50 |
+
.and_then(|v| v.as_str())
|
| 51 |
+
.unwrap_or("")
|
| 52 |
+
.trim()
|
| 53 |
+
.to_string()
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async fn store_arl_session(state: &AppState, arl: &str, check_form: &str, license_token: &str) {
|
| 57 |
+
if arl.len() < 20 {
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
let mut map = state.arl_sessions.write().await;
|
| 61 |
+
if map.len() >= 64 {
|
| 62 |
+
map.clear();
|
| 63 |
+
}
|
| 64 |
+
map.insert(
|
| 65 |
+
arl.to_string(),
|
| 66 |
+
ArlSession {
|
| 67 |
+
check_form: check_form.to_string(),
|
| 68 |
+
license_token: license_token.to_string(),
|
| 69 |
+
},
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
fn json_error(status: StatusCode, message: impl Into<String>) -> axum::response::Response {
|
| 74 |
(status, Json(json!({ "error": message.into() }))).into_response()
|
| 75 |
}
|
|
|
|
| 93 |
if map.len() >= 32 {
|
| 94 |
map.clear();
|
| 95 |
}
|
| 96 |
+
let session = state.arl_sessions.read().await.get(&arl).cloned();
|
| 97 |
+
let client = if let Some(s) = session {
|
| 98 |
+
APIClient::new_with_arl_session(arl.clone(), s.check_form, s.license_token)
|
| 99 |
+
} else {
|
| 100 |
+
APIClient::new_with_arl(arl.clone())
|
| 101 |
+
};
|
| 102 |
+
let c = Arc::new(Mutex::new(client));
|
| 103 |
map.insert(arl, c.clone());
|
| 104 |
c
|
| 105 |
}
|
|
|
|
| 177 |
rest.trim().parse::<u64>().ok()
|
| 178 |
}
|
| 179 |
|
| 180 |
+
String::new()
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
fn media_response_err(json: &serde_json::Value) -> String {
|
| 184 |
+
if let Some(errs) = json.get("errors").and_then(|v| v.as_array()) {
|
| 185 |
+
if let Some(first) = errs.first() {
|
| 186 |
+
let s = first.to_string();
|
| 187 |
+
return s.chars().take(160).collect();
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
json.to_string().chars().take(160).collect()
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async fn fetch_media_url(
|
| 194 |
+
client: &APIClient,
|
| 195 |
+
formats: &[Format],
|
| 196 |
+
track_token: &str,
|
| 197 |
+
) -> Result<String, String> {
|
| 198 |
+
if client.license_token.is_empty() {
|
| 199 |
+
return Err("license_token empty".to_string());
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
let media_resp = client
|
| 203 |
+
.get_media(&formats.to_vec(), vec![track_token])
|
| 204 |
+
.await
|
| 205 |
+
.map_err(|e| e.to_string())?;
|
| 206 |
+
|
| 207 |
+
let status = media_resp.status();
|
| 208 |
+
let media_json: serde_json::Value = media_resp
|
| 209 |
+
.json()
|
| 210 |
+
.await
|
| 211 |
+
.map_err(|_| "Bad media response".to_string())?;
|
| 212 |
+
|
| 213 |
+
let url = extract_media_url(&media_json);
|
| 214 |
+
if !url.is_empty() {
|
| 215 |
+
return Ok(url);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
Err(format!(
|
| 219 |
+
"media:{}:{}",
|
| 220 |
+
status.as_u16(),
|
| 221 |
+
media_response_err(&media_json)
|
| 222 |
+
))
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
async fn media_url_for_track(
|
| 226 |
client: &mut APIClient,
|
| 227 |
id: u32,
|
| 228 |
formats: &Vec<Format>,
|
| 229 |
) -> Result<String, String> {
|
| 230 |
+
if client.license_token.is_empty() {
|
| 231 |
+
if let Err(e) = client.force_renew().await {
|
| 232 |
+
return Err(format!("renew:{e}"));
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
let resp: Result<DeezerTrackList, APIError> = client
|
| 237 |
.api_call(
|
| 238 |
"song.getListData",
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
let track_token = track_list.data[0].TRACK_TOKEN.as_str();
|
| 249 |
+
if track_token.is_empty() {
|
| 250 |
+
return Err("empty TRACK_TOKEN".to_string());
|
| 251 |
+
}
|
|
|
|
| 252 |
|
| 253 |
+
let mut last_err = String::from("no_url");
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
+
for fmt in formats {
|
| 256 |
+
match fetch_media_url(client, std::slice::from_ref(fmt), track_token).await {
|
| 257 |
+
Ok(url) => return Ok(url),
|
| 258 |
+
Err(e) => last_err = e,
|
| 259 |
+
}
|
| 260 |
}
|
| 261 |
+
|
| 262 |
+
if formats.len() > 1 {
|
| 263 |
+
if let Ok(url) = fetch_media_url(client, formats, track_token).await {
|
| 264 |
+
return Ok(url);
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
Err(last_err)
|
| 269 |
}
|
| 270 |
|
| 271 |
fn upstream_dzmedia_base() -> Option<String> {
|
|
|
|
| 511 |
state.api_by_arl.write().await.remove(&arl);
|
| 512 |
}
|
| 513 |
let client = api_client_for_arl(&state, Some(arl.clone())).await;
|
| 514 |
+
let res = {
|
| 515 |
let mut c = client.lock().await;
|
| 516 |
+
media_url_for_track(&mut c, q.id, &formats).await
|
| 517 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
match res {
|
| 519 |
+
Ok(u) => { url = u; break 'arl; }
|
| 520 |
Err(e) => { last_err = Some(e); }
|
| 521 |
}
|
| 522 |
}
|
|
|
|
| 603 |
}
|
| 604 |
}
|
| 605 |
|
| 606 |
+
// До 3 попыток: 0 — renew+запрос, 1 — сброс кэша клиента, 2 — ещё раз renew
|
| 607 |
for attempt in 0..3u8 {
|
| 608 |
if url.is_some() { break; }
|
| 609 |
|
| 610 |
+
if attempt >= 1 {
|
|
|
|
| 611 |
if let Some(ref arl) = arl_key {
|
| 612 |
state.api_by_arl.write().await.remove(arl);
|
| 613 |
} else {
|
|
|
|
| 618 |
let client = api_client_for_arl(&state, arl_key.clone()).await;
|
| 619 |
let res = {
|
| 620 |
let mut c = client.lock().await;
|
| 621 |
+
if attempt >= 1 {
|
|
|
|
| 622 |
if let Err(e) = c.force_renew().await {
|
| 623 |
last_err = Some(format!("renew:{}", e));
|
| 624 |
+
continue;
|
| 625 |
}
|
| 626 |
}
|
| 627 |
media_url_for_track(&mut c, q.id, &formats).await
|
|
|
|
| 1103 |
token: String,
|
| 1104 |
uid: String,
|
| 1105 |
access_token: String,
|
| 1106 |
+
license_token: String,
|
| 1107 |
}
|
| 1108 |
|
| 1109 |
fn gw_browser_ua() -> &'static str {
|
|
|
|
| 1176 |
email: &str,
|
| 1177 |
password_md5: &str,
|
| 1178 |
user_id: Option<&str>,
|
| 1179 |
+
) -> Result<(String, String, String, String), String> {
|
| 1180 |
let mut last_err = String::from("getArl:none");
|
| 1181 |
|
| 1182 |
for attempt in 0..3u8 {
|
|
|
|
| 1228 |
Ok(v) => {
|
| 1229 |
let arl = v.as_str().unwrap_or("").to_string();
|
| 1230 |
if arl.len() >= 100 {
|
| 1231 |
+
let license_token = license_token_from_ud(&ud);
|
| 1232 |
+
return Ok((arl, check_form, uid, license_token));
|
| 1233 |
}
|
| 1234 |
last_err = format!("arl_too_short:{}", arl.len());
|
| 1235 |
}
|
|
|
|
| 1261 |
async fn try_fetch_arl_for_token(
|
| 1262 |
access_token: &str,
|
| 1263 |
user_id: Option<&str>,
|
| 1264 |
+
) -> (Option<String>, Option<String>, Option<String>) {
|
| 1265 |
let jar = Arc::new(Jar::default());
|
| 1266 |
let url_deezer = match "https://www.deezer.com".parse::<Url>() {
|
| 1267 |
Ok(u) => u,
|
| 1268 |
+
Err(_) => return (None, None, None),
|
| 1269 |
};
|
| 1270 |
jar.add_cookie_str("comeback=1; Domain=.deezer.com; Path=/", &url_deezer);
|
| 1271 |
|
|
|
|
| 1276 |
.build()
|
| 1277 |
{
|
| 1278 |
Ok(c) => c,
|
| 1279 |
+
Err(_) => return (None, None, None),
|
| 1280 |
};
|
| 1281 |
|
| 1282 |
let _ = client
|
|
|
|
| 1287 |
.await;
|
| 1288 |
|
| 1289 |
if ensure_anonymous_sid(&client, &jar).await.is_err() {
|
| 1290 |
+
return (None, None, None);
|
| 1291 |
}
|
| 1292 |
|
| 1293 |
establish_oauth_session(&client, &jar, access_token).await;
|
|
|
|
| 1304 |
};
|
| 1305 |
|
| 1306 |
match gw_fetch_arl(&client, "", "", uid.as_deref()).await {
|
| 1307 |
+
Ok((arl, token, _, license_token)) if arl.len() >= 20 => {
|
| 1308 |
let gw_token = if token.is_empty() { None } else { Some(token) };
|
| 1309 |
+
let lic = if license_token.is_empty() { None } else { Some(license_token) };
|
| 1310 |
+
(Some(arl), gw_token, lic)
|
| 1311 |
}
|
| 1312 |
+
_ => (None, None, None),
|
| 1313 |
}
|
| 1314 |
}
|
| 1315 |
|
|
|
|
| 1449 |
.filter(|id| *id > 0)
|
| 1450 |
.map(|id| id.to_string());
|
| 1451 |
|
| 1452 |
+
let (arl, token, uid, license_token) = match gw_fetch_arl(
|
| 1453 |
&client,
|
| 1454 |
email,
|
| 1455 |
password_md5,
|
|
|
|
| 1459 |
{
|
| 1460 |
Ok(v) => v,
|
| 1461 |
Err(_e) => {
|
|
|
|
| 1462 |
if let Ok(mobile_arl) = mobile_login(email, password_md5).await {
|
| 1463 |
(
|
| 1464 |
mobile_arl,
|
| 1465 |
String::new(),
|
| 1466 |
uid_from_api.clone().unwrap_or_default(),
|
| 1467 |
+
String::new(),
|
| 1468 |
)
|
| 1469 |
} else {
|
| 1470 |
return Ok(WebLoginResult {
|
|
|
|
| 1472 |
token: String::new(),
|
| 1473 |
uid: uid_from_api.unwrap_or_default(),
|
| 1474 |
access_token,
|
| 1475 |
+
license_token: String::new(),
|
| 1476 |
});
|
| 1477 |
}
|
| 1478 |
}
|
| 1479 |
};
|
| 1480 |
|
| 1481 |
Ok(WebLoginResult {
|
| 1482 |
+
arl: Some(arl.clone()),
|
| 1483 |
token,
|
| 1484 |
uid: if uid.is_empty() {
|
| 1485 |
uid_from_api.unwrap_or_default()
|
|
|
|
| 1487 |
uid
|
| 1488 |
},
|
| 1489 |
access_token,
|
| 1490 |
+
license_token,
|
| 1491 |
})
|
| 1492 |
}
|
| 1493 |
|
|
|
|
| 1602 |
])
|
| 1603 |
.header(ACCEPT, "*/*")
|
| 1604 |
.header("Cache-Control", "max-age=0")
|
|
|
|
| 1605 |
.header("Origin", "https://www.deezer.com")
|
| 1606 |
.header("Referer", "https://www.deezer.com/")
|
| 1607 |
.header("Sec-Fetch-Site", "same-origin")
|
|
|
|
| 1704 |
.query(&qp)
|
| 1705 |
.header(ACCEPT, "*/*")
|
| 1706 |
.header("Cache-Control", "max-age=0")
|
|
|
|
| 1707 |
.header("Origin", "https://www.deezer.com")
|
| 1708 |
.header("Referer", "https://www.deezer.com/")
|
| 1709 |
.header("Sec-Fetch-Site", "same-origin")
|
|
|
|
| 1746 |
s.split(';').any(|p| p.trim_start().starts_with(cookie_prefix))
|
| 1747 |
}
|
| 1748 |
|
| 1749 |
+
async fn login(State(state): State<AppState>, Json(req): Json<LoginReq>) -> impl IntoResponse {
|
| 1750 |
let email = req.email.trim().to_string();
|
| 1751 |
let pass = req.password_md5.trim().to_string();
|
| 1752 |
|
|
|
|
| 1755 |
}
|
| 1756 |
|
| 1757 |
match web_login(&email, &pass).await {
|
| 1758 |
+
Ok(res) => {
|
| 1759 |
+
if let Some(ref arl) = res.arl {
|
| 1760 |
+
store_arl_session(&state, arl, &res.token, &res.license_token).await;
|
| 1761 |
+
state.api_by_arl.write().await.remove(arl);
|
| 1762 |
+
}
|
| 1763 |
+
(StatusCode::OK, Json(json!({
|
| 1764 |
+
"arl": res.arl,
|
| 1765 |
+
"token": res.token,
|
| 1766 |
+
"uid": res.uid,
|
| 1767 |
+
"access_token": res.access_token
|
| 1768 |
+
}))).into_response()
|
| 1769 |
+
}
|
| 1770 |
Err(e) => json_error(
|
| 1771 |
StatusCode::UNAUTHORIZED,
|
| 1772 |
format!("login_failed; {e}"),
|
|
|
|
| 1979 |
Some(user_id.clone())
|
| 1980 |
};
|
| 1981 |
|
| 1982 |
+
let (arl_opt, token_opt, license_opt) = try_fetch_arl_for_token(
|
| 1983 |
&access_token,
|
| 1984 |
uid_from_api.as_deref(),
|
| 1985 |
)
|
| 1986 |
.await;
|
| 1987 |
|
| 1988 |
+
if let (Some(ref arl), Some(ref token), Some(ref lic)) = (&arl_opt, &token_opt, &license_opt) {
|
| 1989 |
+
store_arl_session(&state, arl, token, lic).await;
|
| 1990 |
+
state.api_by_arl.write().await.remove(arl);
|
| 1991 |
+
} else if let (Some(ref arl), Some(ref token)) = (&arl_opt, &token_opt) {
|
| 1992 |
+
store_arl_session(&state, arl, token, "").await;
|
| 1993 |
+
state.api_by_arl.write().await.remove(arl);
|
| 1994 |
+
}
|
| 1995 |
+
|
| 1996 |
let mut map = state.pair.write().await;
|
| 1997 |
let Some(sess) = map.get_mut(&code) else {
|
| 1998 |
return json_error(StatusCode::NOT_FOUND, "no such code");
|
|
|
|
| 2224 |
}
|
| 2225 |
drop(map);
|
| 2226 |
pair_store_save(&state).await;
|
| 2227 |
+
if let Some(ref arl) = login.arl {
|
| 2228 |
+
store_arl_session(&state, arl, &login.token, &login.license_token).await;
|
| 2229 |
+
state.api_by_arl.write().await.remove(arl);
|
| 2230 |
+
}
|
| 2231 |
let msg = if login.arl.is_some() {
|
| 2232 |
"<h3>Готово</h3><p>Вернись на ТВ — плагин подключится автоматически.</p>"
|
| 2233 |
} else {
|
|
|
|
| 2330 |
access_token: String,
|
| 2331 |
}
|
| 2332 |
|
| 2333 |
+
async fn oauth_arl(State(state): State<AppState>, Json(req): Json<OauthArlReq>) -> impl IntoResponse {
|
| 2334 |
let token = req.access_token.trim().to_string();
|
| 2335 |
if token.is_empty() {
|
| 2336 |
return json_error(StatusCode::BAD_REQUEST, "access_token required");
|
|
|
|
| 2369 |
.map(|id| id.to_string());
|
| 2370 |
|
| 2371 |
match gw_fetch_arl(&client, "", "", uid.as_deref()).await {
|
| 2372 |
+
Ok((arl, check_form, _, license_token)) if arl.len() >= 20 => {
|
| 2373 |
+
store_arl_session(&state, &arl, &check_form, &license_token).await;
|
| 2374 |
+
state.api_by_arl.write().await.remove(&arl);
|
| 2375 |
(StatusCode::OK, Json(json!({ "arl": arl }))).into_response()
|
| 2376 |
}
|
| 2377 |
+
Ok((arl, _, _, _)) => {
|
| 2378 |
json_error(
|
| 2379 |
StatusCode::SERVICE_UNAVAILABLE,
|
| 2380 |
format!("oauth_arl:arl_too_short:{}", arl.len()),
|
|
|
|
| 2800 |
}
|
| 2801 |
}
|
| 2802 |
|
| 2803 |
+
#[derive(Debug, Deserialize)]
|
| 2804 |
+
struct WarmArlReq {
|
| 2805 |
+
arl: String,
|
| 2806 |
+
}
|
| 2807 |
+
|
| 2808 |
+
async fn warm_arl(State(state): State<AppState>, Json(req): Json<WarmArlReq>) -> impl IntoResponse {
|
| 2809 |
+
let arl = req.arl.trim().to_string();
|
| 2810 |
+
if arl.len() < 20 {
|
| 2811 |
+
return json_error(StatusCode::BAD_REQUEST, "arl required");
|
| 2812 |
+
}
|
| 2813 |
+
|
| 2814 |
+
state.api_by_arl.write().await.remove(&arl);
|
| 2815 |
+
let client = api_client_for_arl(&state, Some(arl.clone())).await;
|
| 2816 |
+
let session = {
|
| 2817 |
+
let mut c = client.lock().await;
|
| 2818 |
+
if c.license_token.is_empty() {
|
| 2819 |
+
if let Err(e) = c.force_renew().await {
|
| 2820 |
+
return json_error(StatusCode::SERVICE_UNAVAILABLE, format!("warm:renew:{e}"));
|
| 2821 |
+
}
|
| 2822 |
+
}
|
| 2823 |
+
(c.check_form.clone(), c.license_token.clone())
|
| 2824 |
+
};
|
| 2825 |
+
|
| 2826 |
+
store_arl_session(&state, &arl, &session.0, &session.1).await;
|
| 2827 |
+
state.api_by_arl.write().await.remove(&arl);
|
| 2828 |
+
(StatusCode::OK, Json(json!({ "ok": true }))).into_response()
|
| 2829 |
+
}
|
| 2830 |
+
|
| 2831 |
#[tokio::main]
|
| 2832 |
async fn main() {
|
| 2833 |
let bind_addr = std::env::var("BIND_ADDR").unwrap_or("[::]".to_string());
|
|
|
|
| 2837 |
let state = AppState {
|
| 2838 |
api: Arc::new(Mutex::new(APIClient::new())),
|
| 2839 |
api_by_arl: Arc::new(RwLock::new(HashMap::new())),
|
| 2840 |
+
arl_sessions: Arc::new(RwLock::new(HashMap::new())),
|
| 2841 |
pair: Arc::new(RwLock::new(HashMap::new())),
|
| 2842 |
pair_store_path: std::env::var("PAIR_STORE_PATH").ok().unwrap_or_else(|| "pair_store.json".to_string()),
|
| 2843 |
};
|
|
|
|
| 2873 |
.route("/oauth/playlists", post(oauth_playlists))
|
| 2874 |
.route("/oauth/playlist_tracks", post(oauth_playlist_tracks))
|
| 2875 |
.route("/oauth/arl", post(oauth_arl))
|
| 2876 |
+
.route("/session/warm", post(warm_arl))
|
| 2877 |
.route("/oauth/flow", post(oauth_flow))
|
| 2878 |
.route("/oauth/reco_playlists", post(oauth_reco_playlists))
|
| 2879 |
.route("/oauth/reco_albums", post(oauth_reco_albums))
|