fix(douyu): ffmpeg / mesio / streamlink / 边录边传也用上网宿 expire=0,HEAD 探测 + 403 退回原直链 (#1719)

* fix(douyu): ffmpeg / mesio / streamlink / 边录边传也用追加 expire=0 的网宿直链,拉流前探测 403 退回原直链

#1713 只让 stream-gears 用上追加 expire=0 的直链(它在首连里自带 403 兜底),
其它下载器仍拿原直链,录斗鱼网宿房间每 300 s 断一次。

改为在下载公共层(DownloadTask::download)交出直链前,用同一组请求头对追加过
expire=0 的网宿直链探一次:403 改用原直链,其余结果保留追加后的直链。只等响应头、
不读 body,不是追加直链时不发请求。stream-gears 仍走自己的 connect() 兜底。

* fix(douyu): expire=0 探测改用 HEAD,只有 2xx 才保留追加后的直链

实测网宿 token 一经 GET 成功即作废:GET 探测拿到 200 后,下载器用同一直链再连
8 次里 6 次只收到一个 GOP(0.2–2.3 MB)就被断开;HEAD 同样校验签名(改写 expire
即 403),之后 GET 4/4 正常持续。探测失败或返回非 2xx 时保守退回原直链。

* fix(streamlink): 直链型 runtime 每次拉流改用本次解析出的直链

streamlink runtime 在录制开始时固定了第一次解析的直链,重试时 check_stream 刷新的
DownloadConfig::url 用不上:斗鱼网宿 token 连过一次就作废,断开后每次重连只拿到一个
GOP,1 秒一个文件;也绕过了 expire=0 探测。runtime 的 url 就是插件解析出的流直链时
(无 runtime_options),改用 DownloadConfig::url。

* fix(douyu): 追加 expire=0 的直链探测通过却零字节失败时,本次录制余下的拉流改用原直链

HEAD 探测与下载器实际的 GET 若被网宿判得不一样(HEAD 200、GET 403),下载器会
反复失败直到重试用尽。DownloadTask 记一个粘性标记:非 stream-gears 下载器用追加
直链拉流、一个字节没写出且没有正常结束,之后本任务不再追加、也不再探测。
This commit is contained in:
Noah authored and GitHub committed 2026-09-23 15:01:53 +00:00
1 parent d343386fbd
commit 53aeba193f
5 files changed
+305 -10

No files matched your search

@@ -5,6 +5,7 @@ use crate::server::common::throughput::{RateMeter, Sampler};
use crate::server::common::upload::UploaderMessage;
use crate::server::common::util::FileValidator;
use crate::server::core::downloader::cover_downloader;
use crate::server::core::downloader::ws_expire::resolve_ws_expire_override;
use crate::server::core::downloader::{
DanmakuClient, DownloadStatus, DownloaderRuntime, SegmentEvent, SegmentInfo,
};
@@ -19,6 +20,7 @@ use biliup::downloader::preview::PreviewHub;
use danmaku_client::DanmakuEvent;
use error_stack::ResultExt;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::{Mutex, Notify, broadcast};
use tokio_util::sync::CancellationToken;
@@ -175,6 +177,9 @@ pub struct DownloadTask {
/// 实时弹幕广播:弹幕客户端每解出一条就 `send` 一份给预览播放器;
/// 平台没有弹幕实现(`stream.danmaku` 为 None)时为 `None`。
danmaku_tx: Option<broadcast::Sender<DanmakuEvent>>,
/// 追加 `expire=0` 的网宿直链探测通过、下载器却一个字节没拿到就失败过:
/// 本任务余下的拉流不再追加,直接用原直链(stream-gears 有自己的首连兜底,不受影响)。
ws_expire_rejected: AtomicBool,
}
/// 每路实时弹幕广播的槽位数;掉队的订阅者跳过丢掉的那几条继续收,不断开。
@@ -199,6 +204,7 @@ impl DownloadTask {
source: std::sync::RwLock::new(LiveSource::from_stream(stream)),
preview,
danmaku_tx,
ws_expire_rejected: AtomicBool::new(false),
}
}
@@ -303,9 +309,23 @@ impl DownloadTask {
// 创建守卫确保清理
// 创建事件处理器
// 执行下载
let bytes_before = self.meter.counter().total();
let components = self
.download(&mut processor, ctx.clone(), danmaku_client.clone(), &stream)
.await;
if !matches!(self.downloader, DownloaderRuntime::StreamGears(_))
&& ws_expire_override_failed(
&stream.raw_stream_url,
&components,
self.meter.counter().total() > bytes_before,
)
&& !self.ws_expire_rejected.swap(true, Ordering::Relaxed)
{
warn!(
url = url,
"追加 expire=0 的直链没拉到数据,本次录制余下的拉流改用原直链"
);
}
// 失败原因只藏在结束时的 Debug 输出里会让用户以为下载器“什么都没做”
// (典型:边录边传缺少上传模板、cookie 失效)。
@@ -422,11 +442,16 @@ impl DownloadTask {
// 获取配置和主播信息
let streamer = ctx.live_streamer();
let mut download_config = ctx.download_config(stream);
// 只有 stream-gears 会在网宿 403 时退回原直链,其它下载器仍用原直链
if !matches!(self.downloader, DownloaderRuntime::StreamGears(_))
&& let Some(original) = strip_ws_expire_override(&download_config.url)
{
download_config.url = original.to_string();
// stream-gears 在自己的首连里处理网宿 403,其它下载器拉流前先探一次
if !matches!(self.downloader, DownloaderRuntime::StreamGears(_)) {
download_config.url = match strip_ws_expire_override(&download_config.url) {
Some(original) if self.ws_expire_rejected.load(Ordering::Relaxed) => {
original.to_string()
}
_ => {
resolve_ws_expire_override(download_config.url, &download_config.headers).await
}
};
}
download_config.bytes_written = self.meter.counter();
download_config.preview = self.preview.clone();
@@ -545,6 +570,18 @@ fn download_attempt_progressed(result: &AppResult<DownloadStatus>) -> bool {
)
}
/// 用追加 `expire=0` 的网宿直链拉流(探测已通过)却没写出任何字节就失败:
/// 说明网宿对下载器的请求与对 HEAD 探测的判断不一致,不能再信探测结果。
fn ws_expire_override_failed(
stream_url: &str,
result: &AppResult<DownloadStatus>,
wrote_bytes: bool,
) -> bool {
strip_ws_expire_override(stream_url).is_some()
&& !wrote_bytes
&& !download_attempt_progressed(result)
}
/// Exponential backoff delay for the current `retry_count`.
///
/// `retry_count == 0` means "immediate next segment" (Duration::ZERO). Non-zero
@@ -621,6 +658,24 @@ mod tests {
use crate::server::errors::AppError;
use error_stack::Report;
#[test]
fn ws_expire_override_counts_as_failed_only_without_data() {
let overridden = "https://ws1a.douyucdn.cn/live/a.flv?wsAuth=a&expire=300&fcdn=ws&expire=0";
let original = "https://ws1a.douyucdn.cn/live/a.flv?wsAuth=a&expire=300&fcdn=ws";
let failed = Ok(DownloadStatus::Error("FFmpeg error: Some(8)".into()));
let err: AppResult<DownloadStatus> = Err(Report::new(AppError::Unknown));
assert!(ws_expire_override_failed(overridden, &failed, false));
assert!(ws_expire_override_failed(overridden, &err, false));
assert!(!ws_expire_override_failed(overridden, &failed, true));
assert!(!ws_expire_override_failed(
overridden,
&Ok(DownloadStatus::StreamEnded),
false
));
assert!(!ws_expire_override_failed(original, &failed, false));
}
#[test]
fn segment_completed_and_stream_ended_count_as_progress() {
assert!(download_attempt_progressed(&Ok(
@@ -8,6 +8,7 @@ pub mod stream_gears;
pub mod streamlink;
/// 边录边传(零落盘流式上传)
pub mod sync_downloader;
pub mod ws_expire;
pub mod ytdlp;
use crate::server::common::timerange;
@@ -116,6 +116,9 @@ pub struct StreamlinkDownloader {
url: String,
headers: HashMap<String, String>,
output_mode: OutputMode,
/// `url` 就是插件解析出的流直链时,每次拉流改用 `DownloadConfig::url`:
/// 重试会重新解析出新直链(斗鱼网宿的 token 连过一次就作废),且经过了 expire=0 探测
follow_stream_url: bool,
}
impl StreamlinkDownloader {
@@ -125,9 +128,15 @@ impl StreamlinkDownloader {
url,
headers: HashMap::new(),
output_mode: OutputMode::Pipe, // 默认管道模式
follow_stream_url: false,
}
}
pub fn following_stream_url(mut self) -> Self {
self.follow_stream_url = true;
self
}
pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
self.headers = headers;
self
@@ -176,7 +185,12 @@ impl StreamlinkDownloader {
args.push("force".to_string());
args.push("--output".to_string());
args.push(output_file.to_string());
args.push(streamlink_cli_url(&self.url));
let url = if self.follow_stream_url {
&download_config.url
} else {
&self.url
};
args.push(streamlink_cli_url(url));
args.push("best".to_string());
Ok(args)
}
@@ -524,6 +538,49 @@ async fn spawn_log(
#[cfg(test)]
mod tests {
use super::*;
use crate::server::common::util::Recorder;
use crate::server::infrastructure::models::StreamerInfo;
fn download_config(url: &str) -> DownloadConfig {
DownloadConfig {
url: url.to_string(),
segment_time: None,
time_range: None,
file_size: None,
headers: HashMap::new(),
recorder: Recorder::new(
None,
StreamerInfo::new("t", "u", "title", chrono::Utc::now(), ""),
),
output_dir: ".".into(),
suffix: "flv".to_string(),
bytes_written: ByteCounter::new(),
preview: Default::default(),
}
}
#[test]
fn stream_url_runtime_follows_each_attempts_url() {
let first = "https://ws1a.douyucdn.cn/live/a.flv?token=first&expire=300&fcdn=ws&expire=0";
let fresh = "https://ws1a.douyucdn.cn/live/a.flv?token=fresh&expire=300&fcdn=ws";
let args = |downloader: StreamlinkDownloader| {
downloader
.build_file_args(&download_config(fresh), "out.flv.part")
.unwrap()
};
let following = args(
StreamlinkDownloader::new(first.to_string(), Platform::Generic).following_stream_url(),
);
assert!(following.contains(&streamlink_cli_url(fresh)));
assert!(!following.iter().any(|arg| arg.contains("token=first")));
let pinned = args(StreamlinkDownloader::new(
first.to_string(),
Platform::Generic,
));
assert!(pinned.contains(&streamlink_cli_url(first)));
}
#[test]
fn progressive_flv_gets_httpstream_prefix() {
@@ -0,0 +1,178 @@
//! 斗鱼网宿直链追加 `expire=0` 的拉流前探测。
//!
//! stream-gears 在自己的首连里处理 403(见 `stream_gears::connect`);ffmpeg / mesio /
//! streamlink / 边录边传把直链交给外部进程或库,拿不到首连的状态码,所以在交出去之前
//! 先用同一组请求头探一次,确认网宿接受追加后的直链才用它,否则退回原直链。
use crate::server::common::construct_headers;
use biliup::downloader::live::strip_ws_expire_override;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{info, warn};
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
/// 返回交给下载器的直链:不是追加过 `expire=0` 的网宿直链时原样返回、不发请求。
///
/// 探测必须用 HEAD:网宿的 token 一经 GET 成功就作废,同一直链再 GET 只回一个 GOP
/// 就断开;HEAD 照样校验签名(不通过即 403),却不消耗 token。只有 HEAD 返回 2xx
/// 才保留追加后的直链,403、其它状态码与网络错误一律退回原直链。
pub(crate) async fn resolve_ws_expire_override(
url: String,
headers: &HashMap<String, String>,
) -> String {
let Some(original) = strip_ws_expire_override(&url) else {
return url;
};
let original = original.to_string();
let client = match construct_headers(headers).map(|headers| {
reqwest::Client::builder()
.default_headers(headers)
.connect_timeout(PROBE_TIMEOUT)
.timeout(PROBE_TIMEOUT)
.build()
}) {
Ok(Ok(client)) => client,
_ => return original,
};
match client.head(&url).send().await {
Ok(response) if response.status().is_success() => {
info!(status = %response.status(), "网宿接受追加 expire=0 的直链");
url
}
Ok(response) => {
warn!(
status = %response.status(),
"网宿拒绝了追加 expire=0 的直链,本次改用原直链,连接仍会按 expire 定时断开"
);
original
}
Err(e) => {
warn!(error = %e, "探测追加 expire=0 的直链失败,本次改用原直链");
original
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::extract::RawQuery;
use axum::http::{HeaderMap, Method, StatusCode};
use axum::routing::any;
use std::sync::{Arc, Mutex};
#[derive(Debug, PartialEq)]
struct Hit {
method: Method,
user_agent: Option<String>,
}
struct Cdn {
base: String,
hits: Arc<Mutex<Vec<Hit>>>,
}
/// `reject_duplicate`:模拟网宿修掉重复参数的解析差异,带重复 expire 的一律 403
async fn wangsu(reject_duplicate: bool, status_otherwise: StatusCode) -> Cdn {
let hits = Arc::new(Mutex::new(Vec::new()));
let seen = hits.clone();
let app = Router::new().route(
"/live/a.flv",
any(
move |method: Method, RawQuery(query): RawQuery, headers: HeaderMap| {
seen.lock().unwrap().push(Hit {
method,
user_agent: headers
.get("user-agent")
.map(|ua| ua.to_str().unwrap().to_string()),
});
async move {
let expires = query
.unwrap_or_default()
.split('&')
.filter(|pair| pair.starts_with("expire="))
.count();
if reject_duplicate && expires > 1 {
StatusCode::FORBIDDEN
} else {
status_otherwise
}
}
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
Cdn {
base: format!("http://{addr}/live/a.flv"),
hits,
}
}
fn headers() -> HashMap<String, String> {
HashMap::from([("User-Agent".to_string(), "biliup-test".to_string())])
}
#[tokio::test]
async fn keeps_override_when_wangsu_accepts_it() {
let cdn = wangsu(false, StatusCode::OK).await;
let overridden = format!("{}?wsAuth=a&token=t&expire=300&fcdn=ws&expire=0", cdn.base);
let url = resolve_ws_expire_override(overridden.clone(), &headers()).await;
assert_eq!(url, overridden);
assert_eq!(
*cdn.hits.lock().unwrap(),
[Hit {
method: Method::HEAD,
user_agent: Some("biliup-test".to_string()),
}]
);
}
#[tokio::test]
async fn falls_back_to_original_url_on_403() {
let cdn = wangsu(true, StatusCode::OK).await;
let original = format!("{}?wsAuth=a&token=t&expire=300&fcdn=ws", cdn.base);
let url = resolve_ws_expire_override(format!("{original}&expire=0"), &headers()).await;
assert_eq!(url, original);
assert_eq!(cdn.hits.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn falls_back_to_original_url_unless_probe_succeeds() {
let cdn = wangsu(false, StatusCode::METHOD_NOT_ALLOWED).await;
let original = format!("{}?wsAuth=a&expire=300&fcdn=ws", cdn.base);
assert_eq!(
resolve_ws_expire_override(format!("{original}&expire=0"), &headers()).await,
original
);
let unreachable = "http://127.0.0.1:1/live/a.flv?wsAuth=a&expire=300&fcdn=ws";
assert_eq!(
resolve_ws_expire_override(format!("{unreachable}&expire=0"), &headers()).await,
unreachable
);
}
#[tokio::test]
async fn sends_nothing_for_urls_without_override() {
let cdn = wangsu(true, StatusCode::OK).await;
for url in [
format!("{}?wsAuth=a&expire=300&fcdn=ws", cdn.base),
format!("{}?wsAuth=a&expire=300&fcdn=hw&expire=0", cdn.base),
"https://example.com/live.m3u8".to_string(),
] {
assert_eq!(
resolve_ws_expire_override(url.clone(), &headers()).await,
url
);
}
assert!(cdn.hits.lock().unwrap().is_empty());
}
}
+8 -4
View File
@@ -201,20 +201,24 @@ pub fn downloader_runtime(
}
fn streamlink_runtime(stream: &LiveStream) -> DownloaderRuntime {
let (url, platform) = match stream.runtime_options.as_ref() {
let (url, platform, from_stream_url) = match stream.runtime_options.as_ref() {
Some(RuntimeOptions::Streamlink(StreamlinkOptions { url, platform })) => (
url.clone().unwrap_or_else(|| stream.raw_stream_url.clone()),
streamlink_platform(platform),
false,
),
// yt-dlp 型来源(如 YouTube)交给 streamlink 时,传入网页地址让其自行提取,
// 而非已解析的 manifest 直链(对齐 youtube.py:96-101)
Some(RuntimeOptions::YtDlp(options)) if !options.webpage_url.is_empty() => {
(options.webpage_url.clone(), Platform::Generic)
(options.webpage_url.clone(), Platform::Generic, false)
}
_ => (stream.raw_stream_url.clone(), Platform::Generic),
_ => (stream.raw_stream_url.clone(), Platform::Generic, true),
};
let downloader =
let mut downloader =
StreamlinkDownloader::new(url, platform).with_headers(stream.stream_headers.clone());
if from_stream_url {
downloader = downloader.following_stream_url();
}
DownloaderRuntime::StreamLink(Streamlink::new(downloader))
}