164 lines
5.1 KiB
Rust
164 lines
5.1 KiB
Rust
//! 飞牛音乐本地流代理(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
|
||
} |