更新点歌和弹幕微调

This commit is contained in:
2026-08-19 12:30:30 -07:00
parent a36d511d39
commit 845b0f5900
43 changed files with 1903 additions and 766 deletions
+2 -1
View File
@@ -21,8 +21,9 @@ crate 导出。
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
| `realtime` | account event routing 与 component-scoped fanout |
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
| `song_request` | 点歌命令、事务队列、快照和管理服务 |
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
| `guard_effect` | 独立大航海主题、感谢文案与 `live.guard.buy` 事件订阅 |
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
## 重要不变量
+18
View File
@@ -28,6 +28,7 @@ use crate::{
db::{Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
guard_effect::{GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings},
i18n,
overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -669,6 +670,23 @@ impl AuthService {
],
)
.await?;
let guard_component_id = Uuid::new_v4();
let guard_settings = serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&guard_component_id,
&user_id,
&GUARD_EFFECT_KIND,
&GUARD_EFFECT_NAME,
&guard_settings,
],
)
.await?;
let menu_component_id = Uuid::new_v4();
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
.expect("GiftMenuSettings is always JSON serializable");
+27 -1
View File
@@ -22,6 +22,7 @@ use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind},
gift_effect::GiftEffectDefinition,
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
guard_effect::GuardEffectDefinition,
overlay::OverlaySettings,
song_request::{SongRequestDefinition, SongRequestProjection},
};
@@ -389,6 +390,12 @@ impl ComponentRegistry {
Arc::new(PassthroughProjection),
)
.expect("built-in component kinds are unique");
registry
.register(
Arc::new(GuardEffectDefinition),
Arc::new(PassthroughProjection),
)
.expect("built-in component kinds are unique");
registry
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
.expect("built-in component kinds are unique");
@@ -560,7 +567,7 @@ mod tests {
}
#[test]
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
fn builtin_gift_effect_only_subscribes_to_gifts() {
let registry = ComponentRegistry::default();
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
let runtime = registry.runtime("gift_effect").unwrap();
@@ -574,7 +581,26 @@ mod tests {
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn builtin_guard_effect_only_subscribes_to_guard_purchases() {
let registry = ComponentRegistry::default();
assert!(registry.kinds().contains(&"guard_effect".to_owned()));
let runtime = registry.runtime("guard_effect").unwrap();
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"guard_effect",
"大航海特效",
1,
runtime.definition().default_settings(),
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
+4
View File
@@ -135,6 +135,7 @@ struct OverlayFileConfig {
font_scale: Option<u16>,
decoration_line_weight: Option<u16>,
max_visible: Option<u8>,
expand_new_danmaku: Option<bool>,
collapse_after_seconds: Option<u16>,
unfold_duration_ms: Option<u16>,
motion_intensity: Option<u8>,
@@ -345,6 +346,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
show_like: file.events.like.unwrap_or(default.show_like),
show_share: file.events.share.unwrap_or(default.show_share),
max_visible: file.max_visible.unwrap_or(default.max_visible),
expand_new_danmaku: file
.expand_new_danmaku
.unwrap_or(default.expand_new_danmaku),
collapse_after_seconds: file
.collapse_after_seconds
.unwrap_or(default.collapse_after_seconds),
+27 -18
View File
@@ -1,7 +1,7 @@
//! Full-screen gift and membership effect component.
//! Full-screen gift effect component.
//!
//! The component is deliberately passive: it subscribes to canonical gift and
//! guard-purchase events and projects them to its own authenticated OBS stream.
//! The component is deliberately passive: it subscribes only to canonical gift
//! events and projects them to its own authenticated OBS stream.
//! All visual differentiation is settings-driven in the browser, so receiving
//! an effect never creates database writes or depends on an OBS connection.
@@ -57,9 +57,8 @@ pub struct GiftEffectSettings {
pub high: MeteorTierSettings,
pub featured: MeteorTierSettings,
pub trail_intensity: u8,
pub guard_star_count: u8,
pub guard_effect_duration_ms: u16,
pub max_concurrent_effects: u8,
#[serde(default = "default_queue_capacity")]
pub queue_capacity: u16,
pub low_performance_mode: bool,
}
@@ -87,9 +86,7 @@ impl Default for GiftEffectSettings {
speed: 880,
},
trail_intensity: 78,
guard_star_count: 48,
guard_effect_duration_ms: 5_200,
max_concurrent_effects: 8,
queue_capacity: default_queue_capacity(),
low_performance_mode: false,
}
}
@@ -105,13 +102,15 @@ impl GiftEffectSettings {
self.high = self.high.sanitize();
self.featured = self.featured.sanitize();
self.trail_intensity = self.trail_intensity.min(100);
self.guard_star_count = self.guard_star_count.clamp(8, 96);
self.guard_effect_duration_ms = self.guard_effect_duration_ms.clamp(1_000, 15_000);
self.max_concurrent_effects = self.max_concurrent_effects.clamp(1, 12);
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
self
}
}
const fn default_queue_capacity() -> u16 {
256
}
pub struct GiftEffectDefinition;
impl GiftEffectDefinition {
@@ -148,10 +147,7 @@ impl ComponentDefinition for GiftEffectDefinition {
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([
LiveEventKind::Gift,
LiveEventKind::GuardPurchase,
]))
Ok(EventSubscription::new([LiveEventKind::Gift]))
}
}
@@ -168,25 +164,38 @@ mod tests {
settings["featured"]["size"] = Value::from(9_999);
settings["highValueThreshold"] = Value::from(50_000);
settings["featuredValueThreshold"] = Value::from(10_000);
settings["queueCapacity"] = Value::from(9_999);
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["normal"]["count"], 1);
assert_eq!(sanitized["fontBrightness"], 180);
assert_eq!(sanitized["featured"]["size"], 480);
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
assert_eq!(sanitized["queueCapacity"], 1_000);
}
#[test]
fn component_only_subscribes_to_durable_gifts_and_guards() {
fn component_only_subscribes_to_durable_gifts() {
let definition = GiftEffectDefinition;
let subscriptions = definition
.subscriptions(&definition.default_settings())
.unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
}
#[test]
fn old_settings_receive_queue_capacity_default() {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings.as_object_mut().unwrap().remove("queueCapacity");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["queueCapacity"], 256);
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GiftEffectDefinition;
+247
View File
@@ -0,0 +1,247 @@
//! Full-screen membership-purchase effect component.
//!
//! This component is deliberately separate from gift effects: every instance
//! owns its settings, read-only OBS token, event subscription, and WebSocket
//! channel, and consumes only canonical guard-purchase events.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
components::{ComponentDefinition, ComponentError, EventSubscription},
domain::LiveEventKind,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const GUARD_EFFECT_KIND: &str = "guard_effect";
pub const GUARD_EFFECT_NAME: &str = "大航海特效";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GuardEffectThemeId {
#[default]
JadeStarfall,
MoonlitWater,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GuardEffectSettings {
#[serde(default)]
pub theme_id: GuardEffectThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
pub star_count: u8,
pub effect_duration_ms: u16,
#[serde(default = "default_title_template")]
pub title_template: String,
#[serde(default = "default_closing_text")]
pub closing_text: String,
#[serde(default = "default_queue_capacity")]
pub queue_capacity: u16,
pub low_performance_mode: bool,
}
impl Default for GuardEffectSettings {
fn default() -> Self {
Self {
theme_id: GuardEffectThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
star_count: 48,
effect_duration_ms: 5_200,
title_template: default_title_template(),
closing_text: default_closing_text(),
queue_capacity: default_queue_capacity(),
low_performance_mode: false,
}
}
}
impl GuardEffectSettings {
pub fn sanitize(mut self) -> Self {
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.star_count = self.star_count.clamp(8, 96);
self.effect_duration_ms = self.effect_duration_ms.clamp(1_000, 15_000);
self.title_template = sanitize_copy(self.title_template, &default_title_template());
self.closing_text = sanitize_copy(self.closing_text, &default_closing_text());
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
self
}
}
const fn default_queue_capacity() -> u16 {
256
}
/// Preserve the membership-related portion of the first legacy gift-effect
/// instance when an existing account receives its initial guard component.
pub fn settings_from_legacy_gift(value: &Value) -> GuardEffectSettings {
let mut migrated = serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable");
for (legacy, current) in [
("themeId", "themeId"),
("fontFamily", "fontFamily"),
("fontBrightness", "fontBrightness"),
("guardStarCount", "starCount"),
("guardEffectDurationMs", "effectDurationMs"),
("guardTitleTemplate", "titleTemplate"),
("guardClosingText", "closingText"),
("lowPerformanceMode", "lowPerformanceMode"),
] {
if let Some(candidate) = value.get(legacy) {
migrated[current] = candidate.clone();
}
}
serde_json::from_value::<GuardEffectSettings>(migrated)
.unwrap_or_default()
.sanitize()
}
fn default_title_template() -> String {
"{guard}启航".into()
}
fn default_closing_text() -> String {
"相伴前行".into()
}
fn sanitize_copy(value: String, fallback: &str) -> String {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
fallback.to_owned()
} else {
normalized.chars().take(80).collect()
}
}
pub struct GuardEffectDefinition;
impl GuardEffectDefinition {
fn parse(&self, settings: Value) -> Result<GuardEffectSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GUARD_EFFECT_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GuardEffectDefinition {
fn kind(&self) -> &'static str {
GUARD_EFFECT_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
ComponentError::InvalidSettings {
kind: GUARD_EFFECT_KIND.to_owned(),
detail: error.to_string(),
}
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([LiveEventKind::GuardPurchase]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_are_bounded_and_copy_is_sanitized() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings["fontBrightness"] = Value::from(999);
settings["starCount"] = Value::from(255);
settings["effectDurationMs"] = Value::from(200);
settings["queueCapacity"] = Value::from(9_999);
settings["titleTemplate"] = Value::from(format!(" {{guard}}{} ", "启航".repeat(50)));
settings["closingText"] = Value::from(" ");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["fontBrightness"], 180);
assert_eq!(sanitized["starCount"], 96);
assert_eq!(sanitized["effectDurationMs"], 1_000);
assert_eq!(sanitized["queueCapacity"], 1_000);
assert_eq!(
sanitized["titleTemplate"].as_str().unwrap().chars().count(),
80
);
assert_eq!(sanitized["closingText"], "相伴前行");
}
#[test]
fn old_settings_receive_copy_defaults() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings.as_object_mut().unwrap().remove("titleTemplate");
settings.as_object_mut().unwrap().remove("closingText");
settings.as_object_mut().unwrap().remove("queueCapacity");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["titleTemplate"], "{guard}启航");
assert_eq!(sanitized["closingText"], "相伴前行");
assert_eq!(sanitized["queueCapacity"], 256);
}
#[test]
fn legacy_gift_settings_preserve_membership_configuration() {
let legacy = serde_json::json!({
"themeId": "moonlit-water",
"fontFamily": "kai",
"fontBrightness": 145,
"guardStarCount": 72,
"guardEffectDurationMs": 8_500,
"guardTitleTemplate": "{guard}出发",
"guardClosingText": "一路顺风",
"lowPerformanceMode": true,
"normal": { "count": 20 }
});
let migrated = settings_from_legacy_gift(&legacy);
assert_eq!(migrated.theme_id, GuardEffectThemeId::MoonlitWater);
assert_eq!(migrated.star_count, 72);
assert_eq!(migrated.effect_duration_ms, 8_500);
assert_eq!(migrated.title_template, "{guard}出发");
assert_eq!(migrated.closing_text, "一路顺风");
assert!(migrated.low_performance_mode);
}
#[test]
fn component_only_subscribes_to_guard_purchases() {
let definition = GuardEffectDefinition;
let subscriptions = definition
.subscriptions(&definition.default_settings())
.unwrap();
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings["themeId"] = Value::from("moonlit-water");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["themeId"], "moonlit-water");
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod db;
pub mod domain;
pub mod gift_effect;
pub mod gift_menu;
pub mod guard_effect;
pub mod http_api;
pub mod i18n;
pub mod live;
+11
View File
@@ -57,6 +57,8 @@ pub struct OverlaySettings {
pub show_like: bool,
pub show_share: bool,
pub max_visible: u8,
#[serde(default = "default_expand_new_danmaku")]
pub expand_new_danmaku: bool,
pub collapse_after_seconds: u16,
#[serde(default = "default_unfold_duration_ms")]
pub unfold_duration_ms: u16,
@@ -89,6 +91,7 @@ impl Default for OverlaySettings {
show_like: false,
show_share: false,
max_visible: 5,
expand_new_danmaku: default_expand_new_danmaku(),
collapse_after_seconds: 12,
unfold_duration_ms: default_unfold_duration_ms(),
motion_intensity: 70,
@@ -141,6 +144,10 @@ fn default_unfold_duration_ms() -> u16 {
1_000
}
fn default_expand_new_danmaku() -> bool {
true
}
fn default_particle_count() -> u8 {
8
}
@@ -588,6 +595,7 @@ mod tests {
danmaku_color: Some("not-css".into()),
decoration_line_weight: 999,
max_visible: 99,
expand_new_danmaku: false,
collapse_after_seconds: 1,
unfold_duration_ms: 9_000,
motion_intensity: 200,
@@ -604,6 +612,7 @@ mod tests {
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.decoration_line_weight, 300);
assert_eq!(settings.max_visible, 12);
assert!(!settings.expand_new_danmaku);
assert_eq!(settings.collapse_after_seconds, 2);
assert_eq!(settings.unfold_duration_ms, 5_000);
assert_eq!(settings.motion_intensity, 100);
@@ -630,6 +639,7 @@ mod tests {
object.remove("viewerColor");
object.remove("danmakuColor");
object.remove("decorationLineWeight");
object.remove("expandNewDanmaku");
object.remove("unfoldDurationMs");
object.remove("particleCount");
object.remove("particleSpeed");
@@ -642,6 +652,7 @@ mod tests {
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.font_scale, 140);
assert_eq!(settings.decoration_line_weight, 160);
assert!(settings.expand_new_danmaku);
assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8);
assert_eq!(settings.particle_speed, 100);
+41
View File
@@ -17,6 +17,9 @@ use crate::{
db::{ComponentRecord, Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
guard_effect::{
GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings, settings_from_legacy_gift,
},
i18n,
realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -140,6 +143,44 @@ impl TenantRepository {
)
.await?;
}
let has_guard_effect = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GUARD_EFFECT_KIND],
)
.await?
.get::<_, bool>(0);
if !has_guard_effect {
let legacy_gift_settings = transaction
.query_opt(
"SELECT settings FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2 ORDER BY created_at,id LIMIT 1",
&[&tenant.user_id, &GIFT_EFFECT_KIND],
)
.await?
.map(|row| row.get::<_, Value>(0));
let guard_settings = legacy_gift_settings
.as_ref()
.map(settings_from_legacy_gift)
.unwrap_or_else(GuardEffectSettings::default);
let guard_settings = serde_json::to_value(guard_settings)
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GUARD_EFFECT_KIND,
&GUARD_EFFECT_NAME,
&guard_settings,
],
)
.await?;
}
let has_gift_menu = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
+39 -115
View File
@@ -128,34 +128,20 @@ pub enum SongCommand {
title: String,
normalized_title: String,
},
Rate(u8),
}
/// Parse only explicit commands separated from their argument by whitespace.
/// This avoids treating ordinary words such as “点歌姬” as queue mutations.
/// Parse a request prefix followed immediately by a title or by whitespace and
/// a title. Whitespace inside the title is normalized before deduplication.
pub fn parse_command(text: &str) -> Option<SongCommand> {
let canonical = collapse_whitespace(text);
let (command, argument) = canonical.split_once(' ')?;
match command {
"点歌" => {
if argument.is_empty() || argument.chars().count() > 80 {
return None;
}
Some(SongCommand::Request {
title: argument.to_owned(),
normalized_title: argument.to_lowercase(),
})
}
"打分" => match argument {
"1" => Some(SongCommand::Rate(1)),
"2" => Some(SongCommand::Rate(2)),
"3" => Some(SongCommand::Rate(3)),
"4" => Some(SongCommand::Rate(4)),
"5" => Some(SongCommand::Rate(5)),
_ => None,
},
_ => None,
let argument = canonical.strip_prefix("点歌")?.trim_start();
if argument.is_empty() || argument.chars().count() > 80 {
return None;
}
Some(SongCommand::Request {
title: argument.to_owned(),
normalized_title: argument.to_lowercase(),
})
}
fn collapse_whitespace(value: &str) -> String {
@@ -180,8 +166,6 @@ pub struct SongRequestItem {
pub requested_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub average_score: Option<f64>,
pub rating_count: i64,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -191,7 +175,6 @@ pub struct SongQueueSummary {
pub queued_count: i64,
pub completed_count: i64,
pub cancelled_count: i64,
pub rating_count: i64,
}
#[derive(Clone, Debug, Serialize)]
@@ -241,25 +224,20 @@ impl SongRequestService {
};
let settings: SongRequestSettings = serde_json::from_value(component.settings.clone())
.map_err(|error| SongRequestError::Invalid(error.to_string()))?;
let result = match command {
SongCommand::Request {
title,
normalized_title,
} => {
self.request_song(
component,
&danmaku.viewer,
&title,
&normalized_title,
event.id,
&settings,
)
.await?
}
SongCommand::Rate(score) => {
self.rate_current(component, &danmaku.viewer, score).await?
}
};
let SongCommand::Request {
title,
normalized_title,
} = command;
let result = self
.request_song(
component,
&danmaku.viewer,
&title,
&normalized_title,
event.id,
&settings,
)
.await?;
if let Some(change) = result {
self.publish_change(component, &event.room_id, change)?;
}
@@ -299,7 +277,7 @@ impl SongRequestService {
};
let sql = format!(
"{} WHERE r.component_instance_id=$1 AND {status_filter} \
GROUP BY r.id ORDER BY {order} OFFSET $2 LIMIT $3",
ORDER BY {order} OFFSET $2 LIMIT $3",
item_select()
);
let rows = transaction
@@ -342,7 +320,7 @@ impl SongRequestService {
.query(
&format!(
"{} WHERE r.component_instance_id=$1 AND r.status='queued' \
GROUP BY r.id ORDER BY r.queue_position ASC",
ORDER BY r.queue_position ASC",
item_select()
),
&[&component.id],
@@ -671,59 +649,6 @@ impl SongRequestService {
}))
}
async fn rate_current(
&self,
component: &ComponentInstance,
viewer: &PlatformViewer,
score: u8,
) -> Result<Option<SongQueueChange>, SongRequestError> {
let viewer_uid = bounded_identity(&viewer.uid, 64)?;
let viewer_name = bounded_identity(&viewer.name, 80)?;
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, component.owner_id).await?;
lock_state(&transaction, component).await?;
let Some(row) = transaction
.query_opt(
"SELECT id FROM song_requests WHERE component_instance_id=$1 AND status='current' FOR UPDATE",
&[&component.id],
)
.await?
else {
return Ok(None);
};
let request_id: Uuid = row.get(0);
let score = i16::from(score);
transaction
.execute(
"INSERT INTO song_ratings \
(id,owner_user_id,component_instance_id,song_request_id,viewer_uid,viewer_name,score) \
VALUES($1,$2,$3,$4,$5,$6,$7) \
ON CONFLICT(song_request_id,viewer_uid) DO UPDATE \
SET score=EXCLUDED.score,viewer_name=EXCLUDED.viewer_name,updated_at=now()",
&[
&Uuid::new_v4(),
&component.owner_id,
&component.id,
&request_id,
&viewer_uid,
&viewer_name,
&score,
],
)
.await?;
let revision = bump_revision(&transaction, component.id).await?;
let current = current_item(&transaction, component.id).await?;
transaction.commit().await?;
Ok(Some(SongQueueChange {
revision,
operation: "rating-updated",
item_id: request_id,
item: current.clone(),
current,
}))
}
fn publish_change(
&self,
component: &ComponentInstance,
@@ -846,8 +771,7 @@ async fn promote_next(
fn item_select() -> &'static str {
"SELECT r.id,r.song_title,r.requester_uid,r.requester_name,r.status,r.queue_position,\
r.requested_at,r.started_at,r.finished_at,avg(v.score)::double precision,count(v.id)::BIGINT \
FROM song_requests r LEFT JOIN song_ratings v ON v.song_request_id=r.id"
r.requested_at,r.started_at,r.finished_at FROM song_requests r"
}
fn item_from_row(row: &Row) -> SongRequestItem {
@@ -863,8 +787,6 @@ fn item_from_row(row: &Row) -> SongRequestItem {
requested_at: row.get(6),
started_at: row.get(7),
finished_at: row.get(8),
average_score: row.get(9),
rating_count: row.get(10),
}
}
@@ -873,10 +795,7 @@ async fn request_item(
request_id: Uuid,
) -> Result<Option<SongRequestItem>, SongRequestError> {
Ok(transaction
.query_opt(
&format!("{} WHERE r.id=$1 GROUP BY r.id", item_select()),
&[&request_id],
)
.query_opt(&format!("{} WHERE r.id=$1", item_select()), &[&request_id])
.await?
.map(|row| item_from_row(&row)))
}
@@ -888,7 +807,7 @@ async fn current_item(
Ok(transaction
.query_opt(
&format!(
"{} WHERE r.component_instance_id=$1 AND r.status='current' GROUP BY r.id",
"{} WHERE r.component_instance_id=$1 AND r.status='current'",
item_select()
),
&[&component_id],
@@ -906,8 +825,7 @@ async fn queue_summary(
"SELECT count(*) FILTER (WHERE status IN ('current','queued'))::BIGINT,\
count(*) FILTER (WHERE status='queued')::BIGINT,\
count(*) FILTER (WHERE status='completed')::BIGINT,\
count(*) FILTER (WHERE status='cancelled')::BIGINT,\
(SELECT count(*) FROM song_ratings WHERE component_instance_id=$1)::BIGINT \
count(*) FILTER (WHERE status='cancelled')::BIGINT \
FROM song_requests WHERE component_instance_id=$1",
&[&component_id],
)
@@ -917,7 +835,6 @@ async fn queue_summary(
queued_count: row.get(1),
completed_count: row.get(2),
cancelled_count: row.get(3),
rating_count: row.get(4),
})
}
@@ -1079,7 +996,7 @@ mod tests {
use super::*;
#[test]
fn parses_requests_and_scores_with_normalized_whitespace() {
fn parses_only_requests_with_normalized_whitespace() {
assert_eq!(
parse_command(" 点歌 My Song "),
Some(SongCommand::Request {
@@ -1087,11 +1004,18 @@ mod tests {
normalized_title: "my song".into(),
})
);
assert_eq!(parse_command("打分 5"), Some(SongCommand::Rate(5)));
assert_eq!(
parse_command("点歌夜曲"),
Some(SongCommand::Request {
title: "夜曲".into(),
normalized_title: "夜曲".into(),
})
);
assert_eq!(parse_command("打分 5"), None);
assert_eq!(parse_command("打分 0"), None);
assert_eq!(parse_command("点歌姬"), None);
assert_eq!(parse_command("点歌"), None);
assert_eq!(parse_command(&format!("点歌 {}", "歌".repeat(81))), None);
assert_eq!(parse_command(&format!("点歌{}", "歌".repeat(81))), None);
}
#[test]