调整,音乐模块

This commit is contained in:
zhongluofeng
2026-09-12 11:05:26 +08:00
parent 27ad5d89a5
commit d702ed0d31
71 changed files with 13647 additions and 387 deletions
+164
View File
@@ -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
}