tweaked v1
This commit is contained in:
+1320
-86
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user