调整,音乐模块

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
+166
View File
@@ -0,0 +1,166 @@
//! FnConnect 远程连接解析(参考 feiniu-car-music `fn-api.js`)。
//!
//! fnId → 网关 `https://5ddd.com/api/v1/fn/con`authx md5 签名)→ 内网/公网/中继候选 →
//! 探测可达性 → 得到可用的 base_url(含 mode=relay 的中继地址)。
use md5::Md5;
use rand::RngCore;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
/// 网关地址与签名常量(对齐 feiniu-car-music)。
const FN_CONNECT_URL: &str = "https://5ddd.com/api/v1/fn/con";
const FN_AUTHX_PREFIX: &str = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh";
const FN_API_KEY: &str = "zIGtkc3dqZnJpd29qZXJqa2w7c";
fn md5_hex(input: &str) -> String {
let mut h = Md5::new();
h.update(input.as_bytes());
format!("{:x}", h.finalize())
}
fn sha256_hex(input: &str) -> String {
let mut h = Sha256::new();
h.update(input.as_bytes());
format!("{:x}", h.finalize())
}
/// 从输入识别 fnId`fnos.net/<id>`、`<id>.5ddd.com`、或裸 fnId。
pub fn extract_fn_id(input: &str) -> Option<String> {
let s = input.trim();
if s.is_empty() {
return None;
}
if let Some(id) = s.split_once("fnos.net/").map(|(_, r)| r.split('/').next().unwrap_or("")) {
if !id.is_empty() {
return Some(id.trim().to_string());
}
}
if let Some(rest) = s.rsplit_once("/") {
let last = rest.1;
if last.ends_with(".5ddd.com") {
return Some(last.trim_end_matches(".5ddd.com").to_string());
}
}
if s.ends_with(".5ddd.com") {
return Some(s.trim_end_matches(".5ddd.com").to_string());
}
// 裸 fnId
if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && !s.starts_with("http") {
return Some(s.to_string());
}
None
}
/// 计算网关 authx 签名。
fn fn_authx(method: &str, url: &str, data: &Value) -> String {
let body = if method.eq_ignore_ascii_case("get") {
String::new()
} else {
serde_json::to_string(data).unwrap_or_default()
};
let mut nonce = String::new();
let mut rng = rand::thread_rng();
for _ in 0..6 {
nonce.push(char::from(b'0' + (rng.next_u32() % 10) as u8));
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_default();
let raw = format!(
"{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}__{FN_API_KEY}",
md5_hex(&body)
);
format!("nonce={nonce}&timestamp={timestamp}&sign={}", md5_hex(&raw))
}
/// 从网关查询 fnId 的连接参数。
pub async fn query_fn_connect(fn_id: &str) -> Result<Value, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let body = json!({ "fnId": fn_id });
let resp = client
.post(FN_CONNECT_URL)
.header("Content-Type", "application/json")
.header("authx", fn_authx("post", "/api/v1/fn/con", &body))
.json(&body)
.send()
.await
.map_err(|e| format!("FnConnect 网关不可达: {e}"))?;
let b: Value = resp.json().await.map_err(|e| e.to_string())?;
if b["code"].as_i64().unwrap_or(-1) != 0 {
return Err(b["msg"].as_str().unwrap_or("FnConnect 网关返回错误").to_string());
}
Ok(b["data"].clone())
}
/// 构建候选 base_url 列表。返回 (url, is_relay)。
pub fn build_candidates(data: &Value) -> Vec<(String, bool)> {
let mut out: Vec<(String, bool)> = Vec::new();
let port = &data["port"];
let http = port["httpPort"].as_u64().unwrap_or(5666);
let https = port["httpsPort"].as_u64().unwrap_or(5667);
let empty = vec![];
for ip in data["ipv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
out.push((format!("http://{ip}:{http}"), false));
out.push((format!("https://{ip}:{https}"), false));
}
for ip in data["publicIpv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
out.push((format!("http://{ip}:{http}"), false));
out.push((format!("https://{ip}:{https}"), false));
}
for ip in data["publicIpv6"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
out.push((format!("http://[{ip}]:{http}"), false));
out.push((format!("https://[{ip}]:{https}"), false));
}
let relays = data["fn"].as_array().unwrap_or(&empty);
let relay_addrs: Vec<String> = if relays.is_empty() {
vec!["5ddd.com".to_string()]
} else {
relays
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
};
for addr in relay_addrs {
let domain = addr.split(':').next().unwrap_or(&addr).to_string();
out.push((format!("https://{domain}"), true));
}
out
}
/// 探测某个 base_url 是否可用。
async fn probe(url: &str) -> bool {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(6))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let full = format!("{}/music/api/v1/track/list?page=1&size=1", url.trim_end_matches('/'));
match client.get(&full).send().await {
Ok(resp) => resp.status().as_u16() < 500,
Err(_) => false,
}
}
/// 解析 fnId → 第一个可达的 base_url;返回 (base_url, is_relay)。
pub async fn resolve_base_url(fn_id: &str) -> Result<(String, bool), String> {
let data = query_fn_connect(fn_id).await?;
let candidates = build_candidates(&data);
if candidates.is_empty() {
return Err("FnConnect 未返回可用地址".into());
}
for (url, relay) in &candidates {
if probe(url).await {
return Ok((url.clone(), *relay));
}
}
Err(format!("FnConnect 候选均不可达({} 个)", candidates.len()))
}
/// sha256 hex(登录用)。
pub fn sha256_hex_pub(input: &str) -> String {
sha256_hex(input)
}