//! Durable multi-tenant song request queue and component integration. //! //! Chat commands are parsed from provider-independent danmaku events. Every //! accepted mutation locks the component state row, writes queue state and //! advances a monotonic revision in one transaction. OBS rendering consumes //! snapshots and revisioned changes; it is never the authority for the queue. use std::{error::Error, fmt, sync::Arc}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio_postgres::{IsolationLevel, Row, Transaction}; use uuid::Uuid; use crate::{ components::{ ComponentDefinition, ComponentError, ComponentInstance, ComponentSnapshotProvider, EventHandler, EventProjection, EventSubscription, HandlerFuture, SnapshotFuture, }, db::{Db, DbError}, domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload, PlatformViewer}, overlay::OverlayThemeId, realtime::EventHub, typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness}, }; pub const SONG_REQUEST_KIND: &str = "song_request"; pub const SONG_REQUEST_NAME: &str = "点歌姬"; const SNAPSHOT_PAGE_SIZE: usize = 100; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SongRequestSettings { #[serde(default)] pub theme_id: OverlayThemeId, #[serde(default)] pub font_family: FontFamilyId, #[serde(default = "default_font_brightness")] pub font_brightness: u16, /// Optional text-color overrides. `None` follows the selected theme so a /// later theme switch can update the palette without stale saved colors. #[serde(default)] pub requester_color: Option, #[serde(default)] pub song_title_color: Option, #[serde(default = "default_font_scale")] pub font_scale: u16, #[serde(default = "default_decoration_line_weight")] pub decoration_line_weight: u16, #[serde(default = "default_scroll_speed")] pub scroll_speed_pixels_per_second: u16, #[serde(default = "default_edge_pause")] pub edge_pause_seconds: u8, /// Zero means unlimited. #[serde(default)] pub max_queue_size: u32, /// Zero means unlimited. #[serde(default)] pub max_requests_per_viewer: u16, /// Zero disables cooldown. #[serde(default)] pub request_cooldown_seconds: u32, } impl Default for SongRequestSettings { fn default() -> Self { Self { theme_id: OverlayThemeId::default(), font_family: FontFamilyId::default(), font_brightness: default_font_brightness(), requester_color: None, song_title_color: None, font_scale: default_font_scale(), decoration_line_weight: default_decoration_line_weight(), scroll_speed_pixels_per_second: default_scroll_speed(), edge_pause_seconds: default_edge_pause(), max_queue_size: 0, max_requests_per_viewer: 0, request_cooldown_seconds: 0, } } } impl SongRequestSettings { pub fn sanitize(mut self) -> Self { self.font_scale = self.font_scale.clamp(50, 250); self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300); self.font_brightness = sanitize_font_brightness(self.font_brightness); self.requester_color = sanitize_optional_hex_color(self.requester_color); self.song_title_color = sanitize_optional_hex_color(self.song_title_color); self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.clamp(5, 200); self.edge_pause_seconds = self.edge_pause_seconds.min(15); self.max_queue_size = self.max_queue_size.min(10_000); self.max_requests_per_viewer = self.max_requests_per_viewer.min(1_000); self.request_cooldown_seconds = self.request_cooldown_seconds.min(86_400); 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) } const fn default_font_scale() -> u16 { 100 } const fn default_decoration_line_weight() -> u16 { 160 } const fn default_scroll_speed() -> u16 { 28 } const fn default_edge_pause() -> u8 { 2 } #[derive(Clone, Debug, Eq, PartialEq)] pub enum SongCommand { Request { title: String, normalized_title: String, }, } /// 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 { let canonical = collapse_whitespace(text); 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 { value.split_whitespace().collect::>().join(" ") } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SongRequester { pub uid: String, pub name: String, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SongRequestItem { pub id: Uuid, pub title: String, pub requester: SongRequester, pub status: String, pub queue_position: i64, pub requested_at: DateTime, pub started_at: Option>, pub finished_at: Option>, } #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct SongQueueSummary { pub active_count: i64, pub queued_count: i64, pub completed_count: i64, pub cancelled_count: i64, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SongRequestPage { pub revision: i64, pub current: Option, pub items: Vec, pub next_cursor: Option, pub summary: SongQueueSummary, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct SongQueueChange { revision: i64, operation: &'static str, item_id: Uuid, item: Option, current: Option, } #[derive(Clone)] pub struct SongRequestService { db: Db, hub: EventHub, } impl SongRequestService { pub fn new(db: Db, hub: EventHub) -> Self { Self { db, hub } } pub async fn process_event( &self, component: &ComponentInstance, event: &LiveEvent, ) -> Result<(), SongRequestError> { if event.simulated { return Ok(()); } let LiveEventPayload::Danmaku(danmaku) = &event.payload else { return Ok(()); }; let Some(command) = parse_command(&danmaku.text) else { return Ok(()); }; let settings: SongRequestSettings = serde_json::from_value(component.settings.clone()) .map_err(|error| SongRequestError::Invalid(error.to_string()))?; 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)?; } Ok(()) } pub async fn list_page( &self, component: &ComponentInstance, scope: SongListScope, cursor: i64, limit: i64, ) -> Result { let cursor = cursor.max(0); let limit = limit.clamp(1, 100); let mut client = self.db.get().await?; // A snapshot must describe one revision. READ COMMITTED could observe // a queue mutation between reading revision/current/items and produce // a self-inconsistent page sequence. let transaction = client .build_transaction() .isolation_level(IsolationLevel::RepeatableRead) .read_only(true) .start() .await?; Db::set_tenant(&transaction, component.owner_id).await?; ensure_song_component(&transaction, component).await?; let revision = state_revision(&transaction, component.id).await?; let current = current_item(&transaction, component.id).await?; let status_filter = match scope { SongListScope::Active => "r.status = 'queued'", SongListScope::History => "r.status IN ('completed', 'cancelled')", }; let order = match scope { SongListScope::Active => "r.queue_position ASC", SongListScope::History => "r.finished_at DESC, r.requested_at DESC", }; let sql = format!( "{} WHERE r.component_instance_id=$1 AND {status_filter} \ ORDER BY {order} OFFSET $2 LIMIT $3", item_select() ); let rows = transaction .query(&sql, &[&component.id, &cursor, &(limit + 1)]) .await?; let has_more = rows.len() as i64 > limit; let items = rows .into_iter() .take(limit as usize) .map(|row| item_from_row(&row)) .collect(); let summary = queue_summary(&transaction, component.id).await?; transaction.commit().await?; Ok(SongRequestPage { revision, current, items, next_cursor: has_more.then_some(cursor + limit), summary, }) } pub async fn snapshot_messages( &self, component: &ComponentInstance, room_id: &str, ) -> Result, SongRequestError> { let mut client = self.db.get().await?; let transaction = client .build_transaction() .isolation_level(IsolationLevel::RepeatableRead) .read_only(true) .start() .await?; Db::set_tenant(&transaction, component.owner_id).await?; ensure_song_component(&transaction, component).await?; let revision = state_revision(&transaction, component.id).await?; let current = current_item(&transaction, component.id).await?; let rows = transaction .query( &format!( "{} WHERE r.component_instance_id=$1 AND r.status='queued' \ ORDER BY r.queue_position ASC", item_select() ), &[&component.id], ) .await?; let items: Vec<_> = rows.iter().map(item_from_row).collect(); transaction.commit().await?; let snapshot_id = Uuid::new_v4(); let mut messages = Vec::with_capacity(items.len() / SNAPSHOT_PAGE_SIZE + 2); messages.push(ComponentMessage::new( component, room_id, "song.queue.snapshot.begin", json!({ "snapshotId": snapshot_id, "revision": revision, "current": current, "totalQueued": items.len(), }), )); for (page, chunk) in items.chunks(SNAPSHOT_PAGE_SIZE).enumerate() { messages.push(ComponentMessage::new( component, room_id, "song.queue.snapshot.page", json!({ "snapshotId": snapshot_id, "revision": revision, "offset": page * SNAPSHOT_PAGE_SIZE, "items": chunk, }), )); } messages.push(ComponentMessage::new( component, room_id, "song.queue.snapshot.end", json!({"snapshotId":snapshot_id,"revision":revision}), )); Ok(messages) } pub async fn promote( &self, component: &ComponentInstance, request_id: Uuid, room_id: &str, ) -> Result<(), SongRequestError> { 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 row = transaction .query_opt( "SELECT queue_position FROM song_requests \ WHERE owner_user_id=$1 AND component_instance_id=$2 AND id=$3 AND status='queued' \ FOR UPDATE", &[&component.owner_id, &component.id, &request_id], ) .await? .ok_or(SongRequestError::Conflict("request is not queued".into()))?; let old_position: i64 = row.get(0); transaction .execute( "UPDATE song_requests SET queue_position=queue_position+1 \ WHERE owner_user_id=$1 AND component_instance_id=$2 AND status='queued' \ AND queue_position < $3", &[&component.owner_id, &component.id, &old_position], ) .await?; transaction .execute( "UPDATE song_requests SET queue_position=1 WHERE owner_user_id=$1 AND id=$2", &[&component.owner_id, &request_id], ) .await?; let revision = bump_revision(&transaction, component.id).await?; let item = request_item(&transaction, request_id).await?; let current = current_item(&transaction, component.id).await?; transaction.commit().await?; self.publish_change( component, room_id, SongQueueChange { revision, operation: "promoted", item_id: request_id, item, current, }, ) } pub async fn finish( &self, component: &ComponentInstance, request_id: Uuid, cancelled: bool, room_id: &str, ) -> Result<(), SongRequestError> { 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 row = transaction .query_opt( "SELECT status FROM song_requests WHERE owner_user_id=$1 \ AND component_instance_id=$2 AND id=$3 FOR UPDATE", &[&component.owner_id, &component.id, &request_id], ) .await? .ok_or(SongRequestError::NotFound)?; let status: String = row.get(0); if status != "current" && !(cancelled && status == "queued") { return Err(SongRequestError::Conflict( "request cannot be changed from its current state".into(), )); } let next_status = if cancelled { "cancelled" } else { "completed" }; transaction .execute( "UPDATE song_requests SET status=$1,queue_position=0,finished_at=now() \ WHERE owner_user_id=$2 AND id=$3", &[&next_status, &component.owner_id, &request_id], ) .await?; let current = if status == "current" { promote_next(&transaction, component).await?; current_item(&transaction, component.id).await? } else { current_item(&transaction, component.id).await? }; let revision = bump_revision(&transaction, component.id).await?; transaction.commit().await?; self.publish_change( component, room_id, SongQueueChange { revision, operation: if cancelled { "cancelled" } else { "completed" }, item_id: request_id, item: None, current, }, ) } /// Cancel every active request in one tenant-scoped transaction. /// /// A single revision represents the whole reset. Publishing one reset /// event also avoids exposing a half-cleared queue to OBS clients while a /// long queue is being processed. pub async fn clear_active( &self, component: &ComponentInstance, room_id: &str, ) -> Result { 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 cancelled_count = transaction .execute( "UPDATE song_requests SET status='cancelled',queue_position=0,finished_at=now() \ WHERE owner_user_id=$1 AND component_instance_id=$2 \ AND status IN ('current','queued')", &[&component.owner_id, &component.id], ) .await?; if cancelled_count == 0 { transaction.commit().await?; return Ok(0); } let revision = bump_revision(&transaction, component.id).await?; transaction.commit().await?; self.hub.publish( component.id, Arc::new(ComponentMessage::new( component, room_id, "song.queue.changed", json!({ "revision": revision, "operation": "cleared", "itemId": null, "item": null, "current": null, "cancelledCount": cancelled_count, }), )), ); Ok(cancelled_count) } async fn request_song( &self, component: &ComponentInstance, viewer: &PlatformViewer, title: &str, normalized_title: &str, source_event_id: Uuid, settings: &SongRequestSettings, ) -> Result, 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?; if transaction .query_opt( "SELECT 1 FROM song_requests WHERE component_instance_id=$1 AND source_event_id=$2", &[&component.id, &source_event_id], ) .await? .is_some() { return Ok(None); } if transaction .query_opt( "SELECT 1 FROM song_requests WHERE component_instance_id=$1 \ AND normalized_song_title=$2 AND status IN ('current','queued')", &[&component.id, &normalized_title], ) .await? .is_some() { return Ok(None); } let active_count: i64 = transaction .query_one( "SELECT count(*) FROM song_requests WHERE component_instance_id=$1 \ AND status IN ('current','queued')", &[&component.id], ) .await? .get(0); if settings.max_queue_size > 0 && active_count >= i64::from(settings.max_queue_size) { return Ok(None); } if settings.max_requests_per_viewer > 0 { let viewer_count: i64 = transaction .query_one( "SELECT count(*) FROM song_requests WHERE component_instance_id=$1 \ AND requester_uid=$2 AND status IN ('current','queued')", &[&component.id, &viewer_uid], ) .await? .get(0); if viewer_count >= i64::from(settings.max_requests_per_viewer) { return Ok(None); } } if settings.request_cooldown_seconds > 0 { let cooling_down: bool = transaction .query_one( "SELECT EXISTS(SELECT 1 FROM song_requests WHERE component_instance_id=$1 \ AND requester_uid=$2 AND requested_at > now() - ($3::BIGINT * interval '1 second'))", &[ &component.id, &viewer_uid, &i64::from(settings.request_cooldown_seconds), ], ) .await? .get(0); if cooling_down { return Ok(None); } } let has_current: bool = transaction .query_one( "SELECT EXISTS(SELECT 1 FROM song_requests WHERE component_instance_id=$1 AND status='current')", &[&component.id], ) .await? .get(0); let (status, position): (&str, i64) = if has_current { let max_position: i64 = transaction .query_one( "SELECT COALESCE(max(queue_position),0) FROM song_requests \ WHERE component_instance_id=$1 AND status='queued'", &[&component.id], ) .await? .get(0); ("queued", max_position.saturating_add(1)) } else { ("current", 0) }; let request_id = Uuid::new_v4(); transaction .execute( "INSERT INTO song_requests \ (id,owner_user_id,component_instance_id,requester_uid,requester_name,song_title,\ normalized_song_title,status,queue_position,source_event_id,started_at) \ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,CASE WHEN $8='current' THEN now() ELSE NULL END)", &[ &request_id, &component.owner_id, &component.id, &viewer_uid, &viewer_name, &title, &normalized_title, &status, &position, &source_event_id, ], ) .await?; let revision = bump_revision(&transaction, component.id).await?; let item = request_item(&transaction, request_id).await?; let current = current_item(&transaction, component.id).await?; transaction.commit().await?; Ok(Some(SongQueueChange { revision, operation: "added", item_id: request_id, item, current, })) } fn publish_change( &self, component: &ComponentInstance, room_id: &str, change: SongQueueChange, ) -> Result<(), SongRequestError> { let payload = serde_json::to_value(change)?; self.hub.publish( component.id, Arc::new(ComponentMessage::new( component, room_id, "song.queue.changed", payload, )), ); Ok(()) } } fn bounded_identity(value: &str, max: usize) -> Result { let value = value.trim(); if value.is_empty() { return Err(SongRequestError::Invalid("viewer identity is empty".into())); } Ok(value.chars().take(max).collect()) } async fn ensure_song_component( transaction: &Transaction<'_>, component: &ComponentInstance, ) -> Result<(), SongRequestError> { let valid: bool = transaction .query_one( "SELECT EXISTS(SELECT 1 FROM component_instances WHERE owner_user_id=$1 \ AND id=$2 AND kind='song_request' AND enabled)", &[&component.owner_id, &component.id], ) .await? .get(0); if valid { Ok(()) } else { Err(SongRequestError::NotFound) } } async fn lock_state( transaction: &Transaction<'_>, component: &ComponentInstance, ) -> Result { ensure_song_component(transaction, component).await?; transaction .execute( "INSERT INTO song_request_state(owner_user_id,component_instance_id) VALUES($1,$2) \ ON CONFLICT(component_instance_id) DO NOTHING", &[&component.owner_id, &component.id], ) .await?; Ok(transaction .query_one( "SELECT revision FROM song_request_state WHERE owner_user_id=$1 \ AND component_instance_id=$2 FOR UPDATE", &[&component.owner_id, &component.id], ) .await? .get(0)) } async fn state_revision( transaction: &Transaction<'_>, component_id: Uuid, ) -> Result { Ok(transaction .query_one( "SELECT revision FROM song_request_state WHERE component_instance_id=$1", &[&component_id], ) .await? .get(0)) } async fn bump_revision( transaction: &Transaction<'_>, component_id: Uuid, ) -> Result { Ok(transaction .query_one( "UPDATE song_request_state SET revision=revision+1,updated_at=now() \ WHERE component_instance_id=$1 RETURNING revision", &[&component_id], ) .await? .get(0)) } async fn promote_next( transaction: &Transaction<'_>, component: &ComponentInstance, ) -> Result<(), SongRequestError> { if let Some(row) = transaction .query_opt( "SELECT id FROM song_requests WHERE owner_user_id=$1 AND component_instance_id=$2 \ AND status='queued' ORDER BY queue_position ASC FOR UPDATE LIMIT 1", &[&component.owner_id, &component.id], ) .await? { let next_id: Uuid = row.get(0); transaction .execute( "UPDATE song_requests SET status='current',queue_position=0,started_at=now() \ WHERE owner_user_id=$1 AND id=$2", &[&component.owner_id, &next_id], ) .await?; } Ok(()) } 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 FROM song_requests r" } fn item_from_row(row: &Row) -> SongRequestItem { SongRequestItem { id: row.get(0), title: row.get(1), requester: SongRequester { uid: row.get(2), name: row.get(3), }, status: row.get(4), queue_position: row.get(5), requested_at: row.get(6), started_at: row.get(7), finished_at: row.get(8), } } async fn request_item( transaction: &Transaction<'_>, request_id: Uuid, ) -> Result, SongRequestError> { Ok(transaction .query_opt(&format!("{} WHERE r.id=$1", item_select()), &[&request_id]) .await? .map(|row| item_from_row(&row))) } async fn current_item( transaction: &Transaction<'_>, component_id: Uuid, ) -> Result, SongRequestError> { Ok(transaction .query_opt( &format!( "{} WHERE r.component_instance_id=$1 AND r.status='current'", item_select() ), &[&component_id], ) .await? .map(|row| item_from_row(&row))) } async fn queue_summary( transaction: &Transaction<'_>, component_id: Uuid, ) -> Result { let row = transaction .query_one( "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 \ FROM song_requests WHERE component_instance_id=$1", &[&component_id], ) .await?; Ok(SongQueueSummary { active_count: row.get(0), queued_count: row.get(1), completed_count: row.get(2), cancelled_count: row.get(3), }) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SongListScope { Active, History, } pub struct SongRequestDefinition; impl ComponentDefinition for SongRequestDefinition { fn kind(&self) -> &'static str { SONG_REQUEST_KIND } fn settings_version(&self) -> u32 { 1 } fn default_settings(&self) -> Value { serde_json::to_value(SongRequestSettings::default()) .expect("SongRequestSettings is always JSON serializable") } fn validate_settings(&self, settings: Value) -> Result { let parsed: SongRequestSettings = serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings { kind: SONG_REQUEST_KIND.into(), detail: error.to_string(), })?; serde_json::to_value(parsed.sanitize()).map_err(|error| ComponentError::InvalidSettings { kind: SONG_REQUEST_KIND.into(), detail: error.to_string(), }) } fn subscriptions(&self, _settings: &Value) -> Result { Ok(EventSubscription::new([LiveEventKind::Danmaku])) } } #[derive(Default)] pub struct SongRequestProjection; impl EventProjection for SongRequestProjection { fn project( &self, _component: &ComponentInstance, _event: &LiveEvent, ) -> Result, ComponentError> { Ok(None) } } pub struct SongRequestHandler { service: SongRequestService, } impl SongRequestHandler { pub fn new(service: SongRequestService) -> Self { Self { service } } } impl EventHandler for SongRequestHandler { fn name(&self) -> &'static str { "song-request-handler" } fn accepts(&self, _component: &ComponentInstance, event: &LiveEvent) -> bool { matches!(event.payload, LiveEventPayload::Danmaku(_)) } fn handle<'a>( &'a self, component: &'a ComponentInstance, event: Arc, ) -> HandlerFuture<'a> { Box::pin(async move { self.service .process_event(component, &event) .await .map_err(|error| ComponentError::Handler(error.to_string())) }) } } pub struct SongRequestSnapshotProvider { service: SongRequestService, } impl SongRequestSnapshotProvider { pub fn new(service: SongRequestService) -> Self { Self { service } } } impl ComponentSnapshotProvider for SongRequestSnapshotProvider { fn snapshot<'a>( &'a self, component: &'a ComponentInstance, room_id: &'a str, ) -> SnapshotFuture<'a> { Box::pin(async move { self.service .snapshot_messages(component, room_id) .await .map_err(|error| ComponentError::Projection(error.to_string())) }) } } #[derive(Debug)] pub enum SongRequestError { NotFound, Conflict(String), Invalid(String), Database(DbError), Postgres(tokio_postgres::Error), Json(serde_json::Error), } impl fmt::Display for SongRequestError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NotFound => formatter.write_str("song request component or item was not found"), Self::Conflict(message) => formatter.write_str(message), Self::Invalid(message) => formatter.write_str(message), Self::Database(error) => error.fmt(formatter), Self::Postgres(error) => error.fmt(formatter), Self::Json(error) => error.fmt(formatter), } } } impl Error for SongRequestError {} impl From for SongRequestError { fn from(value: DbError) -> Self { Self::Database(value) } } impl From for SongRequestError { fn from(value: tokio_postgres::Error) -> Self { Self::Postgres(value) } } impl From for SongRequestError { fn from(value: serde_json::Error) -> Self { Self::Json(value) } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_only_requests_with_normalized_whitespace() { assert_eq!( parse_command(" 点歌 My Song "), Some(SongCommand::Request { title: "My Song".into(), normalized_title: "my song".into(), }) ); 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(&format!("点歌 {}", "歌".repeat(81))), None); assert_eq!(parse_command(&format!("点歌{}", "歌".repeat(81))), None); } #[test] fn settings_keep_unlimited_defaults_and_bound_renderer_values() { let defaults = SongRequestSettings::default(); assert_eq!(defaults.max_queue_size, 0); assert_eq!(defaults.max_requests_per_viewer, 0); assert_eq!(defaults.request_cooldown_seconds, 0); let bounded = SongRequestSettings { font_scale: 999, font_brightness: u16::MAX, requester_color: Some(" #a1b2c3 ".into()), song_title_color: Some("transparent".into()), decoration_line_weight: 999, scroll_speed_pixels_per_second: 0, edge_pause_seconds: 200, max_queue_size: u32::MAX, max_requests_per_viewer: u16::MAX, request_cooldown_seconds: u32::MAX, ..SongRequestSettings::default() } .sanitize(); assert_eq!(bounded.font_scale, 250); assert_eq!(bounded.font_brightness, 180); assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3")); assert_eq!(bounded.song_title_color, None); assert_eq!(bounded.decoration_line_weight, 300); assert_eq!(bounded.scroll_speed_pixels_per_second, 5); assert_eq!(bounded.edge_pause_seconds, 15); assert_eq!(bounded.max_queue_size, 10_000); } }