Files
lxc-streamutils/apps/server-rust/src/song_request.rs
T
2026-08-19 12:30:30 -07:00

1052 lines
34 KiB
Rust

//! 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<String>,
#[serde(default)]
pub song_title_color: Option<String>,
#[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<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)
}
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<SongCommand> {
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::<Vec<_>>().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<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
}
#[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<SongRequestItem>,
pub items: Vec<SongRequestItem>,
pub next_cursor: Option<i64>,
pub summary: SongQueueSummary,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SongQueueChange {
revision: i64,
operation: &'static str,
item_id: Uuid,
item: Option<SongRequestItem>,
current: Option<SongRequestItem>,
}
#[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<SongRequestPage, SongRequestError> {
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<Vec<ComponentMessage>, 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<u64, 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 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<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?;
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<String, SongRequestError> {
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<i64, SongRequestError> {
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<i64, SongRequestError> {
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<i64, SongRequestError> {
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<Option<SongRequestItem>, 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<Option<SongRequestItem>, 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<SongQueueSummary, SongRequestError> {
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<Value, ComponentError> {
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<EventSubscription, ComponentError> {
Ok(EventSubscription::new([LiveEventKind::Danmaku]))
}
}
#[derive(Default)]
pub struct SongRequestProjection;
impl EventProjection for SongRequestProjection {
fn project(
&self,
_component: &ComponentInstance,
_event: &LiveEvent,
) -> Result<Option<ComponentMessage>, 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<LiveEvent>,
) -> 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<DbError> for SongRequestError {
fn from(value: DbError) -> Self {
Self::Database(value)
}
}
impl From<tokio_postgres::Error> for SongRequestError {
fn from(value: tokio_postgres::Error) -> Self {
Self::Postgres(value)
}
}
impl From<serde_json::Error> 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);
}
}