调整,音乐模块
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
//! 飞牛音乐本地流代理(axum)。
|
||||
//!
|
||||
//! 原生 `/track/stream`、`/static/cover` 需要 `Cookie: music-token=<token>`,而
|
||||
//! WebView 的 `<audio>/<img>` 无法设置 Cookie。本模块绑定 `127.0.0.1:<动态端口>`,
|
||||
//! 转发请求时注入 Cookie 与可选的安全码头,前端直接用本地地址播放/显示,天然支持 Range/seek。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
use super::conn::normalize_base_url;
|
||||
|
||||
/// 由 Feiniu 运行期与代理 handler 共享的连接配置(登录更新、登出置空)。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ProxyCfg {
|
||||
pub base_url: String,
|
||||
pub token: String,
|
||||
pub access_code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyShared {
|
||||
pub client: reqwest::Client,
|
||||
pub cfg: Arc<Mutex<ProxyCfg>>,
|
||||
}
|
||||
|
||||
/// 启动本地流代理,绑定到 `127.0.0.1:0`(动态空闲端口),返回实际端口。
|
||||
/// 代理以独立 tokio 任务常驻应用存活期;token 变化经共享 `ProxyCfg` 即时生效,无需重启。
|
||||
pub async fn start(shared: ProxyShared) -> Result<u16, String> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("绑定本地端口失败: {e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| e.to_string())?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.route("/feiniu/stream", get(proxy_stream))
|
||||
.route("/feiniu/cover", get(proxy_cover))
|
||||
.with_state(shared);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
crate::logger::log_error("music", &format!("飞牛流代理异常: {e}"));
|
||||
}
|
||||
});
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn proxy_stream(
|
||||
State(s): State<ProxyShared>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let guid = q.get("guid").cloned().unwrap_or_default().trim().to_string();
|
||||
if guid.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "missing guid").into_response();
|
||||
}
|
||||
forward(
|
||||
&s,
|
||||
"/music/api/v1/track/stream",
|
||||
vec![("guid".to_string(), guid)],
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn proxy_cover(
|
||||
State(s): State<ProxyShared>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let cover_id = q
|
||||
.get("coverId")
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if cover_id.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "missing coverId").into_response();
|
||||
}
|
||||
let size = q.get("size").cloned().unwrap_or_else(|| "320".into());
|
||||
forward(
|
||||
&s,
|
||||
"/music/api/v1/static/cover",
|
||||
vec![
|
||||
("coverId".to_string(), cover_id),
|
||||
("size".to_string(), size),
|
||||
],
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 统一转发:向 NAS 发起上游 GET,注入 Cookie/安全码,透传 Range 与响应头,流式回传 body。
|
||||
async fn forward(
|
||||
s: &ProxyShared,
|
||||
path: &str,
|
||||
query: Vec<(String, String)>,
|
||||
headers: &HeaderMap,
|
||||
) -> Response {
|
||||
let cfg = match s.cfg.lock() {
|
||||
Ok(g) => g.clone(),
|
||||
Err(e) => e.into_inner().clone(),
|
||||
};
|
||||
if cfg.base_url.is_empty() || cfg.token.is_empty() {
|
||||
return (StatusCode::UNAUTHORIZED, "飞牛音乐未登录").into_response();
|
||||
}
|
||||
|
||||
let base = normalize_base_url(&cfg.base_url);
|
||||
let url = format!("{base}{path}");
|
||||
let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
|
||||
let mut rb = s.client.get(&url).query(&qrefs);
|
||||
rb = rb.header("cookie", format!("music-token={}", cfg.token));
|
||||
if !cfg.access_code.is_empty() {
|
||||
use base64::Engine;
|
||||
rb = rb
|
||||
.header(
|
||||
"x-access-code",
|
||||
base64::engine::general_purpose::STANDARD.encode(cfg.access_code.as_bytes()),
|
||||
)
|
||||
.header("x-access-source", "app");
|
||||
}
|
||||
if let Some(range) = headers.get(header::RANGE) {
|
||||
rb = rb.header(header::RANGE, range.clone());
|
||||
}
|
||||
drop(cfg);
|
||||
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("上游错误: {e}")).into_response(),
|
||||
};
|
||||
let status = resp.status();
|
||||
|
||||
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
|
||||
let cl = resp.headers().get(header::CONTENT_LENGTH).cloned();
|
||||
let cr = resp.headers().get(header::CONTENT_RANGE).cloned();
|
||||
let ar = resp.headers().get(header::ACCEPT_RANGES).cloned();
|
||||
|
||||
let body = axum::body::Body::from_stream(resp.bytes_stream());
|
||||
let mut out = Response::new(body);
|
||||
*out.status_mut() = status;
|
||||
let h = out.headers_mut();
|
||||
if let Some(v) = ct {
|
||||
h.insert(header::CONTENT_TYPE, v);
|
||||
}
|
||||
if let Some(v) = cl {
|
||||
h.insert(header::CONTENT_LENGTH, v);
|
||||
}
|
||||
if let Some(v) = cr {
|
||||
h.insert(header::CONTENT_RANGE, v);
|
||||
}
|
||||
if let Some(v) = ar {
|
||||
h.insert(header::ACCEPT_RANGES, v);
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user