tweaked v1

This commit is contained in:
2026-07-15 22:14:58 -07:00
parent c9d497153f
commit b8850c87a4
23 changed files with 4567 additions and 119 deletions
+545
View File
@@ -0,0 +1,545 @@
use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::RwLock;
use tokio_postgres::NoTls;
use tracing::warn;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OverlaySettings {
pub title: String,
#[serde(default = "default_font_scale")]
pub font_scale: 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 {
title: "洛星瓷专用弹幕猪!".into(),
font_scale: default_font_scale(),
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.title = self.title.trim().chars().take(48).collect();
if self.title.is_empty() {
self.title = Self::default().title;
}
self.max_visible = self.max_visible.clamp(1, 12);
self.font_scale = self.font_scale.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 default_font_scale() -> u16 {
140
}
fn default_unfold_duration_ms() -> u16 {
1_000
}
fn default_particle_count() -> u8 {
8
}
fn default_particle_speed() -> u16 {
100
}
pub async fn load_settings(
database_url: &str,
room_id: &str,
defaults: OverlaySettings,
) -> Result<OverlaySettings, String> {
let (client, connection) = tokio_postgres::connect(database_url, NoTls)
.await
.map_err(|e| e.to_string())?;
tokio::spawn(async move {
let _ = connection.await;
});
let row = client
.query_opt(
"SELECT settings FROM overlay_settings WHERE room_id=$1",
&[&room_id],
)
.await
.map_err(|e| e.to_string())?;
Ok(row
.and_then(|row| serde_json::from_value::<OverlaySettings>(row.get::<_, Value>(0)).ok())
.unwrap_or(defaults)
.sanitize())
}
pub async fn save_settings(
database_url: &str,
room_id: &str,
settings: &OverlaySettings,
) -> Result<(), String> {
let (client, connection) = tokio_postgres::connect(database_url, NoTls)
.await
.map_err(|e| e.to_string())?;
tokio::spawn(async move {
let _ = connection.await;
});
client.execute("INSERT INTO overlay_settings(room_id,settings,updated_at) VALUES($1,$2,now()) ON CONFLICT(room_id) DO UPDATE SET settings=EXCLUDED.settings,updated_at=now()", &[&room_id, &json!(settings)]).await.map_err(|e| e.to_string())?;
Ok(())
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMeta {
pub id: Option<i64>,
pub name: String,
pub coin_type: String,
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>>>,
}
#[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>>>,
}
impl EmoticonCatalog {
pub async fn len(&self) -> usize {
self.by_unique.read().await.len()
}
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
if let Some(unique) = unique.filter(|value| !value.is_empty()) {
if 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 get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
if let Some(id) = id {
if let Some(gift) = self.by_id.read().await.get(&id).cloned() {
return Some(gift);
}
}
self.by_name
.read()
.await
.get(&normalize_name(name))
.cloned()
}
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)
}
pub fn spawn_refresh(self, room_id: String, interval_seconds: u64, timeout_seconds: u64) {
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(interval_seconds)).await;
if let Err(error) = self.refresh(&room_id, timeout_seconds).await {
warn!(%error, "gift catalog refresh failed; retaining the last successful cache");
}
}
});
}
}
fn parse_catalog(
payload: &Value,
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), 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 gift = GiftMeta {
id,
name: name.to_owned(),
coin_type: item
.get("coin_type")
.and_then(Value::as_str)
.unwrap_or("gold")
.to_owned(),
unit_price: item.get("price").and_then(Value::as_i64).unwrap_or(0),
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()))
}
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<
(
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
),
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::*;
#[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!(
names
.get("青玉灯")
.expect("name index")
.effect_type
.as_deref(),
Some("2")
);
}
#[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 {
title: " ".into(),
font_scale: 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.title, "洛星瓷专用弹幕猪!");
assert_eq!(settings.font_scale, 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.remove("fontScale");
object.remove("unfoldDurationMs");
object.remove("particleCount");
object.remove("particleSpeed");
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
assert_eq!(settings.font_scale, 140);
assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8);
assert_eq!(settings.particle_speed, 100);
}
}