调整,音乐模块
This commit is contained in:
@@ -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}×tamp={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)
|
||||
}
|
||||
Reference in New Issue
Block a user