Files
lxc-streamutils/apps/server-rust/src/overlay.rs
T
2026-08-11 09:19:13 -07:00

670 lines
23 KiB
Rust

//! 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<String>,
#[serde(default)]
pub danmaku_color: Option<String>,
#[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<String>) -> Option<String> {
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<i64>,
pub name: String,
pub coin_type: String,
pub battery_value: i64,
pub unit_price: i64,
pub image_url: Option<String>,
pub animation_url: Option<String>,
pub effect_type: Option<String>,
pub stay_time: Option<i64>,
}
#[derive(Clone, Default)]
pub struct GiftCatalog {
by_id: Arc<RwLock<HashMap<i64, GiftMeta>>>,
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
}
/// 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<RwLock<HashMap<Uuid, GiftCatalog>>>,
}
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<String>,
pub url: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub is_dynamic: bool,
pub bulge_display: bool,
}
#[derive(Clone, Default)]
pub struct EmoticonCatalog {
by_unique: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
}
// 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<i64, GiftMeta>, HashMap<String, GiftMeta>, usize);
type EmoticonCatalogSnapshot = (
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
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<EmoticonMeta> {
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<usize, String> {
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<i64>, name: &str) -> Option<GiftMeta> {
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<GiftMeta> {
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<usize, String> {
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<GiftCatalogSnapshot, String> {
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<String> {
value
.get(name)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
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<String> {
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<u32> {
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::<Vec<_>>()
.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::<OverlaySettings>(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"
);
}
}