fix guard effects

This commit is contained in:
2026-08-28 21:28:53 -07:00
parent e5186f62bd
commit 1020c75e37
5 changed files with 255 additions and 28 deletions
+1 -1
View File
@@ -142,7 +142,7 @@ function JadeGuardVoyage({
const level = guardLevel(effect.payload.guardName)
const guard = translate(`gift_menu.guard.${level}`)
const viewer =
effect.payload.viewer?.uid || effect.payload.viewer?.name || translate('common.viewer')
effect.payload.viewer?.name || effect.payload.viewer?.uid || translate('common.viewer')
const title = settings.titleTemplate.replaceAll('{guard}', guard)
return (
<section
+243 -19
View File
@@ -6,7 +6,11 @@
//! modeled by `libilibili` are interpreted only inside this provider boundary;
//! unknown events expose sanitized command names, never raw packets or secrets.
use std::{collections::HashMap, sync::Arc, time::Duration};
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use async_trait::async_trait;
use libilibili::{
@@ -36,6 +40,7 @@ use crate::{
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20);
const RECONNECT_BACKOFF: Duration = Duration::from_secs(3);
const GUARD_DUPLICATE_WINDOW: Duration = Duration::from_secs(3);
#[derive(Clone)]
pub struct BilibiliProvider {
@@ -266,6 +271,7 @@ impl LiveProvider for BilibiliProvider {
) -> Result<(), String> {
self.initial_catalogs(&context.room_id).await;
self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone());
let mut guard_deduplicator = GuardEventDeduplicator::default();
let room_id = context
.room_id
@@ -346,6 +352,9 @@ impl LiveProvider for BilibiliProvider {
let Some(raw) = normalize_upstream(upstream) else {
continue;
};
if !guard_deduplicator.should_emit(&raw, Instant::now()) {
continue;
}
if !received_event {
received_event = true;
info!(room_id = %context.room_id, "libilibili listener received its first live event");
@@ -472,6 +481,49 @@ enum ProviderEvent {
},
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct GuardEventFingerprint {
viewer_uid: String,
level: String,
}
#[derive(Default)]
struct GuardEventDeduplicator {
recent: HashMap<GuardEventFingerprint, Instant>,
}
impl GuardEventDeduplicator {
fn should_emit(&mut self, event: &ProviderEvent, now: Instant) -> bool {
let ProviderEvent::Guard { viewer, name, .. } = event else {
return true;
};
self.recent
.retain(|_, seen| now.saturating_duration_since(*seen) <= GUARD_DUPLICATE_WINDOW);
let fingerprint = GuardEventFingerprint {
viewer_uid: viewer.uid.clone(),
level: guard_level_key(name),
};
if self.recent.contains_key(&fingerprint) {
return false;
}
self.recent.insert(fingerprint, now);
true
}
}
fn guard_level_key(name: &str) -> String {
let normalized = name.trim().to_lowercase();
if normalized.contains("总督") || normalized.contains("governor") {
"governor".to_owned()
} else if normalized.contains("提督") || normalized.contains("admiral") {
"admiral".to_owned()
} else if normalized.contains("舰长") || normalized.contains("captain") {
"captain".to_owned()
} else {
normalized
}
}
fn normalize_upstream(event: UpstreamLiveEvent) -> Option<ProviderEvent> {
let UpstreamLiveEvent::Command(command) = event else {
return None;
@@ -507,11 +559,17 @@ fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
command,
raw,
error: _,
} => normalize_raw(&raw).or_else(|| {
} => {
if ignored_raw_command(&raw) {
None
} else {
normalize_raw(&raw).or_else(|| {
Some(ProviderEvent::Unknown {
command: sanitized_command(command.as_deref()),
})
}),
})
}
}
LiveCommand::OnlineRankCount(message) => unknown(message.cmd),
LiveCommand::WatchedChange(message) => unknown(message.cmd),
LiveCommand::StopLiveRoomList(message) => unknown(message.cmd),
@@ -839,9 +897,13 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
};
let data_viewer = |value: &Value| {
viewer(
value.get("uid")?,
value
.get("uid")
.or_else(|| value.pointer("/sender_uinfo/uid"))
.or_else(|| value.pointer("/user_info/uid"))?,
value
.get("uname")
.or_else(|| value.get("username"))
.or_else(|| value.pointer("/sender_uinfo/base/name"))
.or_else(|| value.pointer("/user_info/uname"))?,
value
@@ -890,21 +952,9 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
)
}),
}),
"GUARD_BUY" => Some(ProviderEvent::Guard {
viewer: data_viewer(data)?,
name: data
.get("gift_name")
.or_else(|| data.get("giftName"))
.and_then(Value::as_str)
.unwrap_or("舰长")
.to_owned(),
quantity: data
.get("num")
.and_then(value_i64)
.map(bounded_signed_i32)
.unwrap_or(1),
price: data.get("price").and_then(value_i64).unwrap_or(0),
}),
"GUARD_BUY" | "USER_TOAST_MSG" | "USER_TOAST_MSG_V2" => {
normalize_raw_guard(data, &data_viewer)
}
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat {
viewer: data_viewer(data)?,
message: data
@@ -925,6 +975,68 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
}
}
fn ignored_raw_command(raw: &Value) -> bool {
let command = raw
.get("cmd")
.and_then(Value::as_str)
.and_then(|value| value.split(':').next());
let data = raw.get("data").unwrap_or(raw);
matches!(command, Some("USER_TOAST_MSG" | "USER_TOAST_MSG_V2"))
&& data
.pointer("/option/source")
.or_else(|| data.get("source"))
.and_then(value_i64)
== Some(2)
}
fn normalize_raw_guard(
data: &Value,
data_viewer: &impl Fn(&Value) -> Option<PlatformViewer>,
) -> Option<ProviderEvent> {
let guard_level = data
.pointer("/guard_info/guard_level")
.or_else(|| data.get("guard_level"))
.and_then(value_i64);
let name = data
.pointer("/gift_info/gift_name")
.or_else(|| data.get("gift_name"))
.or_else(|| data.get("giftName"))
.or_else(|| data.get("role_name"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.or_else(|| guard_name_from_level(guard_level).map(ToOwned::to_owned))
.unwrap_or_else(|| "舰长".to_owned());
let quantity = data
.pointer("/pay_info/num")
.or_else(|| data.get("num"))
.and_then(value_i64)
.map(bounded_signed_i32)
.unwrap_or(1)
.max(1);
let price = data
.pointer("/pay_info/price")
.or_else(|| data.get("price"))
.and_then(value_i64)
.unwrap_or(0)
.max(0);
Some(ProviderEvent::Guard {
viewer: data_viewer(data)?,
name,
quantity,
price,
})
}
fn guard_name_from_level(level: Option<i64>) -> Option<&'static str> {
match level {
Some(1) => Some("总督"),
Some(2) => Some("提督"),
Some(3) => Some("舰长"),
_ => None,
}
}
fn value_i64(value: &Value) -> Option<i64> {
value
.as_i64()
@@ -1478,6 +1590,118 @@ mod tests {
}
}
#[test]
fn normalizes_nested_guard_buy_with_username() {
let event = normalize_command(parse_command(json!({
"cmd":"GUARD_BUY",
"data":{
"uid":789,
"username":"上舰观众",
"guard_level":3,
"num":1,
"price":198000,
"gift_id":10003,
"gift_name":"舰长",
"start_time":1_755_000_000,
"end_time":1_755_000_000
}
})))
.unwrap();
match event {
ProviderEvent::Guard {
viewer,
name,
quantity,
price,
} => {
assert_eq!(viewer.uid, "789");
assert_eq!(viewer.name, "上舰观众");
assert_eq!(name, "舰长");
assert_eq!(quantity, 1);
assert_eq!(price, 198_000);
}
_ => panic!("expected guard purchase"),
}
}
#[test]
fn normalizes_current_user_toast_guard_and_ignores_mirror_source() {
let command = |source| {
parse_command(json!({
"cmd":"USER_TOAST_MSG_V2",
"data":{
"sender_uinfo":{
"uid":987,
"base":{
"name":"续舰观众",
"face":"//i0.hdslb.com/bfs/face/guard.jpg"
}
},
"guard_info":{
"guard_level":2,
"start_time":1_755_000_001,
"end_time":1_755_000_001
},
"pay_info":{"num":2,"price":1_998_000,"unit":"月"},
"gift_info":{"gift_id":10002},
"option":{"source":source},
"toast_msg":"续费大航海"
}
}))
};
let event = normalize_command(command(0)).unwrap();
match event {
ProviderEvent::Guard {
viewer,
name,
quantity,
price,
} => {
assert_eq!(viewer.uid, "987");
assert_eq!(viewer.name, "续舰观众");
assert_eq!(
viewer.avatar_url.as_deref(),
Some("https://i0.hdslb.com/bfs/face/guard.jpg")
);
assert_eq!(name, "提督");
assert_eq!(quantity, 2);
assert_eq!(price, 1_998_000);
}
_ => panic!("expected guard purchase"),
}
assert!(normalize_command(command(2)).is_none());
}
#[test]
fn duplicate_guard_notifications_ignore_accounting_differences() {
let guard = |name: &str, quantity, price| ProviderEvent::Guard {
viewer: PlatformViewer {
uid: "987".into(),
name: "上舰观众".into(),
avatar_url: None,
},
name: name.into(),
quantity,
price,
};
let first = guard("舰长", 1, 198_000);
let duplicate = guard("舰长月卡", 12, 1_998_000);
let different_level = guard("提督", 1, 1_998_000);
let later = guard("舰长", 1, 198_000);
let now = Instant::now();
let mut deduplicator = GuardEventDeduplicator::default();
assert!(deduplicator.should_emit(&first, now));
assert!(!deduplicator.should_emit(&duplicate, now + Duration::from_secs(1)));
assert!(deduplicator.should_emit(&different_level, now + Duration::from_secs(1)));
assert!(deduplicator.should_emit(
&later,
now + GUARD_DUPLICATE_WINDOW + Duration::from_millis(1)
));
}
#[test]
fn normalizes_typed_like_clicks_with_a_viewer() {
let event = normalize_command(parse_command(json!({
+3 -2
View File
@@ -9,8 +9,9 @@ token、`publicId` 和 WebSocket 广播通道。它只订阅 `live.guard.buy`,
## 青玉星落
舰长、提督或总督分别选择 `captain.webm`、`admiral.webm` 或 `general.webm`
全屏播放。视频约 8 秒,首尾各淡入/淡出 0.5 秒;第 4 秒开始在中央横向展开行书卷轴,依次显示可编辑的等级标题、开通或续费用户 UID,以及可编辑的收尾文字。标题模板可使用
`{guard}` 插入规范化的“舰长/提督/总督”,默认三行为“{guard}启航”、UID、“相伴前行”。
全屏播放。视频约 8 秒,首尾各淡入/淡出 0.5 秒;第 4 秒开始在中央横向展开行书卷轴,依次显示可编辑的等级标题、开通或续费用户名,以及可编辑的收尾文字。标题模板可使用
`{guard}`
插入规范化的“舰长/提督/总督”,默认三行为“{guard}启航”、用户名、“相伴前行”。用户名缺失时才回退显示 UID。
大航海事件进入浏览器内存中的有界 FIFO,当前约八秒的视频或月夜庆祝完整结束后才播放下一项,不会覆盖正在播放的用户。队列按事件到达顺序排列并去重,容量可在控制台设置;达到上限时保留正在播放及已等待的事件,忽略新到达事件。该视觉队列不持久化,OBS 断线重连后只接收新事件。
+3 -1
View File
@@ -122,7 +122,9 @@ close 结束连接。
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
`guard_effect` 只订阅
`live.guard.buy`。舰长、提督和总督事件进入该组件独立的广播通道;它不接收普通礼物或礼物连击,也不维护状态快照。
`live.guard.buy`。舰长、提督和总督事件进入该组件独立的广播通道;它不接收普通礼物或礼物连击,也不维护状态快照。服务端会将 Bilibili 的
`GUARD_BUY`、`USER_TOAST_MSG` 与当前的 `USER_TOAST_MSG_V2` 统一规范化为该事件;V2 的 `source = 2`
镜像通知会被忽略,同一购买产生的重叠通知也会在短时间窗口内去重。
`gift_effect` 与 `guard_effect`
的浏览器渲染器分别维护有界 FIFO,并且每次只播放一个 active 特效。动画完成后按到达顺序推进下一项;这个队列仅用于 OBS 视觉节流,不是可靠业务队列,也不会在重连后恢复。
+2 -2
View File
@@ -448,7 +448,7 @@ name = "简体中文"
"guard.settings.title_template" = "第一行"
"guard.settings.title_template_description" = "使用 {guard} 插入舰长、提督或总督;默认“{guard}启航”。"
"guard.settings.closing_text" = "第三行"
"guard.settings.closing_text_description" = "显示在用户 UID 下方;默认“相伴前行”。"
"guard.settings.closing_text_description" = "显示在用户名下方;默认“相伴前行”。"
"guard.settings.queue_capacity" = "最大等待大航海数"
"gift.settings.queue_capacity" = "最大等待礼物数"
"gift.preview.title" = "全屏礼物特效预览"
@@ -978,7 +978,7 @@ name = "English"
"guard.settings.title_template" = "First line"
"guard.settings.title_template_description" = "Use {guard} to insert the Guard, Admiral, or Governor tier; the default is “{guard}启航”."
"guard.settings.closing_text" = "Third line"
"guard.settings.closing_text_description" = "Shown below the viewer UID; the default is “相伴前行”."
"guard.settings.closing_text_description" = "Shown below the viewer name; the default is “相伴前行”."
"guard.settings.queue_capacity" = "Maximum queued memberships"
"gift.settings.queue_capacity" = "Maximum queued gifts"
"gift.preview.title" = "Full-screen gift effect preview"