diff --git a/app/ui/plugins/douyu.tsx b/app/ui/plugins/douyu.tsx index a61b66c..6d7d0a1 100644 --- a/app/ui/plugins/douyu.tsx +++ b/app/ui/plugins/douyu.tsx @@ -78,6 +78,15 @@ tctc-h5(线路4), tct-h5(线路5), ali-h5(线路6), hw-h5(线路7 线路7(hw-h5) 线路13(hs-h5) + , + /// 斗鱼强制 hs 流使用构造链接 + #[serde(default)] + pub douyu_force_hs: Option, /// 斗鱼弹幕录制 #[serde(default)] pub douyu_danmaku: Option, diff --git a/crates/biliup-cli/src/server/core/live.rs b/crates/biliup-cli/src/server/core/live.rs index 13a6f09..fbacb29 100644 --- a/crates/biliup-cli/src/server/core/live.rs +++ b/crates/biliup-cli/src/server/core/live.rs @@ -71,6 +71,7 @@ fn live_options(config: &Config) -> LiveOptions { .douyu_cdn .clone() .unwrap_or_else(|| "hw-h5".to_string()), + force_hs: config.douyu_force_hs.unwrap_or(false), rate: config.douyu_rate.unwrap_or(0), disable_interactive_game: config.douyu_disable_interactive_game.unwrap_or(false), danmaku: config.douyu_danmaku.unwrap_or(false), diff --git a/crates/biliup/src/downloader/live/douyu.rs b/crates/biliup/src/downloader/live/douyu.rs index c32b68d..ebc60ae 100644 --- a/crates/biliup/src/downloader/live/douyu.rs +++ b/crates/biliup/src/downloader/live/douyu.rs @@ -5,17 +5,23 @@ use super::{ use async_trait::async_trait; use chrono::Utc; use md5::{Digest, Md5}; +use rand::seq::SliceRandom; use regex::Regex; use reqwest::Client; use serde::Deserialize; use serde_json::Value; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; -use url::Url; +use tracing::warn; +use url::{Url, form_urlencoded}; const DOUYU_DEFAULT_DID: &str = "10000000000000000000000000001501"; const DOUYU_WEB_DOMAIN: &str = "www.douyu.com"; const DOUYU_MOBILE_DOMAIN: &str = "m.douyu.com"; +const DOUYU_HUOS_DOMAIN: &str = "openflv-huos.douyucdn2.cn"; +const DOUYU_HS_CDN: &str = "hs-h5"; +const DOUYU_P2P_DOMAIN_TCT: &str = "hdltctwk.douyucdn.cn"; +const DOUYU_P2PSDK_APIS: [&str; 2] = ["https://sdkapiv4.douyucdn.cn", "https://sdkapi.douyucdn.cn"]; const DOUYU_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; pub struct Douyu { @@ -56,6 +62,7 @@ struct DouyuLive { url: String, name: String, douyu_cdn: String, + douyu_force_hs: bool, douyu_rate: u32, douyu_disable_interactive_game: bool, douyu_danmaku: bool, @@ -70,6 +77,7 @@ impl DouyuLive { url: request.url, name: request.name, douyu_cdn: options.cdn, + douyu_force_hs: options.force_hs, douyu_rate: options.rate, douyu_disable_interactive_game: options.disable_interactive_game, douyu_danmaku: options.danmaku, @@ -85,6 +93,7 @@ impl DouyuLive { }; let play_info = self.get_web_play_info(&room_id).await?; let raw_stream_url = format!("{}/{}", play_info.rtmp_url, play_info.rtmp_live); + let raw_stream_url = self.maybe_build_huos_url(raw_stream_url).await; Ok(LiveStatus::Live { stream: Box::new(LiveStream { @@ -104,6 +113,24 @@ impl DouyuLive { }) } + async fn maybe_build_huos_url(&self, raw_stream_url: String) -> String { + if self.should_build_huos_url() { + match self.build_huos_url(&raw_stream_url).await { + Ok(huos_url) => huos_url, + Err(err) => { + warn!( + error = ?err, + url = raw_stream_url, + "failed to build Douyu huos URL, falling back to original stream URL" + ); + raw_stream_url + } + } + } else { + raw_stream_url + } + } + async fn resolve_room_id(&self) -> LiveResult { if let Ok(parsed) = Url::parse(&self.url) && let Some(rid) = parsed @@ -304,6 +331,70 @@ impl DouyuLive { ))) } + fn should_build_huos_url(&self) -> bool { + self.douyu_force_hs && self.douyu_cdn.eq_ignore_ascii_case(DOUYU_HS_CDN) + } + + async fn build_huos_url(&self, raw_stream_url: &str) -> LiveResult { + let (stream_id, params) = parse_stream_url(raw_stream_url)?; + let tx_secret = self.get_txsecret(&stream_id).await?; + Ok(build_huos_url(&stream_id, ¶ms, &tx_secret)) + } + + async fn get_txsecret(&self, stream_id: &str) -> LiveResult { + let mut apis = DOUYU_P2PSDK_APIS; + { + let mut rng = rand::thread_rng(); + apis.shuffle(&mut rng); + } + + let mut last_error = None; + for api in apis { + match self.request_txsecret(api, stream_id).await { + Ok(tx_secret) => return Ok(tx_secret), + Err(err) => last_error = Some(err), + } + } + + Err(last_error.unwrap_or_else(|| { + LiveError::custom(format!("获取 txSecret 失败: {stream_id}")) + })) + } + + async fn request_txsecret(&self, api: &str, stream_id: &str) -> LiveResult { + let tx_secret: XP2PTxSecret = self + .client + .get(format!("{api}/p2p/get_txsecret")) + .query(&[("lid", stream_id)]) + .header("user-agent", DOUYU_USER_AGENT) + .send() + .await + .map_err(|err| { + LiveError::custom(format!( + "获取 txSecret 失败 api: {api}, stream_id: {stream_id}: {err}" + )) + })? + .error_for_status() + .map_err(|err| { + LiveError::custom(format!( + "获取 txSecret 响应异常 api: {api}, stream_id: {stream_id}: {err}" + )) + })? + .json() + .await + .map_err(|err| { + LiveError::custom(format!( + "解析 txSecret 失败 api: {api}, stream_id: {stream_id}: {err}" + )) + })?; + + if tx_secret.tx_secret.is_empty() || tx_secret.tx_time.is_empty() { + return Err(LiveError::custom(format!("txSecret 为空: {stream_id}"))); + } + + Ok(tx_secret) + } + async fn update_key(&self) -> LiveResult { let rsp: EncryptionResponse = self .client @@ -369,6 +460,66 @@ fn md5_hex(input: String) -> String { format!("{:x}", hasher.finalize()) } +fn parse_stream_url(input: &str) -> LiveResult<(String, Vec<(String, String)>)> { + let parsed = Url::parse(input) + .map_err(|err| LiveError::custom(format!("解析斗鱼 huos 源链接失败: {err}")))?; + let stream_name = parsed + .path() + .rsplit('/') + .find(|part| !part.is_empty()) + .ok_or_else(|| LiveError::custom("斗鱼 huos 源链接缺少 stream_id"))?; + let stream_id = stream_name + .split_once('.') + .map(|(stream_id, _)| stream_id) + .unwrap_or(stream_name); + if stream_id.is_empty() { + return Err(LiveError::custom("斗鱼 huos 源链接 stream_id 为空")); + } + let params = parsed + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + Ok((stream_id.to_string(), params)) +} + +fn build_huos_url( + stream_id: &str, + params: &[(String, String)], + tx_secret: &XP2PTxSecret, +) -> String { + let mut next_params = Vec::with_capacity(params.len() + 3); + let mut has_fcdn = false; + + for (key, value) in params { + if matches!(key.as_str(), "txSecret" | "txTime" | "domain") { + continue; + } + if key == "fcdn" { + if !has_fcdn { + next_params.push((key.clone(), "hs".to_string())); + has_fcdn = true; + } + continue; + } + next_params.push((key.clone(), value.clone())); + } + + if !has_fcdn { + next_params.push(("fcdn".to_string(), "hs".to_string())); + } + next_params.push(("txSecret".to_string(), tx_secret.tx_secret.clone())); + next_params.push(("txTime".to_string(), tx_secret.tx_time.clone())); + next_params.push(("domain".to_string(), DOUYU_P2P_DOMAIN_TCT.to_string())); + + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (key, value) in next_params { + serializer.append_pair(&key, &value); + } + let query = serializer.finish(); + + format!("http://{DOUYU_HUOS_DOMAIN}/live/{stream_id}.xs?{query}") +} + #[derive(Deserialize)] struct BetardResponse { room: Option, @@ -419,3 +570,93 @@ struct PlayInfo { struct CdnInfo { cdn: Option, } + +#[derive(Deserialize)] +struct XP2PTxSecret { + #[serde(rename = "xp2p_txSecret")] + tx_secret: String, + #[serde(rename = "xp2p_txTime")] + tx_time: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_huos_url_rewrites_fcdn_and_appends_secret() { + let (stream_id, params) = parse_stream_url( + "https://hw3.douyucdn2.cn/live/6925114rIDrEEuKo.flv?wsAuth=auth&fcdn=hw&isp=", + ) + .unwrap(); + let tx_secret = XP2PTxSecret { + tx_secret: "secret".to_string(), + tx_time: "time".to_string(), + }; + + let url = build_huos_url(&stream_id, ¶ms, &tx_secret); + + assert_eq!( + url, + "http://openflv-huos.douyucdn2.cn/live/6925114rIDrEEuKo.xs?wsAuth=auth&fcdn=hs&isp=&txSecret=secret&txTime=time&domain=hdltctwk.douyucdn.cn" + ); + } + + #[test] + fn build_huos_url_removes_existing_secret_params() { + let (stream_id, params) = parse_stream_url( + "https://hw3.douyucdn2.cn/live/abc.flv?fcdn=hw&txSecret=old&txTime=old&domain=old.example", + ) + .unwrap(); + let tx_secret = XP2PTxSecret { + tx_secret: "new".to_string(), + tx_time: "next".to_string(), + }; + + let url = build_huos_url(&stream_id, ¶ms, &tx_secret); + + assert_eq!( + url, + "http://openflv-huos.douyucdn2.cn/live/abc.xs?fcdn=hs&txSecret=new&txTime=next&domain=hdltctwk.douyucdn.cn" + ); + } + + #[test] + fn build_huos_url_adds_missing_fcdn() { + let (stream_id, params) = + parse_stream_url("https://hw3.douyucdn2.cn/live/abc.flv?token=value").unwrap(); + let tx_secret = XP2PTxSecret { + tx_secret: "secret".to_string(), + tx_time: "time".to_string(), + }; + + let url = build_huos_url(&stream_id, ¶ms, &tx_secret); + + assert_eq!( + url, + "http://openflv-huos.douyucdn2.cn/live/abc.xs?token=value&fcdn=hs&txSecret=secret&txTime=time&domain=hdltctwk.douyucdn.cn" + ); + } + + #[tokio::test] + async fn maybe_build_huos_url_falls_back_when_build_fails() { + let live = DouyuLive { + client: Client::new(), + url: "https://www.douyu.com/10568722".to_string(), + name: "test".to_string(), + douyu_cdn: DOUYU_HS_CDN.to_string(), + douyu_force_hs: true, + douyu_rate: 0, + douyu_disable_interactive_game: false, + douyu_danmaku: false, + room_id: None, + }; + + let raw_stream_url = "not a url".to_string(); + + assert_eq!( + live.maybe_build_huos_url(raw_stream_url.clone()).await, + raw_stream_url + ); + } +} diff --git a/crates/biliup/src/downloader/live/mod.rs b/crates/biliup/src/downloader/live/mod.rs index c23b63f..080e9ea 100644 --- a/crates/biliup/src/downloader/live/mod.rs +++ b/crates/biliup/src/downloader/live/mod.rs @@ -169,6 +169,7 @@ impl Default for DouyinOptions { #[derive(Debug, Clone)] pub struct DouyuOptions { pub cdn: String, + pub force_hs: bool, pub rate: u32, pub disable_interactive_game: bool, pub danmaku: bool, @@ -178,6 +179,7 @@ impl Default for DouyuOptions { fn default() -> Self { Self { cdn: "hw-h5".to_string(), + force_hs: false, rate: 0, disable_interactive_game: false, danmaku: false,