diff --git a/apps/overlay/src/guardEffect.tsx b/apps/overlay/src/guardEffect.tsx index f3c0557..b8db452 100644 --- a/apps/overlay/src/guardEffect.tsx +++ b/apps/overlay/src/guardEffect.tsx @@ -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 (
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, +} + +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 { let UpstreamLiveEvent::Command(command) = event else { return None; @@ -507,11 +559,17 @@ fn normalize_command(command: LiveCommand) -> Option { command, raw, error: _, - } => normalize_raw(&raw).or_else(|| { - Some(ProviderEvent::Unknown { - command: sanitized_command(command.as_deref()), - }) - }), + } => { + 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 { }; 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 { ) }), }), - "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 { } } +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, +) -> Option { + 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) -> Option<&'static str> { + match level { + Some(1) => Some("总督"), + Some(2) => Some("提督"), + Some(3) => Some("舰长"), + _ => None, + } +} + fn value_i64(value: &Value) -> Option { 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!({ diff --git a/docs/components/guard-effect.md b/docs/components/guard-effect.md index 5eccf4a..83202f7 100644 --- a/docs/components/guard-effect.md +++ b/docs/components/guard-effect.md @@ -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 断线重连后只接收新事件。 diff --git a/docs/protocol.md b/docs/protocol.md index b56377d..7aaed77 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 视觉节流,不是可靠业务队列,也不会在重连后恢复。 diff --git a/resources/i18n.toml b/resources/i18n.toml index fb58cf6..781097c 100644 --- a/resources/i18n.toml +++ b/resources/i18n.toml @@ -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"