Spaces:
Paused
Paused
Upload 2 files
Browse files- src/main.rs +144 -7
src/main.rs
CHANGED
|
@@ -123,6 +123,55 @@ async fn media_url_for_track(
|
|
| 123 |
Ok(url)
|
| 124 |
}
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
#[derive(Deserialize)]
|
| 127 |
struct DeezerTrackList {
|
| 128 |
data: Vec<DeezerTrack>
|
|
@@ -153,6 +202,13 @@ async fn get_url(State(state): State<AppState>, Json(req): Json<RequestParams>)
|
|
| 153 |
return (StatusCode::BAD_REQUEST, "ID list cannot be empty".to_string());
|
| 154 |
}
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
let client = api_client_for_arl(&state, req.arl.clone()).await;
|
| 157 |
let media_resp = {
|
| 158 |
let mut client = client.lock().await;
|
|
@@ -277,7 +333,18 @@ async fn stream(State(state): State<AppState>, Query(q): Query<StreamParams>) ->
|
|
| 277 |
|
| 278 |
let mut url: Option<String> = None;
|
| 279 |
let mut last_err: Option<String> = None;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
for attempt in 0..2 {
|
|
|
|
|
|
|
|
|
|
| 281 |
let client = api_client_for_arl(&state, arl_key.clone()).await;
|
| 282 |
let res = {
|
| 283 |
let mut client = client.lock().await;
|
|
@@ -304,12 +371,27 @@ async fn stream(State(state): State<AppState>, Query(q): Query<StreamParams>) ->
|
|
| 304 |
}
|
| 305 |
}
|
| 306 |
|
| 307 |
-
let
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
};
|
| 314 |
|
| 315 |
let http = reqwest::Client::builder()
|
|
@@ -829,6 +911,7 @@ async fn pair_page(Query(q): Query<PairPageQuery>) -> impl IntoResponse {
|
|
| 829 |
.box{{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}}\
|
| 830 |
input{{width:100%;padding:12px 14px;margin:10px 0;border-radius:10px;border:1px solid #333;background:#0f0f0f;color:#fff;font-size:16px}}\
|
| 831 |
button{{width:100%;padding:12px 14px;border-radius:10px;border:0;background:#1db954;color:#000;font-weight:700;font-size:16px}}\
|
|
|
|
| 832 |
.muted{{color:#aaa;font-size:13px;line-height:1.35}}</style>\
|
| 833 |
</head><body><div class=\"box\">\
|
| 834 |
<h2 style=\"margin:0 0 6px\">Вход Deezer</h2>\
|
|
@@ -840,6 +923,14 @@ async fn pair_page(Query(q): Query<PairPageQuery>) -> impl IntoResponse {
|
|
| 840 |
<button type=\"submit\">Войти</button>\
|
| 841 |
</form>\
|
| 842 |
<p class=\"muted\" style=\"margin:12px 0 0\">Пароль используется только для по��учения токена доступа и не сохраняется.</p>\
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 843 |
</div></body></html>"
|
| 844 |
);
|
| 845 |
Html(html)
|
|
@@ -852,6 +943,12 @@ struct PairCompleteForm {
|
|
| 852 |
password: String,
|
| 853 |
}
|
| 854 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
fn htmlesc(s: &str) -> String {
|
| 856 |
s.replace('&', "&")
|
| 857 |
.replace('<', "<")
|
|
@@ -860,6 +957,27 @@ fn htmlesc(s: &str) -> String {
|
|
| 860 |
.replace('\'', "'")
|
| 861 |
}
|
| 862 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
async fn api_deezer_get(
|
| 864 |
client: &reqwest::Client,
|
| 865 |
path: &str,
|
|
@@ -999,7 +1117,25 @@ async fn pair_complete(State(state): State<AppState>, Form(f): Form<PairComplete
|
|
| 999 |
}
|
| 1000 |
}
|
| 1001 |
|
| 1002 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1003 |
}
|
| 1004 |
|
| 1005 |
#[derive(Debug, Deserialize)]
|
|
@@ -1226,6 +1362,7 @@ async fn main() {
|
|
| 1226 |
.route("/pair/status", get(pair_status))
|
| 1227 |
.route("/pair", get(pair_page))
|
| 1228 |
.route("/pair/complete", post(pair_complete))
|
|
|
|
| 1229 |
.route("/oauth/me", post(oauth_me))
|
| 1230 |
.route("/oauth/playlists", post(oauth_playlists))
|
| 1231 |
.route("/oauth/playlist_tracks", post(oauth_playlist_tracks))
|
|
|
|
| 123 |
Ok(url)
|
| 124 |
}
|
| 125 |
|
| 126 |
+
fn upstream_dzmedia_base() -> Option<String> {
|
| 127 |
+
let env = std::env::var("DZMEDIA_UPSTREAM").ok().unwrap_or_default();
|
| 128 |
+
let base = env.trim();
|
| 129 |
+
if base.is_empty() {
|
| 130 |
+
return Some("https://lufts-dzmedia.fly.dev".to_string());
|
| 131 |
+
}
|
| 132 |
+
if base == "-" {
|
| 133 |
+
return None;
|
| 134 |
+
}
|
| 135 |
+
Some(base.trim_end_matches('/').to_string())
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
async fn upstream_get_url_text(formats: &Vec<Format>, ids: &Vec<u32>) -> Result<String, String> {
|
| 139 |
+
let Some(base) = upstream_dzmedia_base() else {
|
| 140 |
+
return Err("upstream:disabled".to_string());
|
| 141 |
+
};
|
| 142 |
+
let url = format!("{base}/get_url");
|
| 143 |
+
|
| 144 |
+
let client = reqwest::Client::builder()
|
| 145 |
+
.timeout(std::time::Duration::from_secs(20))
|
| 146 |
+
.build()
|
| 147 |
+
.map_err(|_| "upstream:client".to_string())?;
|
| 148 |
+
|
| 149 |
+
let r = client
|
| 150 |
+
.post(url)
|
| 151 |
+
.json(&json!({ "formats": formats, "ids": ids }))
|
| 152 |
+
.send()
|
| 153 |
+
.await
|
| 154 |
+
.map_err(|_| "upstream:network".to_string())?;
|
| 155 |
+
|
| 156 |
+
let status = r.status();
|
| 157 |
+
let text = r.text().await.map_err(|_| "upstream:read".to_string())?;
|
| 158 |
+
if !status.is_success() {
|
| 159 |
+
let snip: String = text.chars().take(200).collect();
|
| 160 |
+
return Err(format!("upstream:{}:{}", status.as_u16(), snip));
|
| 161 |
+
}
|
| 162 |
+
Ok(text)
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async fn upstream_media_url(id: u32, formats: &Vec<Format>) -> Result<String, String> {
|
| 166 |
+
let text = upstream_get_url_text(formats, &vec![id]).await?;
|
| 167 |
+
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "upstream:parse".to_string())?;
|
| 168 |
+
let url = extract_media_url(&v);
|
| 169 |
+
if url.is_empty() {
|
| 170 |
+
return Err("upstream:no_url".to_string());
|
| 171 |
+
}
|
| 172 |
+
Ok(url)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
#[derive(Deserialize)]
|
| 176 |
struct DeezerTrackList {
|
| 177 |
data: Vec<DeezerTrack>
|
|
|
|
| 202 |
return (StatusCode::BAD_REQUEST, "ID list cannot be empty".to_string());
|
| 203 |
}
|
| 204 |
|
| 205 |
+
let arl = req.arl.clone().unwrap_or_default().trim().to_string();
|
| 206 |
+
if arl.is_empty() {
|
| 207 |
+
if let Ok(t) = upstream_get_url_text(&req.formats, &req.ids).await {
|
| 208 |
+
return (StatusCode::OK, t);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
let client = api_client_for_arl(&state, req.arl.clone()).await;
|
| 213 |
let media_resp = {
|
| 214 |
let mut client = client.lock().await;
|
|
|
|
| 333 |
|
| 334 |
let mut url: Option<String> = None;
|
| 335 |
let mut last_err: Option<String> = None;
|
| 336 |
+
|
| 337 |
+
if arl_key.is_none() {
|
| 338 |
+
match upstream_media_url(q.id, &formats).await {
|
| 339 |
+
Ok(u) => url = Some(u),
|
| 340 |
+
Err(e) => last_err = Some(e),
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
for attempt in 0..2 {
|
| 345 |
+
if url.is_some() {
|
| 346 |
+
break;
|
| 347 |
+
}
|
| 348 |
let client = api_client_for_arl(&state, arl_key.clone()).await;
|
| 349 |
let res = {
|
| 350 |
let mut client = client.lock().await;
|
|
|
|
| 371 |
}
|
| 372 |
}
|
| 373 |
|
| 374 |
+
let url = match url {
|
| 375 |
+
Some(u) => u,
|
| 376 |
+
None => {
|
| 377 |
+
if arl_key.is_some() {
|
| 378 |
+
if let Ok(u) = upstream_media_url(q.id, &formats).await {
|
| 379 |
+
u
|
| 380 |
+
} else {
|
| 381 |
+
return (
|
| 382 |
+
StatusCode::SERVICE_UNAVAILABLE,
|
| 383 |
+
last_err.unwrap_or_else(|| "stream:error".to_string()),
|
| 384 |
+
)
|
| 385 |
+
.into_response();
|
| 386 |
+
}
|
| 387 |
+
} else {
|
| 388 |
+
return (
|
| 389 |
+
StatusCode::SERVICE_UNAVAILABLE,
|
| 390 |
+
last_err.unwrap_or_else(|| "stream:error".to_string()),
|
| 391 |
+
)
|
| 392 |
+
.into_response();
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
};
|
| 396 |
|
| 397 |
let http = reqwest::Client::builder()
|
|
|
|
| 911 |
.box{{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}}\
|
| 912 |
input{{width:100%;padding:12px 14px;margin:10px 0;border-radius:10px;border:1px solid #333;background:#0f0f0f;color:#fff;font-size:16px}}\
|
| 913 |
button{{width:100%;padding:12px 14px;border-radius:10px;border:0;background:#1db954;color:#000;font-weight:700;font-size:16px}}\
|
| 914 |
+
.secondary{{background:#2b2b2b;color:#fff}}\
|
| 915 |
.muted{{color:#aaa;font-size:13px;line-height:1.35}}</style>\
|
| 916 |
</head><body><div class=\"box\">\
|
| 917 |
<h2 style=\"margin:0 0 6px\">Вход Deezer</h2>\
|
|
|
|
| 923 |
<button type=\"submit\">Войти</button>\
|
| 924 |
</form>\
|
| 925 |
<p class=\"muted\" style=\"margin:12px 0 0\">Пароль используется только для по��учения токена доступа и не сохраняется.</p>\
|
| 926 |
+
<hr style=\"border:0;border-top:1px solid #2a2a2a;margin:16px 0\">\
|
| 927 |
+
<h3 style=\"margin:0 0 6px;font-size:16px\">ARL (для полного аудио)</h3>\
|
| 928 |
+
<div class=\"muted\">Если сервер не может автоматически получить ARL (ошибка 503/502), вставь ARL сюда — с телефона это быстрее, чем на ТВ.</div>\
|
| 929 |
+
<form method=\"POST\" action=\"/pair/arl\">\
|
| 930 |
+
<input type=\"hidden\" name=\"code\" value=\"{safe_code}\">\
|
| 931 |
+
<input name=\"arl\" type=\"text\" placeholder=\"arl=...\" autocomplete=\"off\">\
|
| 932 |
+
<button class=\"secondary\" type=\"submit\">Отправить ARL</button>\
|
| 933 |
+
</form>\
|
| 934 |
</div></body></html>"
|
| 935 |
);
|
| 936 |
Html(html)
|
|
|
|
| 943 |
password: String,
|
| 944 |
}
|
| 945 |
|
| 946 |
+
#[derive(Deserialize)]
|
| 947 |
+
struct PairArlForm {
|
| 948 |
+
code: String,
|
| 949 |
+
arl: String,
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
fn htmlesc(s: &str) -> String {
|
| 953 |
s.replace('&', "&")
|
| 954 |
.replace('<', "<")
|
|
|
|
| 957 |
.replace('\'', "'")
|
| 958 |
}
|
| 959 |
|
| 960 |
+
async fn pair_arl(State(state): State<AppState>, Form(f): Form<PairArlForm>) -> impl IntoResponse {
|
| 961 |
+
let code = f.code.trim().to_string();
|
| 962 |
+
let arl = f.arl.trim().to_string();
|
| 963 |
+
if code.is_empty() {
|
| 964 |
+
return Html("<h3>Нужен code</h3>".to_string()).into_response();
|
| 965 |
+
}
|
| 966 |
+
if arl.len() < 20 {
|
| 967 |
+
return Html("<h3>ARL выглядит неверно</h3>".to_string()).into_response();
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
let mut map = state.pair.write().await;
|
| 971 |
+
let Some(sess) = map.get_mut(&code) else {
|
| 972 |
+
return Html("<h3>Код не найден или истёк</h3>".to_string()).into_response();
|
| 973 |
+
};
|
| 974 |
+
sess.arl = Some(arl);
|
| 975 |
+
sess.error = None;
|
| 976 |
+
|
| 977 |
+
Html("<h3>ARL сохранён</h3><p>Вернись на ТВ — треки должны стать полными.</p>".to_string())
|
| 978 |
+
.into_response()
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
async fn api_deezer_get(
|
| 982 |
client: &reqwest::Client,
|
| 983 |
path: &str,
|
|
|
|
| 1117 |
}
|
| 1118 |
}
|
| 1119 |
|
| 1120 |
+
let map = state.pair.read().await;
|
| 1121 |
+
let sess_arl = map.get(&code).and_then(|s| s.arl.clone()).unwrap_or_default();
|
| 1122 |
+
if sess_arl.len() > 20 {
|
| 1123 |
+
return Html("<h3>Готово</h3><p>Вернись на ТВ — плагин подключится автоматически.</p>".to_string())
|
| 1124 |
+
.into_response();
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
let safe_code = htmlesc(&code);
|
| 1128 |
+
Html(format!(
|
| 1129 |
+
"<h3>Вход выполнен</h3>\
|
| 1130 |
+
<p>Токен получен, но ARL сервер автоматически не вытащил (из‑за блокировки/ошибок gw-light на хостинге).</p>\
|
| 1131 |
+
<p>Чтобы включить полные треки, вставь ARL (с телефона):</p>\
|
| 1132 |
+
<form method=\"POST\" action=\"/pair/arl\">\
|
| 1133 |
+
<input type=\"hidden\" name=\"code\" value=\"{safe_code}\">\
|
| 1134 |
+
<input name=\"arl\" type=\"text\" placeholder=\"arl=...\" autocomplete=\"off\" style=\"width:100%;padding:12px 14px;border-radius:10px;border:1px solid #333;background:#0f0f0f;color:#fff;font-size:16px\">\
|
| 1135 |
+
<button type=\"submit\" style=\"margin-top:10px;width:100%;padding:12px 14px;border-radius:10px;border:0;background:#2b2b2b;color:#fff;font-weight:700;font-size:16px\">Отправить ARL</button>\
|
| 1136 |
+
</form>"
|
| 1137 |
+
))
|
| 1138 |
+
.into_response()
|
| 1139 |
}
|
| 1140 |
|
| 1141 |
#[derive(Debug, Deserialize)]
|
|
|
|
| 1362 |
.route("/pair/status", get(pair_status))
|
| 1363 |
.route("/pair", get(pair_page))
|
| 1364 |
.route("/pair/complete", post(pair_complete))
|
| 1365 |
+
.route("/pair/arl", post(pair_arl))
|
| 1366 |
.route("/oauth/me", post(oauth_me))
|
| 1367 |
.route("/oauth/playlists", post(oauth_playlists))
|
| 1368 |
.route("/oauth/playlist_tracks", post(oauth_playlist_tracks))
|