fix guard effects
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user