diff --git a/Cargo.lock b/Cargo.lock
index 6f136ed..ef38be5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -387,7 +387,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "biliup"
-version = "1.2.2"
+version = "1.2.3"
dependencies = [
"async-trait",
"base64",
@@ -426,7 +426,7 @@ dependencies = [
[[package]]
name = "biliup-cli"
-version = "1.2.2"
+version = "1.2.3"
dependencies = [
"async-channel",
"async-trait",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "danmaku"
-version = "1.2.2"
+version = "1.2.3"
dependencies = [
"async-trait",
"base64",
@@ -4109,7 +4109,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stream-gears"
-version = "1.2.2"
+version = "1.2.3"
dependencies = [
"async-channel",
"async-trait",
diff --git a/Cargo.toml b/Cargo.toml
index 614796a..8019f13 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
resolver = "3"
[workspace.package]
-version = "1.2.2"
+version = "1.2.3"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
diff --git a/app/(app)/streamers/page.tsx b/app/(app)/streamers/page.tsx
index 39306dd..8a32cc5 100644
--- a/app/(app)/streamers/page.tsx
+++ b/app/(app)/streamers/page.tsx
@@ -69,6 +69,9 @@ export default function Home() {
case 'OutOfSchedule':
statusTag = 非录播时间
break
+ case 'TitleExcluded':
+ statusTag = 标题已排除
+ break
case 'Pause':
statusTag = 暂停中
break
diff --git a/crates/biliup-cli/src/server/api/endpoints.rs b/crates/biliup-cli/src/server/api/endpoints.rs
index a3fe962..e1d3424 100644
--- a/crates/biliup-cli/src/server/api/endpoints.rs
+++ b/crates/biliup-cli/src/server/api/endpoints.rs
@@ -1,10 +1,11 @@
+use crate::server::common::recording_policy::{self, Rejection};
use crate::server::common::upload::{build_studio, submit_to_bilibili, upload};
use crate::server::common::util::Recorder;
use crate::server::config::Config;
use crate::server::core::download_manager::DownloadManager;
use crate::server::errors::{AppError, report_to_response};
use crate::server::infrastructure::connection_pool::ConnectionPool;
-use crate::server::infrastructure::context::{Stage, WorkerStatus};
+use crate::server::infrastructure::context::{Stage, Worker, WorkerStatus};
use crate::server::infrastructure::dto::LiveStreamerResponse;
use crate::server::infrastructure::models::live_streamer::{InsertLiveStreamer, LiveStreamer};
use crate::server::infrastructure::models::upload_streamer::{
@@ -35,6 +36,20 @@ use tokio::fs;
use tracing::info;
use tracing_subscriber::EnvFilter;
+/// 主播当前被录制策略挡下的原因,供 `/v1/streamers` 覆盖状态字段。
+///
+/// 探测前就能判定的条件(录制时间范围)按当前时钟实时计算,不受监控循环节奏影响;
+/// 需要房间标题才能判定的条件取最近一次探测的结论。
+fn rejection_status(streamer: &LiveStreamer, worker: Option<&Worker>) -> Option<&'static str> {
+ if let Some(rejection) = recording_policy::reject_before_probe(streamer) {
+ return Some(rejection.status());
+ }
+ worker
+ .and_then(Worker::rejection)
+ .as_ref()
+ .map(Rejection::status)
+}
+
pub async fn get_streamers_endpoint(
State(pool): State,
State(managers): State>,
@@ -52,6 +67,14 @@ pub async fn get_streamers_endpoint(
Some(t) => format!("{:?}", *t.downloader_status.read().unwrap()),
None => String::new(),
};
+ // 被录制策略挡下时报告具体原因,否则「不在时间范围内」和「单纯没开播」
+ // 在界面上都是「空闲」,用户无从判断配置有没有生效。
+ // 时间类条件按当前时钟实时算,不依赖监控循环轮到这个房间,因此不会过期;
+ // 依赖房间标题的结论来自最近一次探测。正在录制时不覆盖。
+ let status = match rejection_status(&x, option.as_deref()) {
+ Some(reason) if status != "Working" => reason.to_string(),
+ _ => status,
+ };
results.push(LiveStreamerResponse {
status,
@@ -692,3 +715,179 @@ pub async fn post_uploads(
Ok(Json(serde_json::json!({})))
}
+
+#[cfg(test)]
+mod recording_policy_status_tests {
+ use super::*;
+ use crate::server::infrastructure::connection_pool::ConnectionManager;
+ use chrono::{Duration as ChronoDuration, SecondsFormat};
+ use serde_json::Value;
+
+ /// 以当前时刻为基准造窗口,形态与前端 `Date.toISOString()` 写出的一致
+ fn window(starts_in: i64, ends_in: i64) -> String {
+ let now = Utc::now();
+ let iso = |offset: i64| {
+ (now + ChronoDuration::seconds(offset)).to_rfc3339_opts(SecondsFormat::Millis, true)
+ };
+ format!(r#"["{}","{}"]"#, iso(starts_in), iso(ends_in))
+ }
+
+ fn insert(url: &str, time_range: Option) -> InsertLiveStreamer {
+ InsertLiveStreamer {
+ url: url.to_string(),
+ remark: url.to_string(),
+ filename_prefix: None,
+ time_range,
+ upload_streamers_id: None,
+ format: None,
+ override_cfg: None,
+ preprocessor: None,
+ segment_processor: None,
+ downloaded_processor: None,
+ postprocessor: None,
+ opt_args: None,
+ excluded_keywords: None,
+ }
+ }
+
+ fn worker_for(streamer: LiveStreamer) -> Worker {
+ Worker::new(
+ streamer,
+ None,
+ Arc::new(RwLock::new(Config::default())),
+ Default::default(),
+ )
+ }
+
+ /// 走完整的 数据库 -> /v1/streamers 处理函数 -> JSON 这条路
+ #[tokio::test]
+ async fn the_streamers_endpoint_reports_why_a_streamer_is_not_recording() {
+ let dir = tempfile::tempdir().unwrap();
+ let db = dir.path().join("data.sqlite3");
+ let pool = ConnectionManager::new_pool(db.to_str().unwrap())
+ .await
+ .unwrap();
+
+ // 窗口尚未开始 -> 应报告 OutOfSchedule
+ insert("https://live.example.com/closed", Some(window(3600, 7200)))
+ .insert(&pool)
+ .await
+ .unwrap();
+ // 窗口正开着 -> 不该被覆盖
+ insert("https://live.example.com/open", Some(window(-60, 3600)))
+ .insert(&pool)
+ .await
+ .unwrap();
+ // 没配时间范围 -> 不该被覆盖
+ insert("https://live.example.com/always", None)
+ .insert(&pool)
+ .await
+ .unwrap();
+
+ let managers = Arc::new(DownloadManager::new(1, 0, pool.clone()));
+ let Json(responses) = get_streamers_endpoint(State(pool), State(managers))
+ .await
+ .expect("接口应返回成功");
+
+ let status_of = |url: &str| {
+ responses
+ .iter()
+ .find(|r| r.inner.url == url)
+ .unwrap_or_else(|| panic!("响应里应有 {url}"))
+ .status
+ .clone()
+ };
+
+ assert_eq!(
+ status_of("https://live.example.com/closed"),
+ "OutOfSchedule"
+ );
+ assert_eq!(status_of("https://live.example.com/open"), "");
+ assert_eq!(status_of("https://live.example.com/always"), "");
+ }
+
+ #[tokio::test]
+ async fn the_endpoint_response_serialises_the_status_frontend_switches_on() {
+ let dir = tempfile::tempdir().unwrap();
+ let db = dir.path().join("data.sqlite3");
+ let pool = ConnectionManager::new_pool(db.to_str().unwrap())
+ .await
+ .unwrap();
+ insert("https://live.example.com/closed", Some(window(3600, 7200)))
+ .insert(&pool)
+ .await
+ .unwrap();
+
+ let managers = Arc::new(DownloadManager::new(1, 0, pool.clone()));
+ let Json(responses) = get_streamers_endpoint(State(pool), State(managers))
+ .await
+ .unwrap();
+
+ // 前端 app/(app)/streamers/page.tsx 就是对这个字符串做 switch
+ let json: Value = serde_json::to_value(&responses[0]).unwrap();
+ assert_eq!(json["status"], "OutOfSchedule");
+ }
+
+ #[test]
+ fn a_title_excluded_worker_reports_title_excluded() {
+ let streamer = LiveStreamer {
+ id: 1,
+ url: "https://live.example.com/x".to_string(),
+ remark: "x".to_string(),
+ filename_prefix: None,
+ time_range: None,
+ upload_streamers_id: None,
+ format: None,
+ override_cfg: None,
+ preprocessor: None,
+ segment_processor: None,
+ downloaded_processor: None,
+ postprocessor: None,
+ opt_args: None,
+ excluded_keywords: None,
+ };
+ let worker = worker_for(streamer.clone());
+
+ // 没有任何拦截时不覆盖状态
+ assert_eq!(rejection_status(&streamer, Some(&worker)), None);
+
+ // 探测后记录下的标题拦截会被报告出来
+ worker.set_rejection(Some(Rejection::ExcludedKeyword("录像".to_string())));
+ assert_eq!(
+ rejection_status(&streamer, Some(&worker)),
+ Some("TitleExcluded")
+ );
+
+ // 探测结果推翻后清空
+ worker.set_rejection(None);
+ assert_eq!(rejection_status(&streamer, Some(&worker)), None);
+ }
+
+ #[test]
+ fn the_time_window_outranks_a_stale_title_rejection() {
+ let streamer = LiveStreamer {
+ id: 1,
+ url: "https://live.example.com/x".to_string(),
+ remark: "x".to_string(),
+ filename_prefix: None,
+ time_range: Some(window(3600, 7200)),
+ upload_streamers_id: None,
+ format: None,
+ override_cfg: None,
+ preprocessor: None,
+ segment_processor: None,
+ downloaded_processor: None,
+ postprocessor: None,
+ opt_args: None,
+ excluded_keywords: None,
+ };
+ let worker = worker_for(streamer.clone());
+ worker.set_rejection(Some(Rejection::ExcludedKeyword("录像".to_string())));
+
+ // 时间范围按当前时钟实时算、不会过期,优先于上一次探测留下的结论
+ assert_eq!(
+ rejection_status(&streamer, Some(&worker)),
+ Some("OutOfSchedule")
+ );
+ }
+}
diff --git a/crates/biliup-cli/src/server/common/download.rs b/crates/biliup-cli/src/server/common/download.rs
index 5162108..1a82d84 100644
--- a/crates/biliup-cli/src/server/common/download.rs
+++ b/crates/biliup-cli/src/server/common/download.rs
@@ -1,3 +1,4 @@
+use crate::server::common::recording_policy;
use crate::server::common::upload::UploaderMessage;
use crate::server::common::util::FileValidator;
use crate::server::core::downloader::cover_downloader;
@@ -203,6 +204,19 @@ impl DownloadTask {
}
}
+ // 录制策略:条件已不成立就不再续录,把房间交回监控循环。
+ // 少了这一步,下载器按边界收尾后循环会立刻重开一段,等于策略形同虚设。
+ // 每轮都重新判定,对齐 Python 版每轮 `run()` 前重新调用 `should_record()`。
+ //
+ // 放在 check_stream 之后有两个原因:用得到刚刷新的房间标题;且能同时覆盖
+ // 探测失败的分支——那条路径只加重试计数,否则会带着已失效的策略绕回去重录。
+ if let Some(rejection) =
+ recording_policy::reject_before_record(ctx.live_streamer(), &stream.title)
+ {
+ info!(url = url, reason = %rejection, "停止录制");
+ break components;
+ }
+
if retry_count >= max_retries {
warn!(
url = url,
diff --git a/crates/biliup-cli/src/server/common/mod.rs b/crates/biliup-cli/src/server/common/mod.rs
index a96685c..70ad5d7 100644
--- a/crates/biliup-cli/src/server/common/mod.rs
+++ b/crates/biliup-cli/src/server/common/mod.rs
@@ -3,6 +3,10 @@ use std::collections::HashMap;
use std::str::FromStr;
pub mod download;
+/// 录制准入策略
+pub mod recording_policy;
+/// 录制时间范围判定
+pub mod timerange;
pub mod upload;
/// 通用工具函数
pub mod util;
diff --git a/crates/biliup-cli/src/server/common/recording_policy.rs b/crates/biliup-cli/src/server/common/recording_policy.rs
new file mode 100644
index 0000000..1ca2ab6
--- /dev/null
+++ b/crates/biliup-cli/src/server/common/recording_policy.rs
@@ -0,0 +1,248 @@
+//! 录制准入策略:集中回答「现在该不该录这个主播」。
+//!
+//! Python 版把这些条件放在 `should_record()` 里(`biliup/engine/download.py`)。
+//! Rust 重写时这个概念整体消失了,于是 `time_range` 和 `excluded_keywords`
+//! 虽然一路存到了数据库和界面,运行时却没有任何代码读它们(issue #1654)。
+//! 这个模块把「录制前提」重新变成一个有名字、有测试、可枚举的东西:
+//! 新增条件时只要加一个 [`Rejection`] 分支和一行判定,三个调用点会一起生效。
+//!
+//! 判定分两级,依据是**这个条件需要哪些信息**,而这决定了它最早能在什么时候求值:
+//!
+//! * [`reject_before_probe`] —— 只看主播配置和时钟,开播探测之前就能判定,
+//! 因此可以在不占用下载槽位、不发任何请求的情况下把房间筛掉。
+//! * [`reject_before_record`] —— 还要看直播流信息(房间标题),只能在
+//! `check_stream` 拿到流之后判定;它是前者的超集。
+//!
+//! Python 版把两者混在一个探测之后的 `should_record()` 里,所以不在录制时间范围内
+//! 的主播照样要发一次网络请求;这里按信息依赖分级,避免了那次无谓的请求。
+
+use std::fmt;
+
+use serde_json::Value;
+
+use crate::server::common::timerange;
+use crate::server::infrastructure::models::live_streamer::LiveStreamer;
+
+/// 不予录制的原因。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Rejection {
+ /// 当前不在配置的录制时间范围内
+ OutOfTimeRange,
+ /// 房间标题命中了排除关键词
+ ExcludedKeyword(String),
+}
+
+impl Rejection {
+ /// `/v1/streamers` 返回给前端的状态字符串。
+ ///
+ /// `OutOfSchedule` 沿用 Python 版 `biliup/web/__init__.py` 的取值,
+ /// 前端 `app/(app)/streamers/page.tsx` 一直保留着对应的「非录播时间」标签。
+ pub fn status(&self) -> &'static str {
+ match self {
+ Rejection::OutOfTimeRange => "OutOfSchedule",
+ Rejection::ExcludedKeyword(_) => "TitleExcluded",
+ }
+ }
+}
+
+impl fmt::Display for Rejection {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Rejection::OutOfTimeRange => f.write_str("不在录制时间范围内"),
+ Rejection::ExcludedKeyword(keyword) => {
+ write!(f, "房间标题命中排除关键词 {keyword:?}")
+ }
+ }
+ }
+}
+
+/// 只依赖主播配置和时钟的条件,开播探测之前即可判定。
+///
+/// 监控循环在占用下载槽位、发出任何请求之前调用它。
+pub fn reject_before_probe(streamer: &LiveStreamer) -> Option {
+ if !timerange::is_within(streamer.time_range.as_deref()) {
+ return Some(Rejection::OutOfTimeRange);
+ }
+ None
+}
+
+/// 需要直播流信息才能判定的条件,是 [`reject_before_probe`] 的超集。
+///
+/// 在 `check_stream` 取得流之后、真正开录之前调用,续录前每轮也要重新判定
+/// ——房间标题和当前时间都可能在录制过程中变化。
+pub fn reject_before_record(streamer: &LiveStreamer, title: &str) -> Option {
+ if let Some(rejection) = reject_before_probe(streamer) {
+ return Some(rejection);
+ }
+ if let Some(keyword) = matched_excluded_keyword(streamer, title) {
+ return Some(Rejection::ExcludedKeyword(keyword));
+ }
+ None
+}
+
+/// 房间标题命中的第一个排除关键词。
+///
+/// 与 Python 版一致地做大小写敏感的子串匹配并去掉关键词首尾空白;
+/// 但会跳过空关键词——Python 版 `"" in title` 恒真,一个空字符串就会让该主播完全录不到。
+fn matched_excluded_keyword(streamer: &LiveStreamer, title: &str) -> Option {
+ if title.is_empty() {
+ return None;
+ }
+ streamer
+ .excluded_keywords
+ .as_ref()
+ .and_then(Value::as_array)?
+ .iter()
+ .filter_map(Value::as_str)
+ .map(str::trim)
+ .filter(|keyword| !keyword.is_empty())
+ .find(|keyword| title.contains(*keyword))
+ .map(str::to_owned)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::{Duration, SecondsFormat, Utc};
+
+ /// 以当前时刻为基准造窗口,形态与前端 `Date.toISOString()` 写出的一致
+ fn window(starts_in: i64, ends_in: i64) -> String {
+ let now = Utc::now();
+ let iso = |offset: i64| {
+ (now + Duration::seconds(offset)).to_rfc3339_opts(SecondsFormat::Millis, true)
+ };
+ format!(r#"["{}","{}"]"#, iso(starts_in), iso(ends_in))
+ }
+
+ fn streamer(time_range: Option, excluded_keywords: Option) -> LiveStreamer {
+ LiveStreamer {
+ id: 1,
+ url: "https://www.douyu.com/5146843".to_string(),
+ remark: "test".to_string(),
+ filename_prefix: None,
+ time_range,
+ upload_streamers_id: None,
+ format: None,
+ override_cfg: None,
+ preprocessor: None,
+ segment_processor: None,
+ downloaded_processor: None,
+ postprocessor: None,
+ opt_args: None,
+ excluded_keywords,
+ }
+ }
+
+ fn keywords(values: &[&str]) -> Option {
+ Some(Value::Array(
+ values
+ .iter()
+ .map(|k| Value::String((*k).to_string()))
+ .collect(),
+ ))
+ }
+
+ #[test]
+ fn a_streamer_without_any_condition_is_always_recorded() {
+ let streamer = streamer(None, None);
+ assert_eq!(reject_before_probe(&streamer), None);
+ assert_eq!(reject_before_record(&streamer, "随便什么标题"), None);
+ }
+
+ #[test]
+ fn an_open_window_and_a_clean_title_are_recorded() {
+ let streamer = streamer(Some(window(-60, 3600)), keywords(&["录像", "重播"]));
+ assert_eq!(reject_before_probe(&streamer), None);
+ assert_eq!(reject_before_record(&streamer, "今天来点好活"), None);
+ }
+
+ #[test]
+ fn a_closed_window_is_rejected_before_the_probe() {
+ // 关键性质:这个条件在探测之前就能判定,才能省掉下载槽位和网络请求
+ let streamer = streamer(Some(window(3600, 7200)), None);
+ assert_eq!(
+ reject_before_probe(&streamer),
+ Some(Rejection::OutOfTimeRange)
+ );
+ }
+
+ #[test]
+ fn an_excluded_keyword_is_only_visible_after_the_probe() {
+ // 标题类条件探测前无从判断,探测后才拒绝
+ let streamer = streamer(None, keywords(&["录像"]));
+ assert_eq!(reject_before_probe(&streamer), None);
+ assert_eq!(
+ reject_before_record(&streamer, "昨日录像回放"),
+ Some(Rejection::ExcludedKeyword("录像".to_string()))
+ );
+ }
+
+ #[test]
+ fn the_post_probe_check_still_enforces_the_pre_probe_conditions() {
+ // reject_before_record 必须是 reject_before_probe 的超集,
+ // 否则新增一个「探测前条件」时会在开录那一步漏掉
+ let streamer = streamer(Some(window(3600, 7200)), None);
+ assert_eq!(
+ reject_before_record(&streamer, "无关标题"),
+ Some(Rejection::OutOfTimeRange)
+ );
+ }
+
+ #[test]
+ fn keywords_are_matched_as_trimmed_substrings() {
+ let streamer = streamer(None, keywords(&[" 重播 "]));
+ assert_eq!(
+ reject_before_record(&streamer, "这是重播内容"),
+ Some(Rejection::ExcludedKeyword("重播".to_string()))
+ );
+ }
+
+ #[test]
+ fn an_empty_keyword_does_not_block_everything() {
+ // Python 版 `"" in title` 恒真,一个空关键词会让主播彻底录不到
+ let streamer = streamer(None, keywords(&["", " "]));
+ assert_eq!(reject_before_record(&streamer, "正常直播"), None);
+ }
+
+ #[test]
+ fn an_empty_title_skips_the_keyword_check() {
+ let streamer = streamer(None, keywords(&["录像"]));
+ assert_eq!(reject_before_record(&streamer, ""), None);
+ }
+
+ #[test]
+ fn malformed_keywords_fall_open_instead_of_blocking() {
+ for raw in [
+ Value::Null,
+ Value::String("录像".to_string()),
+ Value::Number(1.into()),
+ Value::Array(vec![Value::Number(1.into())]),
+ ] {
+ let streamer = streamer(None, Some(raw.clone()));
+ assert_eq!(
+ reject_before_record(&streamer, "昨日录像回放"),
+ None,
+ "raw={raw}"
+ );
+ }
+ }
+
+ #[test]
+ fn keyword_matching_is_case_sensitive_like_python() {
+ let streamer = streamer(None, keywords(&["Replay"]));
+ assert_eq!(reject_before_record(&streamer, "replay time"), None);
+ assert_eq!(
+ reject_before_record(&streamer, "Replay time"),
+ Some(Rejection::ExcludedKeyword("Replay".to_string()))
+ );
+ }
+
+ #[test]
+ fn rejections_carry_the_status_string_the_frontend_expects() {
+ assert_eq!(Rejection::OutOfTimeRange.status(), "OutOfSchedule");
+ assert_eq!(
+ Rejection::ExcludedKeyword("x".to_string()).status(),
+ "TitleExcluded"
+ );
+ }
+}
diff --git a/crates/biliup-cli/src/server/common/timerange.rs b/crates/biliup-cli/src/server/common/timerange.rs
new file mode 100644
index 0000000..cb76ee7
--- /dev/null
+++ b/crates/biliup-cli/src/server/common/timerange.rs
@@ -0,0 +1,331 @@
+//! 录制时间范围(`time_range`)的解析与判定。
+//!
+//! 存储格式是一个含两个 ISO 8601 时刻的 JSON 数组字符串,例如
+//! `["2025-03-26T16:00:00.000Z","2025-03-27T15:59:59.000Z"]`。
+//!
+//! 前端时间选择器拿到的是用户本地时刻,写库前经 `Date.toISOString()` 转成 UTC
+//! (`app/ui/TemplateModal.tsx`),所以两端存的都是 **UTC 时刻**,日期部分只是
+//! 选择当天的残留、没有意义。判定时统一取 UTC 当日秒数比较,正好抵消前端写入时
+//! 的时区偏移,与 Python 版 `check_timerange()` 用 `datetime.now(timezone.utc)`
+//! 比较的行为一致。若改用本地时间判定,UTC+8 用户配置的 16:00–20:00 会被当成
+//! 08:00–12:00,整整错开 8 小时。
+
+use chrono::{DateTime, NaiveDateTime, Timelike, Utc};
+use tracing::debug;
+
+const SECONDS_PER_DAY: u32 = 24 * 60 * 60;
+
+/// 一个录制窗口,端点为 UTC 当日秒数的闭区间。
+/// `start > end` 表示跨午夜区间(如 23:00 → 04:00)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TimeRange {
+ start: u32,
+ end: u32,
+}
+
+impl TimeRange {
+ /// 解析存储的 `time_range` 字符串,任何形式的非法输入都返回 `None`。
+ pub fn parse(raw: &str) -> Option {
+ let bounds: Vec = serde_json::from_str(raw)
+ .inspect_err(|e| debug!(raw = raw, error = %e, "time_range 不是字符串数组,忽略"))
+ .ok()?;
+ let [start, end] = <[String; 2]>::try_from(bounds)
+ .inspect_err(|_| debug!(raw = raw, "time_range 需要恰好两个时刻,忽略"))
+ .ok()?;
+ Some(Self {
+ start: utc_seconds_from_midnight(&start)?,
+ end: utc_seconds_from_midnight(&end)?,
+ })
+ }
+
+ /// `now`(UTC 当日秒数)是否落在窗口内。
+ fn contains(&self, now: u32) -> bool {
+ if self.start <= self.end {
+ // 普通区间,如 16:00 → 20:00
+ self.start <= now && now <= self.end
+ } else {
+ // 跨午夜区间,如 23:00 → 04:00
+ now >= self.start || now <= self.end
+ }
+ }
+
+ /// 距窗口结束还有多少秒;不在窗口内返回 0。
+ ///
+ /// 跨午夜窗口在午夜之前的那段里 `end < now`,必须跨到次日的结束时刻才对。
+ /// 但「已经错过窗口」同样满足 `end < now`,不先判定在不在窗口内就会算出
+ /// 「还剩将近 24 小时」——正好是这个功能要防的那种失控录制。
+ fn seconds_until_end(&self, now: u32) -> u32 {
+ if !self.contains(now) {
+ return 0;
+ }
+ if self.end >= now {
+ self.end - now
+ } else {
+ SECONDS_PER_DAY - now + self.end
+ }
+ }
+}
+
+/// 取 ISO 8601 时刻换算到 UTC 后的当日秒数。
+///
+/// 前端写入的一定带 `Z`;手写配置若带其他偏移量(如 `+08:00`)同样先换算到 UTC,
+/// 完全不带时区信息时按 UTC 解释。
+fn utc_seconds_from_midnight(raw: &str) -> Option {
+ let time = match DateTime::parse_from_rfc3339(raw) {
+ Ok(dt) => dt.with_timezone(&Utc).time(),
+ Err(_) => raw
+ .parse::()
+ .inspect_err(|e| debug!(raw = raw, error = %e, "time_range 时刻无法解析,忽略"))
+ .ok()?
+ .time(),
+ };
+ Some(time.num_seconds_from_midnight())
+}
+
+fn now_seconds_from_midnight() -> u32 {
+ Utc::now().time().num_seconds_from_midnight()
+}
+
+/// 当前是否处于配置的录制时间范围内。
+///
+/// 未配置或配置无法解析时一律放行:坏配置只应让时间范围失效,不该让主播完全录不到。
+/// 与 Python 版 `check_timerange()` 出错即 `return True` 的取舍一致。
+pub fn is_within(time_range: Option<&str>) -> bool {
+ is_within_at(time_range, now_seconds_from_midnight())
+}
+
+fn is_within_at(time_range: Option<&str>, now: u32) -> bool {
+ match time_range.and_then(TimeRange::parse) {
+ Some(range) => range.contains(now),
+ None => true,
+ }
+}
+
+/// 距录制时间范围结束还剩多久(`"HH:MM:SS"`);未配置或配置非法时为 `None`。
+///
+/// 给「自己不会退出」的录制方式收尾用(如 ffmpeg 内部分段由 segment muxer 持续切片),
+/// 需要一个总时长上限把进程截停在窗口边界。
+pub fn remaining_until_end(time_range: Option<&str>) -> Option {
+ remaining_until_end_at(time_range, now_seconds_from_midnight())
+}
+
+fn remaining_until_end_at(time_range: Option<&str>, now: u32) -> Option {
+ let range = time_range.and_then(TimeRange::parse)?;
+ Some(format_hms(range.seconds_until_end(now)))
+}
+
+/// 计算本次录制块允许的最长时长(`"HH:MM:SS"`)。
+///
+/// 在 `segment_time` 的基础上按窗口结束时刻裁剪:快到结束时间时缩短本段,
+/// 让录制正好停在窗口边界而不是冲出去一整段。等价于 Python 版 `get_duration()`,
+/// 但额外覆盖了「配了时间范围却没配分段时长」的情况——Python 那种情形下
+/// `-to` 不会下发,录制会一直冲到直播结束。
+pub fn clamp_segment_time(segment_time: Option<&str>, time_range: Option<&str>) -> Option {
+ clamp_segment_time_at(segment_time, time_range, now_seconds_from_midnight())
+}
+
+fn clamp_segment_time_at(
+ segment_time: Option<&str>,
+ time_range: Option<&str>,
+ now: u32,
+) -> Option {
+ let Some(range) = time_range.and_then(TimeRange::parse) else {
+ return segment_time.map(str::to_owned);
+ };
+ let remaining = range.seconds_until_end(now);
+
+ match segment_time {
+ // 分段时长无法解析时原样下发,交给下游工具自己报错
+ Some(configured) => match parse_hms(configured) {
+ Some(seconds) if seconds > remaining => Some(format_hms(remaining)),
+ Some(_) | None => Some(configured.to_owned()),
+ },
+ None => Some(format_hms(remaining)),
+ }
+}
+
+/// 解析 `"HH:MM:SS"` 为秒数。
+fn parse_hms(raw: &str) -> Option {
+ let [hours, minutes, seconds] =
+ <[&str; 3]>::try_from(raw.split(':').collect::>()).ok()?;
+ Some(
+ hours.parse::().ok()? * 3600
+ + minutes.parse::().ok()? * 60
+ + seconds.parse::().ok()?,
+ )
+}
+
+fn format_hms(total: u32) -> String {
+ format!(
+ "{:02}:{:02}:{:02}",
+ total / 3600,
+ (total % 3600) / 60,
+ total % 60
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// 把 "HH:MM:SS" 写成当日秒数,让用例读起来贴近配置里的时刻
+ fn at(hms: &str) -> u32 {
+ parse_hms(hms).expect("测试时刻格式应合法")
+ }
+
+ /// 前端 `Date.toISOString()` 写出的形态
+ fn range(start: &str, end: &str) -> String {
+ format!(r#"["2025-03-26T{start}.000Z","2025-03-27T{end}.000Z"]"#)
+ }
+
+ #[test]
+ fn missing_time_range_does_not_restrict_recording() {
+ assert!(is_within_at(None, at("03:00:00")));
+ assert_eq!(
+ clamp_segment_time_at(Some("01:00:00"), None, at("03:00:00")),
+ Some("01:00:00".to_string())
+ );
+ }
+
+ #[test]
+ fn malformed_time_range_falls_open_instead_of_blocking() {
+ // 坏配置只让时间范围失效,不能把主播整个录制掐掉
+ for raw in [
+ "",
+ "null",
+ "not json",
+ "[]",
+ r#"["2025-03-26T16:00:00.000Z"]"#,
+ r#"["a","b"]"#,
+ r#"["2025-03-26T16:00:00.000Z","2025-03-26T16:00:00.000Z","x"]"#,
+ ] {
+ assert!(is_within_at(Some(raw), at("03:00:00")), "raw={raw}");
+ assert_eq!(
+ clamp_segment_time_at(Some("01:00:00"), Some(raw), at("03:00:00")),
+ Some("01:00:00".to_string()),
+ "raw={raw}"
+ );
+ }
+ }
+
+ #[test]
+ fn normal_interval_admits_only_inside_the_window() {
+ let tr = range("16:00:00", "20:00:00");
+ assert!(!is_within_at(Some(&tr), at("15:59:59")));
+ assert!(is_within_at(Some(&tr), at("16:00:00"))); // 起点闭区间
+ assert!(is_within_at(Some(&tr), at("18:00:00")));
+ assert!(is_within_at(Some(&tr), at("20:00:00"))); // 终点闭区间
+ assert!(!is_within_at(Some(&tr), at("20:00:01")));
+ }
+
+ #[test]
+ fn cross_midnight_interval_admits_both_sides_of_midnight() {
+ let tr = range("23:00:00", "04:00:00");
+ assert!(is_within_at(Some(&tr), at("23:30:00")));
+ assert!(is_within_at(Some(&tr), at("00:00:00")));
+ assert!(is_within_at(Some(&tr), at("03:59:59")));
+ assert!(!is_within_at(Some(&tr), at("04:00:01")));
+ assert!(!is_within_at(Some(&tr), at("12:00:00")));
+ assert!(!is_within_at(Some(&tr), at("22:59:59")));
+ }
+
+ /// 前端存的是 UTC,判定也必须按 UTC,否则 UTC+8 用户会整体错开 8 小时
+ #[test]
+ fn offset_bearing_instants_are_converted_to_utc() {
+ // 16:00+08:00 == 08:00Z
+ let tr = r#"["2025-03-26T16:00:00+08:00","2025-03-26T20:00:00+08:00"]"#;
+ assert!(is_within_at(Some(tr), at("09:00:00")));
+ assert!(!is_within_at(Some(tr), at("17:00:00")));
+ }
+
+ #[test]
+ fn naive_instants_are_read_as_utc() {
+ let tr = r#"["2025-03-26T16:00:00","2025-03-26T20:00:00"]"#;
+ assert!(is_within_at(Some(tr), at("17:00:00")));
+ assert!(!is_within_at(Some(tr), at("09:00:00")));
+ }
+
+ #[test]
+ fn segment_time_is_untouched_while_the_window_end_is_far() {
+ let tr = range("16:00:00", "20:00:00");
+ assert_eq!(
+ clamp_segment_time_at(Some("01:00:00"), Some(&tr), at("16:30:00")),
+ Some("01:00:00".to_string())
+ );
+ }
+
+ #[test]
+ fn segment_time_is_shortened_near_the_window_end() {
+ let tr = range("16:00:00", "20:00:00");
+ // 19:40 距结束还有 20 分钟,小于 1 小时的分段时长
+ assert_eq!(
+ clamp_segment_time_at(Some("01:00:00"), Some(&tr), at("19:40:00")),
+ Some("00:20:00".to_string())
+ );
+ }
+
+ #[test]
+ fn segment_time_matching_the_remaining_time_is_not_shortened() {
+ let tr = range("16:00:00", "20:00:00");
+ assert_eq!(
+ clamp_segment_time_at(Some("01:00:00"), Some(&tr), at("19:00:00")),
+ Some("01:00:00".to_string())
+ );
+ }
+
+ #[test]
+ fn missing_segment_time_still_ends_at_the_window_end() {
+ // Python 版这种情况不下发 -to,会一直录到直播结束;这里补上
+ let tr = range("16:00:00", "20:00:00");
+ assert_eq!(
+ clamp_segment_time_at(None, Some(&tr), at("19:40:00")),
+ Some("00:20:00".to_string())
+ );
+ }
+
+ #[test]
+ fn remaining_time_of_a_cross_midnight_window_wraps_past_zero() {
+ let tr = range("23:00:00", "04:00:00");
+ // 23:30 距次日 04:00 还有 4 小时 30 分
+ assert_eq!(
+ clamp_segment_time_at(Some("10:00:00"), Some(&tr), at("23:30:00")),
+ Some("04:30:00".to_string())
+ );
+ }
+
+ #[test]
+ fn unparsable_segment_time_is_passed_through() {
+ let tr = range("16:00:00", "20:00:00");
+ for configured in ["", "abc", "1:2", "01:00:00:00", "-1:00:00"] {
+ assert_eq!(
+ clamp_segment_time_at(Some(configured), Some(&tr), at("19:40:00")),
+ Some(configured.to_string()),
+ "configured={configured}"
+ );
+ }
+ }
+
+ #[test]
+ fn sitting_exactly_on_the_window_end_leaves_zero_remaining() {
+ let tr = range("16:00:00", "20:00:00");
+ assert_eq!(
+ clamp_segment_time_at(Some("01:00:00"), Some(&tr), at("20:00:00")),
+ Some("00:00:00".to_string())
+ );
+ }
+
+ /// 窗口外 `end < now` 与跨午夜窗口的形态相同,若不先判定在不在窗口内,
+ /// 会算出「还剩近 24 小时」,反而放行一整天的录制
+ #[test]
+ fn outside_the_window_nothing_remains_instead_of_wrapping_to_tomorrow() {
+ let tr = range("16:00:00", "20:00:00");
+ assert_eq!(
+ clamp_segment_time_at(Some("01:00:00"), Some(&tr), at("20:00:01")),
+ Some("00:00:00".to_string())
+ );
+ assert_eq!(
+ remaining_until_end_at(Some(&tr), at("06:00:00")),
+ Some("00:00:00".to_string())
+ );
+ }
+}
diff --git a/crates/biliup-cli/src/server/core/downloader.rs b/crates/biliup-cli/src/server/core/downloader.rs
index 331bc2f..16250f6 100644
--- a/crates/biliup-cli/src/server/core/downloader.rs
+++ b/crates/biliup-cli/src/server/core/downloader.rs
@@ -6,6 +6,7 @@ pub mod stream_gears;
pub mod streamlink;
pub mod ytdlp;
+use crate::server::common::timerange;
use crate::server::common::util::Recorder;
use crate::server::core::downloader::ffmpeg_downloader::FfmpegDownloader;
use crate::server::core::downloader::stream_gears::StreamGears;
@@ -29,6 +30,10 @@ pub struct DownloadConfig {
/// 分段时长 (格式: "HH:MM:SS")
pub segment_time: Option,
+ /// 录制时间范围,两个 ISO 8601 时刻的 JSON 数组字符串。
+ /// 见 [`crate::server::common::timerange`]。
+ pub time_range: Option,
+
/// 分段文件大小限制 (字节)
pub file_size: Option,
@@ -52,6 +57,20 @@ impl DownloadConfig {
fn generate_output_filename(&self, suffix: &str) -> PathBuf {
self.output_dir.join(self.recorder.generate_path(suffix))
}
+
+ /// 本次录制块允许的最长时长(`"HH:MM:SS"`)。
+ ///
+ /// 即 `segment_time` 按录制时间范围的结束时刻裁剪后的结果:快到窗口结束时缩短本段,
+ /// 让录制停在窗口边界。各下载器都以「本段录多久」的语义使用它,
+ /// 因此必须用这个方法而不是直接读 [`Self::segment_time`]。
+ pub fn segment_duration(&self) -> Option {
+ timerange::clamp_segment_time(self.segment_time.as_deref(), self.time_range.as_deref())
+ }
+
+ /// 距录制时间范围结束还剩多久(`"HH:MM:SS"`);未配置录制时间范围时为 `None`。
+ pub fn time_range_remaining(&self) -> Option {
+ timerange::remaining_until_end(self.time_range.as_deref())
+ }
}
/// 下载器类型枚举
diff --git a/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs b/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs
index fc907ae..ef70165 100644
--- a/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs
+++ b/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs
@@ -81,6 +81,12 @@ impl FfmpegDownloader {
args.extend(["-segment_time".to_string(), seconds.to_string()]);
}
+ // -t: 录制总时长上限。内部分段由 segment muxer 自己切片、进程不会自行退出,
+ // 所以要用总时长把录制截停在录制时间范围的结束时刻。
+ if let Some(remaining) = download_config.time_range_remaining() {
+ args.extend(["-t".to_string(), remaining]);
+ }
+
// 添加通用输出参数
self.append_common_output_args(&mut args, "segment");
@@ -99,9 +105,9 @@ impl FfmpegDownloader {
self.append_common_input_args(&mut args, download_config);
// 外部分段特定的输出参数
- // -to: 限制录制时长
- if let Some(segment_time) = &download_config.segment_time {
- args.extend(["-to".to_string(), segment_time.clone()]);
+ // -to: 限制录制时长,快到录制时间范围结束时会被裁短,使录制停在窗口边界
+ if let Some(segment_time) = download_config.segment_duration() {
+ args.extend(["-to".to_string(), segment_time]);
}
// -fs: 限制文件大小(字节)
@@ -390,3 +396,108 @@ async fn spawn_log(
};
Ok(status)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::{Duration as ChronoDuration, SecondsFormat, Utc};
+
+ /// 以「当前时刻」为基准造一个录制时间范围,形态与前端 `Date.toISOString()` 写出的一致。
+ /// 相对当前时刻取值,因此走的是真实时钟,也顺带覆盖了窗口跨过零点的情形。
+ fn window(starts_in: i64, ends_in: i64) -> String {
+ let now = Utc::now();
+ let iso = |offset: i64| {
+ (now + ChronoDuration::seconds(offset)).to_rfc3339_opts(SecondsFormat::Millis, true)
+ };
+ format!(r#"["{}","{}"]"#, iso(starts_in), iso(ends_in))
+ }
+
+ fn config(segment_time: Option<&str>, time_range: Option) -> DownloadConfig {
+ DownloadConfig {
+ segment_time: segment_time.map(str::to_owned),
+ time_range,
+ suffix: "flv".to_string(),
+ ..Default::default()
+ }
+ }
+
+ fn value_of(args: &[String], flag: &str) -> Option {
+ let index = args.iter().position(|arg| arg == flag)?;
+ args.get(index + 1).cloned()
+ }
+
+ fn seconds_of(args: &[String], flag: &str) -> u32 {
+ let raw = value_of(args, flag).unwrap_or_else(|| panic!("命令行里应有 {flag}"));
+ let parts: Vec = raw
+ .split(':')
+ .map(|p| p.parse().expect("时长应为 HH:MM:SS"))
+ .collect();
+ parts[0] * 3600 + parts[1] * 60 + parts[2]
+ }
+
+ fn external() -> FfmpegDownloader {
+ FfmpegDownloader::new(Vec::new(), DownloaderType::FfmpegExternal)
+ }
+
+ fn internal() -> FfmpegDownloader {
+ FfmpegDownloader::new(Vec::new(), DownloaderType::FfmpegInternal)
+ }
+
+ #[test]
+ fn without_a_time_range_the_segment_time_reaches_ffmpeg_unchanged() {
+ let args = external().build_ffmpeg_args_external_segment(&config(Some("01:00:00"), None));
+ assert_eq!(value_of(&args, "-to"), Some("01:00:00".to_string()));
+ }
+
+ #[test]
+ fn without_a_segment_time_or_window_ffmpeg_gets_no_duration_limit() {
+ let args = external().build_ffmpeg_args_external_segment(&config(None, None));
+ assert_eq!(value_of(&args, "-to"), None);
+ }
+
+ #[test]
+ fn a_far_away_window_end_leaves_the_segment_time_alone() {
+ // 窗口还剩 2 小时,1 小时的分段时长不该被动
+ let args = external()
+ .build_ffmpeg_args_external_segment(&config(Some("01:00:00"), Some(window(-60, 7200))));
+ assert_eq!(value_of(&args, "-to"), Some("01:00:00".to_string()));
+ }
+
+ #[test]
+ fn a_near_window_end_shortens_the_segment_so_recording_stops_on_the_boundary() {
+ // 窗口只剩 10 分钟,1 小时的分段必须被裁到 10 分钟,否则会冲出窗口 50 分钟
+ let args = external()
+ .build_ffmpeg_args_external_segment(&config(Some("01:00:00"), Some(window(-60, 600))));
+ let to = seconds_of(&args, "-to");
+ assert!((595..=600).contains(&to), "-to 应约为 600 秒,实际 {to}");
+ }
+
+ #[test]
+ fn a_window_bounds_recording_even_when_no_segment_time_is_configured() {
+ // Python 版这种情况根本不下发 -to,会一直录到直播结束
+ let args =
+ external().build_ffmpeg_args_external_segment(&config(None, Some(window(-60, 600))));
+ let to = seconds_of(&args, "-to");
+ assert!((595..=600).contains(&to), "-to 应约为 600 秒,实际 {to}");
+ }
+
+ #[test]
+ fn internal_segmentation_caps_total_duration_at_the_window_end() {
+ // 内部分段的 -segment_time 只是切片间隔,进程不会自己退出,必须靠 -t 截停
+ let args = internal()
+ .build_ffmpeg_args_internal_segment(&config(Some("01:00:00"), Some(window(-60, 600))));
+ assert_eq!(value_of(&args, "-segment_time"), Some("3600".to_string()));
+ let total = seconds_of(&args, "-t");
+ assert!(
+ (595..=600).contains(&total),
+ "-t 应约为 600 秒,实际 {total}"
+ );
+ }
+
+ #[test]
+ fn internal_segmentation_has_no_total_cap_without_a_window() {
+ let args = internal().build_ffmpeg_args_internal_segment(&config(Some("01:00:00"), None));
+ assert_eq!(value_of(&args, "-segment_time"), Some("3600".to_string()));
+ assert_eq!(value_of(&args, "-t"), None);
+ }
+}
diff --git a/crates/biliup-cli/src/server/core/downloader/stream_gears.rs b/crates/biliup-cli/src/server/core/downloader/stream_gears.rs
index e3e739a..a912bb7 100644
--- a/crates/biliup-cli/src/server/core/downloader/stream_gears.rs
+++ b/crates/biliup-cli/src/server/core/downloader/stream_gears.rs
@@ -49,7 +49,11 @@ impl StreamGears {
let headers_in = construct_headers(&download_config.headers).map_err(AppError::Custom)?;
let proxy = self.proxy.clone();
let segment = Segmentable::new(
- download_config.segment_time.as_deref().map(parse_time),
+ // 快到录制时间范围结束时会被裁短,使录制停在窗口边界
+ download_config
+ .segment_duration()
+ .as_deref()
+ .map(parse_time),
download_config.file_size,
);
diff --git a/crates/biliup-cli/src/server/core/downloader/streamlink.rs b/crates/biliup-cli/src/server/core/downloader/streamlink.rs
index 836cf1f..a409d74 100644
--- a/crates/biliup-cli/src/server/core/downloader/streamlink.rs
+++ b/crates/biliup-cli/src/server/core/downloader/streamlink.rs
@@ -157,9 +157,10 @@ impl StreamlinkDownloader {
args.push("--http-header".to_string());
args.push(format!("{}={}", key, value));
}
- if let Some(segment_time) = &download_config.segment_time {
+ // 快到录制时间范围结束时会被裁短,使录制停在窗口边界
+ if let Some(segment_time) = download_config.segment_duration() {
args.push("--hls-duration".to_string());
- args.push(segment_time.clone());
+ args.push(segment_time);
}
args.push("--force".to_string());
args.push("--output".to_string());
diff --git a/crates/biliup-cli/src/server/core/monitor.rs b/crates/biliup-cli/src/server/core/monitor.rs
index 7e9455c..a929dab 100644
--- a/crates/biliup-cli/src/server/core/monitor.rs
+++ b/crates/biliup-cli/src/server/core/monitor.rs
@@ -1,4 +1,5 @@
use crate::server::common::download::start_download_workflow;
+use crate::server::common::recording_policy;
use crate::server::common::upload::UploaderMessage;
use crate::server::core::live::{batch_check_request, live_request, streamer_info};
use crate::server::infrastructure::connection_pool::ConnectionPool;
@@ -34,9 +35,10 @@ enum BatchVerdict {
Fallback,
}
-/// 批量结果为未开播时的快速轮换间隔:缓存命中无网络开销,
-/// 短暂停即可在一个检测周期内扫完整个队列(网络频率仍由缓存 TTL 限制)。
-const BATCH_ROTATE_SLEEP: Duration = Duration::from_secs(1);
+/// 无网络开销的跳过路径(批量结果判定未开播、不在录制时间范围内)使用的快速轮换间隔:
+/// 这些判定不发请求,短暂停即可在一个检测周期内扫完整个队列,
+/// 也让窗口一开就能立刻开录(网络频率仍由批量缓存 TTL 限制)。
+const QUICK_ROTATE_SLEEP: Duration = Duration::from_secs(1);
/// 房间处理器
/// 管理多个直播间的状态和操作
@@ -118,6 +120,15 @@ impl Monitor {
.await;
let url = room.get_streamer().url.clone();
let interval = room.get_config().event_loop_interval;
+ // 探测前的录制策略:不满足就直接放回队列,既不占用下载槽位也不发任何请求。
+ // 这类条件只看配置和时钟、判定无开销,所以用快速轮换而不是整个检测周期,
+ // 条件一满足即可开录。
+ if let Some(rejection) = recording_policy::reject_before_probe(room.get_streamer()) {
+ self.wake_waker(room.id()).await;
+ debug!(url = url, reason = %rejection, "跳过检测");
+ tokio::time::sleep(QUICK_ROTATE_SLEEP).await;
+ continue;
+ }
// 批量检测平台:先用一个检测周期内共享的批量结果快速判定是否开播。
// 未开播直接跳过(不占用下载槽位、不做逐间请求),并以较短间隔轮换到
// 下一个房间,使整条队列在一个检测周期内扫完;批量请求本身由缓存 TTL 限流。
@@ -129,7 +140,7 @@ impl Monitor {
BatchVerdict::Offline => {
self.wake_waker(room.id()).await;
debug!(url = url, "批量检测未开播");
- tokio::time::sleep(BATCH_ROTATE_SLEEP).await;
+ tokio::time::sleep(QUICK_ROTATE_SLEEP).await;
continue;
}
// Live / Fallback 都继续走下面的逐间完整检测
@@ -145,6 +156,18 @@ impl Monitor {
// 检查直播状态
match plugin.check_stream(request).await {
Ok(LiveStatus::Live { stream }) => {
+ // 依赖房间标题的策略要拿到流信息才能判定。命中就按「本轮不录」处理:
+ // 不建 StreamerInfo 记录、不启动下载,等下个检测周期再看。
+ if let Some(rejection) =
+ recording_policy::reject_before_record(room.get_streamer(), &stream.title)
+ {
+ room.set_rejection(Some(rejection.clone()));
+ self.wake_waker(room.id()).await;
+ info!(url = url, title = stream.title, reason = %rejection, "开播但不录制");
+ tokio::time::sleep(Duration::from_secs(interval)).await;
+ continue;
+ }
+ room.set_rejection(None);
let sql_no_id = streamer_info(&stream);
let insert = match StreamerInfo::builder()
.url(sql_no_id.url.clone())
@@ -179,10 +202,13 @@ impl Monitor {
info!("成功开始录制 {}", url);
}
Ok(LiveStatus::Offline) => {
+ // 探测结果推翻了上一轮的策略判定,清掉以免界面停在旧原因上
+ room.set_rejection(None);
self.wake_waker(room.id()).await;
debug!(url = room.get_streamer().url, "未开播")
}
Err(e) => {
+ room.set_rejection(None);
self.wake_waker(room.id()).await;
error!(e=?e, ctx=room.get_streamer().url,"检查直播间出错")
}
diff --git a/crates/biliup-cli/src/server/infrastructure/context.rs b/crates/biliup-cli/src/server/infrastructure/context.rs
index b4e12e1..eccf343 100644
--- a/crates/biliup-cli/src/server/infrastructure/context.rs
+++ b/crates/biliup-cli/src/server/infrastructure/context.rs
@@ -1,4 +1,5 @@
use crate::server::common::download::DownloadTask;
+use crate::server::common::recording_policy::Rejection;
use crate::server::common::util::Recorder;
use crate::server::config::Config;
use crate::server::core::downloader::DownloadConfig;
@@ -121,6 +122,7 @@ impl Context {
// 流URL
url: stream.raw_stream_url.to_string(),
segment_time: config.segment_time,
+ time_range: self.live_streamer().time_range.clone(),
file_size: config.file_size,
headers: stream.stream_headers.clone(),
recorder: self.recorder(stream_info),
@@ -146,6 +148,11 @@ pub struct Worker {
config: Arc>,
/// HTTP客户端
pub client: StatelessClient,
+ /// 最近一次开播探测被录制策略挡下的原因,仅用于向界面解释「为什么没在录」。
+ ///
+ /// 不参与任何控制流。只存需要流信息才能判定的结论(如标题命中排除关键词);
+ /// 探测前就能判定的条件(如录制时间范围)由接口侧按当前时钟实时算,不会过期。
+ last_rejection: RwLock