//! Danmaku-overlay settings plus Bilibili gift and emoticon metadata caches. //! //! Settings are sanitized before persistence or projection. Catalog refreshes //! replace the in-memory snapshot only after a complete valid response, so a //! transient upstream failure preserves the last known gift images, prices and //! emoticon URLs instead of breaking live rendering. use std::{collections::HashMap, sync::Arc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::RwLock; use uuid::Uuid; use crate::typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness}; /// Stable identifier for a renderer theme. /// /// This enum is deliberately shared by defaults, validation and serialization: /// persisted component settings can therefore never reference a theme that the /// deployed frontend does not know how to render. Add a variant only together /// with its frontend theme definition and stylesheet. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum OverlayThemeId { #[default] JadeScroll, MoonlitWater, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OverlaySettings { /// Visual theme selected for this tenant-owned component instance. #[serde(default)] pub theme_id: OverlayThemeId, #[serde(default)] pub font_family: FontFamilyId, #[serde(default = "default_font_brightness")] pub font_brightness: u16, /// Optional per-component overrides. `None` keeps the selected theme's /// palette, which makes future theme changes immediately visible. #[serde(default)] pub viewer_color: Option, #[serde(default)] pub danmaku_color: Option, #[serde(default = "default_font_scale")] pub font_scale: u16, /// Relative weight of theme borders, rules, and ornamental edge artwork. #[serde(default = "default_decoration_line_weight")] pub decoration_line_weight: u16, pub show_danmaku: bool, pub show_enter: bool, pub show_gift: bool, pub show_superchat: bool, pub show_guard: bool, pub show_like: bool, pub show_share: bool, pub max_visible: u8, pub collapse_after_seconds: u16, #[serde(default = "default_unfold_duration_ms")] pub unfold_duration_ms: u16, pub motion_intensity: u8, #[serde(default = "default_particle_count")] pub particle_count: u8, #[serde(default = "default_particle_speed")] pub particle_speed: u16, pub low_performance_mode: bool, /// Bilibili gift-panel prices are in thousandths of a yuan. pub high_value_threshold: i64, pub featured_value_threshold: i64, } impl Default for OverlaySettings { fn default() -> Self { Self { theme_id: OverlayThemeId::default(), font_family: FontFamilyId::default(), font_brightness: default_font_brightness(), viewer_color: None, danmaku_color: None, font_scale: default_font_scale(), decoration_line_weight: default_decoration_line_weight(), show_danmaku: true, show_enter: true, show_gift: true, show_superchat: true, show_guard: true, show_like: false, show_share: false, max_visible: 5, collapse_after_seconds: 12, unfold_duration_ms: default_unfold_duration_ms(), motion_intensity: 70, particle_count: default_particle_count(), particle_speed: default_particle_speed(), low_performance_mode: false, high_value_threshold: 10_000, featured_value_threshold: 100_000, } } } impl OverlaySettings { pub fn sanitize(mut self) -> Self { self.max_visible = self.max_visible.clamp(1, 12); self.font_brightness = sanitize_font_brightness(self.font_brightness); self.viewer_color = sanitize_optional_hex_color(self.viewer_color); self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color); self.font_scale = self.font_scale.clamp(50, 300); self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300); self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120); self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000); self.motion_intensity = self.motion_intensity.min(100); self.particle_count = self.particle_count.min(12); self.particle_speed = self.particle_speed.clamp(25, 300); self.high_value_threshold = self.high_value_threshold.max(0); self.featured_value_threshold = self.featured_value_threshold.max(self.high_value_threshold); self } } fn sanitize_optional_hex_color(value: Option) -> Option { let value = value?.trim().to_ascii_uppercase(); (value.len() == 7 && value.starts_with('#') && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())) .then_some(value) } fn default_font_scale() -> u16 { 140 } fn default_decoration_line_weight() -> u16 { 160 } fn default_unfold_duration_ms() -> u16 { 1_000 } fn default_particle_count() -> u8 { 8 } fn default_particle_speed() -> u16 { 100 } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GiftMeta { pub id: Option, pub name: String, pub coin_type: String, pub battery_value: i64, pub unit_price: i64, pub image_url: Option, pub animation_url: Option, pub effect_type: Option, pub stay_time: Option, } #[derive(Clone, Default)] pub struct GiftCatalog { by_id: Arc>>, by_name: Arc>>, } /// Account-keyed catalog handles shared by the live provider and control API. /// Each account receives a distinct [`GiftCatalog`], so switching or refreshing /// one room can never replace another tenant's gift metadata. #[derive(Clone, Default)] pub struct GiftCatalogRegistry { catalogs: Arc>>, } impl GiftCatalogRegistry { pub async fn catalog(&self, owner_id: Uuid) -> GiftCatalog { if let Some(catalog) = self.catalogs.read().await.get(&owner_id).cloned() { return catalog; } let mut catalogs = self.catalogs.write().await; catalogs.entry(owner_id).or_default().clone() } } #[derive(Clone, Debug)] pub struct EmoticonMeta { pub emoji: String, pub unique: Option, pub url: String, pub width: Option, pub height: Option, pub is_dynamic: bool, pub bulge_display: bool, } #[derive(Clone, Default)] pub struct EmoticonCatalog { by_unique: Arc>>, by_emoji: Arc>>, } // Parser return aliases keep the atomic cache-replacement contract visible: // both lookup maps and their source count are produced before either lock is // updated, so readers never observe a half-refreshed catalog. type GiftCatalogSnapshot = (HashMap, HashMap, usize); type EmoticonCatalogSnapshot = ( HashMap, HashMap, usize, ); impl EmoticonCatalog { pub async fn len(&self) -> usize { self.by_unique.read().await.len() } pub async fn is_empty(&self) -> bool { self.len().await == 0 } pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option { if let Some(unique) = unique.filter(|value| !value.is_empty()) && let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() { return Some(emoticon); } self.by_emoji.read().await.get(emoji).cloned() } pub async fn refresh( &self, room_id: &str, cookie: &str, timeout_seconds: u64, ) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(timeout_seconds)) .build() .map_err(|error| error.to_string())?; let response = client .get("https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons") .query(&[("platform", "pc"), ("room_id", room_id)]) .header(reqwest::header::COOKIE, cookie) .header(reqwest::header::REFERER, "https://live.bilibili.com/") .header( reqwest::header::USER_AGENT, "Mozilla/5.0 lxc-streamutils/1.0", ) .send() .await .map_err(|error| error.to_string())?; if !response.status().is_success() { return Err(format!("emoticon API HTTP {}", response.status())); } let payload: Value = response.json().await.map_err(|error| error.to_string())?; if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 { return Err(format!( "emoticon API error: {}", payload .get("message") .and_then(Value::as_str) .unwrap_or("unknown") )); } let (unique, emoji, count) = parse_emoticon_catalog(&payload)?; *self.by_unique.write().await = unique; *self.by_emoji.write().await = emoji; Ok(count) } } impl GiftCatalog { pub async fn len(&self) -> usize { self.by_id.read().await.len() } pub async fn is_empty(&self) -> bool { self.len().await == 0 } pub async fn get(&self, id: Option, name: &str) -> Option { if let Some(id) = id && let Some(gift) = self.by_id.read().await.get(&id).cloned() { return Some(gift); } self.by_name .read() .await .get(&normalize_name(name)) .cloned() } /// Return a stable, deduplicated control-console view ordered by price and /// name. ID-backed entries are preferred because names are not unique. pub async fn list(&self) -> Vec { let mut gifts: Vec<_> = self.by_id.read().await.values().cloned().collect(); if gifts.is_empty() { gifts.extend(self.by_name.read().await.values().cloned()); } gifts.sort_by(|left, right| { left.unit_price .cmp(&right.unit_price) .then_with(|| left.name.cmp(&right.name)) }); gifts } pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(timeout_seconds)) .build() .map_err(|e| e.to_string())?; let response = client .get("https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList") // Despite the endpoint name, the current API only includes // gift_config for platform=pc. platform=web may return code=0 // with an empty room_gift_list and no catalog. .query(&[("platform", "pc"), ("room_id", room_id)]) .send() .await .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("gift panel HTTP {}", response.status())); } let payload: Value = response.json().await.map_err(|e| e.to_string())?; if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 { return Err(format!( "gift panel API error: {}", payload .get("message") .and_then(Value::as_str) .unwrap_or("unknown") )); } let (ids, names, count) = parse_catalog(&payload)?; *self.by_id.write().await = ids; *self.by_name.write().await = names; Ok(count) } } fn parse_catalog(payload: &Value) -> Result { let list = payload .pointer("/data/gift_config/base_config/list") .and_then(Value::as_array) .ok_or("gift panel response has no gift list")?; let mut ids = HashMap::new(); let mut names = HashMap::new(); for item in list { let Some(name) = item.get("name").and_then(Value::as_str) else { continue; }; let id = item.get("id").and_then(Value::as_i64); let unit_price = item.get("price").and_then(Value::as_i64).unwrap_or(0); let gift = GiftMeta { id, name: name.to_owned(), coin_type: item .get("coin_type") .and_then(Value::as_str) .unwrap_or("gold") .to_owned(), battery_value: gift_price_to_batteries(unit_price), unit_price, image_url: string_field(item, "img_basic"), animation_url: string_field(item, "gif"), effect_type: item.get("effect").and_then(|value| match value { Value::String(value) if !value.is_empty() => Some(value.clone()), Value::Number(value) => Some(value.to_string()), _ => None, }), stay_time: item.get("stay_time").and_then(Value::as_i64), }; if let Some(id) = id { ids.insert(id, gift.clone()); } names.insert(normalize_name(name), gift); } if ids.is_empty() && names.is_empty() { return Err("gift panel returned an empty catalog".into()); } Ok((ids, names, list.len())) } /// Bilibili's gift panel and live messages express paid gift prices in gold /// coins rather than batteries. One battery is 100 gold coins (and ¥0.1). pub fn gift_price_to_batteries(raw_price: i64) -> i64 { raw_price.max(0) / 100 } fn string_field(value: &Value, name: &str) -> Option { value .get(name) .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn parse_emoticon_catalog(payload: &Value) -> Result { let packages = payload .pointer("/data/data") .and_then(Value::as_array) .ok_or("emoticon response has no package list")?; let mut by_unique = HashMap::new(); let mut by_emoji = HashMap::new(); let mut count = 0; for item in packages .iter() .filter_map(|package| package.get("emoticons").and_then(Value::as_array)) .flatten() { let emoji = item .get("emoji") .and_then(Value::as_str) .unwrap_or_default() .to_owned(); let unique = item .get("emoticon_unique") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let Some(url) = item .get("url") .and_then(Value::as_str) .and_then(normalize_image_url) else { continue; }; let emoticon = EmoticonMeta { emoji: emoji.clone(), unique: unique.clone(), url, width: number_as_u32(item.get("width")), height: number_as_u32(item.get("height")), is_dynamic: truthy(item.get("is_dynamic")), bulge_display: truthy(item.get("bulge_display")), }; if let Some(unique) = unique { by_unique.insert(unique, emoticon.clone()); } if !emoji.is_empty() { by_emoji.insert(emoji, emoticon); } count += 1; } if count == 0 { return Err("emoticon API returned an empty catalog".into()); } Ok((by_unique, by_emoji, count)) } pub fn normalize_image_url(url: &str) -> Option { let url = url.trim(); if let Some(rest) = url.strip_prefix("http://") { Some(format!("https://{rest}")) } else if url.starts_with("https://") { Some(url.to_owned()) } else if url.starts_with("//") { Some(format!("https:{url}")) } else { None } } fn number_as_u32(value: Option<&Value>) -> Option { value.and_then(|value| { value .as_u64() .and_then(|value| u32::try_from(value).ok()) .or_else(|| value.as_str()?.parse().ok()) }) } fn truthy(value: Option<&Value>) -> bool { value.is_some_and(|value| { value.as_bool().unwrap_or(false) || value.as_i64().is_some_and(|value| value != 0) || value.as_str().is_some_and(|value| value == "1") }) } fn normalize_name(name: &str) -> String { name.split_whitespace() .collect::>() .join(" ") .to_lowercase() } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn gift_catalog_registry_isolates_account_snapshots() { let registry = GiftCatalogRegistry::default(); let first = registry.catalog(Uuid::new_v4()).await; let second = registry.catalog(Uuid::new_v4()).await; first.by_id.write().await.insert( 1, GiftMeta { id: Some(1), name: "小花花".into(), coin_type: "gold".into(), battery_value: 1, unit_price: 100, image_url: None, animation_url: None, effect_type: None, stay_time: None, }, ); assert_eq!(first.list().await.len(), 1); assert!(second.list().await.is_empty()); } use serde_json::json; #[test] fn parses_current_gift_panel_fields_and_indexes_by_id_and_name() { let payload = json!({"data":{"gift_config":{"base_config":{"list":[{ "id":42,"name":"青玉灯","price":30000,"coin_type":"gold","img_basic":"https://example.test/gift.png", "gif":"https://example.test/gift.gif","effect":2,"stay_time":3 }]}}}}); let (ids, names, count) = parse_catalog(&payload).expect("catalog"); assert_eq!(count, 1); assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000); assert_eq!(ids.get(&42).expect("id index").battery_value, 300); assert_eq!( names .get("青玉灯") .expect("name index") .effect_type .as_deref(), Some("2") ); } #[test] fn converts_raw_gold_coin_prices_to_batteries() { assert_eq!(gift_price_to_batteries(100), 1); assert_eq!(gift_price_to_batteries(15_000), 150); assert_eq!(gift_price_to_batteries(-1), 0); } #[test] fn rejects_an_empty_catalog_without_replacing_the_cache() { let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}}); assert!(parse_catalog(&payload).is_err()); } #[tokio::test] async fn parses_emoticon_packages_and_normalizes_image_urls() { let payload = json!({"code":0,"data":{"data":[{"emoticons":[{ "emoji":"[热]", "emoticon_unique":"emoji_278", "url":"http://i0.hdslb.com/bfs/live/hot.png", "width":20, "height":20, "is_dynamic":1, "bulge_display":0 }]}]}}); let (by_unique, by_emoji, count) = parse_emoticon_catalog(&payload).expect("emoticon catalog"); assert_eq!(count, 1); assert_eq!( by_unique.get("emoji_278").expect("unique index").url, "https://i0.hdslb.com/bfs/live/hot.png" ); assert!(by_emoji.get("[热]").expect("emoji index").is_dynamic); let catalog = EmoticonCatalog { by_unique: Arc::new(RwLock::new(by_unique)), by_emoji: Arc::new(RwLock::new(by_emoji)), }; assert!(catalog.get(Some("emoji_278"), "missing").await.is_some()); } #[test] fn settings_are_bounded_before_persistence() { let settings = OverlaySettings { font_scale: 999, font_brightness: 1, viewer_color: Some(" #a1b2c3 ".into()), danmaku_color: Some("not-css".into()), decoration_line_weight: 999, max_visible: 99, collapse_after_seconds: 1, unfold_duration_ms: 9_000, motion_intensity: 200, particle_count: 255, particle_speed: 0, high_value_threshold: -1, featured_value_threshold: -2, ..OverlaySettings::default() } .sanitize(); assert_eq!(settings.font_scale, 300); assert_eq!(settings.font_brightness, 70); assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3")); assert_eq!(settings.danmaku_color, None); assert_eq!(settings.decoration_line_weight, 300); assert_eq!(settings.max_visible, 12); assert_eq!(settings.collapse_after_seconds, 2); assert_eq!(settings.unfold_duration_ms, 5_000); assert_eq!(settings.motion_intensity, 100); assert_eq!(settings.particle_count, 12); assert_eq!(settings.particle_speed, 25); assert_eq!(settings.featured_value_threshold, 0); let settings = OverlaySettings { particle_speed: u16::MAX, ..OverlaySettings::default() } .sanitize(); assert_eq!(settings.particle_speed, 300); } #[test] fn old_saved_settings_receive_new_field_defaults() { let mut value = json!(OverlaySettings::default()); let object = value.as_object_mut().expect("settings object"); object.insert("title".into(), json!("旧版弹幕栏标题")); object.remove("fontScale"); object.remove("fontFamily"); object.remove("fontBrightness"); object.remove("viewerColor"); object.remove("danmakuColor"); object.remove("decorationLineWeight"); object.remove("unfoldDurationMs"); object.remove("particleCount"); object.remove("particleSpeed"); object.remove("themeId"); let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings"); assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll); assert_eq!(settings.font_family, FontFamilyId::FangSong); assert_eq!(settings.font_brightness, 130); assert_eq!(settings.viewer_color, None); assert_eq!(settings.danmaku_color, None); assert_eq!(settings.font_scale, 140); assert_eq!(settings.decoration_line_weight, 160); assert_eq!(settings.unfold_duration_ms, 1_000); assert_eq!(settings.particle_count, 8); assert_eq!(settings.particle_speed, 100); } #[test] fn unknown_theme_ids_are_rejected() { let mut value = json!(OverlaySettings::default()); value["themeId"] = json!("theme-that-is-not-installed"); let error = serde_json::from_value::(value).expect_err("unknown theme"); assert!(error.to_string().contains("unknown variant")); } #[test] fn moonlit_water_theme_is_a_valid_persisted_identifier() { let mut value = json!(OverlaySettings::default()); value["themeId"] = json!("moonlit-water"); let settings: OverlaySettings = serde_json::from_value(value).expect("moonlit theme"); assert_eq!(settings.theme_id, OverlayThemeId::MoonlitWater); assert_eq!( serde_json::to_value(settings).unwrap()["themeId"], "moonlit-water" ); } }