1052 lines
34 KiB
Rust
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);
|
|
}
|
|
}
|