soundboard
All checks were successful
/ upload (release) Successful in 27s

This commit is contained in:
pavel 2026-02-24 01:41:24 +01:00
commit 33da605c03
13 changed files with 534 additions and 9 deletions

View file

@ -8,10 +8,13 @@ use sea_orm::{
use uuid::Uuid;
use crate::{
entity::{channels, direct_messages, guild_members, guilds, invites, messages, users},
entity::{
channels, direct_messages, guild_members, guilds, invites, messages, soundboard_sounds,
users,
},
models::{
BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor,
User,
SoundboardSound, User,
},
};
@ -575,3 +578,48 @@ async fn increment_invite_use_count(
active.update(txn).await?;
Ok(())
}
pub async fn list_sounds(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<SoundboardSound>> {
let rows = soundboard_sounds::Entity::find()
.filter(soundboard_sounds::Column::GuildId.eq(guild_id))
.order_by_asc(soundboard_sounds::Column::CreatedAt)
.all(db)
.await?;
Ok(rows.into_iter().map(map_sound).collect())
}
pub async fn create_sound(
db: &DatabaseConnection,
guild_id: Uuid,
created_by_user_id: Uuid,
name: &str,
icon: &str,
file_path: &str,
) -> Result<SoundboardSound> {
let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel {
id: Set(Uuid::new_v4()),
guild_id: Set(guild_id),
created_by_user_id: Set(created_by_user_id),
name: Set(name.to_string()),
icon: Set(icon.to_string()),
file_path: Set(file_path.to_string()),
..Default::default()
})
.exec_with_returning(db)
.await?;
Ok(map_sound(model))
}
fn map_sound(model: soundboard_sounds::Model) -> SoundboardSound {
SoundboardSound {
id: model.id,
guild_id: model.guild_id,
name: model.name,
icon: model.icon,
file_path: model.file_path,
created_by_user_id: model.created_by_user_id,
created_at: model.created_at,
}
}

View file

@ -4,4 +4,5 @@ pub mod guild_members;
pub mod guilds;
pub mod invites;
pub mod messages;
pub mod soundboard_sounds;
pub mod users;

View file

@ -0,0 +1,49 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "soundboard_sounds")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub guild_id: Uuid,
pub name: String,
pub icon: String,
pub file_path: String,
pub created_by_user_id: Uuid,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guilds::Entity",
from = "Column::GuildId",
to = "super::guilds::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guilds,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::CreatedByUserId",
to = "super::users::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Users,
}
impl Related<super::guilds::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guilds.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -11,7 +11,9 @@ use uuid::Uuid;
use crate::{
AppState,
auth::{self, ApiError, AuthUser},
chat, db, voice,
chat, db,
models::{Guild, SoundboardSound},
voice,
};
pub fn routes() -> Router<AppState> {
@ -37,6 +39,10 @@ pub fn routes() -> Router<AppState> {
)
.route("/guilds/{guild_id}/invites", post(create_invite))
.route("/invites/{code}/join", post(join_invite))
.route(
"/guilds/{guild_id}/sounds",
get(list_sounds).post(upload_sound),
)
.route("/channels", post(create_channel))
.route(
"/channels/{channel_id}/messages",
@ -399,11 +405,8 @@ async fn join_invite(
State(state): State<AppState>,
user: AuthUser,
Path(code): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let guild = db::join_invite(&state.db, &code.to_uppercase(), user.id)
.await
.map_err(|e| ApiError::bad_request(&format!("failed to join invite: {e}")))?;
) -> Result<Json<Guild>, ApiError> {
let guild = db::join_invite(&state.db, &code, user.id).await?;
Ok(Json(guild))
}
@ -675,6 +678,87 @@ async fn chat_ws(
Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id)))
}
async fn list_sounds(
State(state): State<AppState>,
user: AuthUser,
Path(guild_id): Path<Uuid>,
) -> Result<Json<Vec<SoundboardSound>>, ApiError> {
ensure_guild_member(&state, guild_id, user.id).await?;
let sounds = db::list_sounds(&state.db, guild_id).await?;
Ok(Json(sounds))
}
async fn upload_sound(
State(state): State<AppState>,
user: AuthUser,
Path(guild_id): Path<Uuid>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<SoundboardSound>, ApiError> {
ensure_guild_member(&state, guild_id, user.id).await?;
let mut name = None;
let mut icon = None;
let mut file_data = None;
let mut file_name = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?
{
let field_name = field.name().unwrap_or_default().to_string();
if field_name == "name" {
name = Some(
field
.text()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?,
);
} else if field_name == "icon" {
icon = Some(
field
.text()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?,
);
} else if field_name == "file" {
file_name = Some(field.file_name().unwrap_or("sound.mp3").to_string());
file_data = Some(
field
.bytes()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?,
);
}
}
let (name, icon, file_data, file_name) = match (name, icon, file_data, file_name) {
(Some(n), Some(i), Some(d), Some(f)) => (n, i, d, f),
_ => return Err(ApiError::bad_request("missing fields")),
};
let extension = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("mp3");
let safe_file_name = format!("{}.{}", Uuid::new_v4(), extension);
let upload_dir = std::path::Path::new("static/uploads/soundboard");
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| ApiError::internal(&e.to_string()))?;
let file_path = upload_dir.join(&safe_file_name);
tokio::fs::write(&file_path, file_data)
.await
.map_err(|e| ApiError::internal(&e.to_string()))?;
let web_path = format!("/static/uploads/soundboard/{}", safe_file_name);
let sound = db::create_sound(&state.db, guild_id, user.id, &name, &icon, &web_path).await?;
Ok(Json(sound))
}
async fn ensure_guild_member(
state: &AppState,
guild_id: Uuid,

View file

@ -0,0 +1,86 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(SoundboardSounds::Table)
.if_not_exists()
.col(
ColumnDef::new(SoundboardSounds::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(SoundboardSounds::GuildId).uuid().not_null())
.col(ColumnDef::new(SoundboardSounds::Name).string().not_null())
.col(ColumnDef::new(SoundboardSounds::Icon).string().not_null())
.col(
ColumnDef::new(SoundboardSounds::FilePath)
.string()
.not_null(),
)
.col(
ColumnDef::new(SoundboardSounds::CreatedByUserId)
.uuid()
.not_null(),
)
.col(
ColumnDef::new(SoundboardSounds::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_soundboard_sounds_guild")
.from(SoundboardSounds::Table, SoundboardSounds::GuildId)
.to(Guilds::Table, Guilds::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_soundboard_sounds_user")
.from(SoundboardSounds::Table, SoundboardSounds::CreatedByUserId)
.to(Users::Table, Users::Id),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(SoundboardSounds::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum SoundboardSounds {
Table,
Id,
GuildId,
Name,
Icon,
FilePath,
CreatedByUserId,
CreatedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}
#[derive(DeriveIden)]
enum Guilds {
Table,
Id,
}

View file

@ -4,6 +4,7 @@ mod m20260213_000001_init;
mod m20260213_000002_invites;
mod m20260213_000003_channel_kind;
mod m20260213_000004_direct_messages;
mod m20260224_000005_soundboard;
pub struct Migrator;
@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260213_000002_invites::Migration),
Box::new(m20260213_000003_channel_kind::Migration),
Box::new(m20260213_000004_direct_messages::Migration),
Box::new(m20260224_000005_soundboard::Migration),
]
}
}

View file

@ -84,3 +84,14 @@ pub struct Invite {
pub max_uses: Option<i32>,
pub use_count: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct SoundboardSound {
pub id: Uuid,
pub guild_id: Uuid,
pub name: String,
pub icon: String,
pub file_path: String,
pub created_by_user_id: Uuid,
pub created_at: DateTimeWithTimeZone,
}

View file

@ -64,6 +64,10 @@ enum ServerEvent {
Error {
message: String,
},
PlaySound {
user_id: Uuid,
sound_url: String,
},
}
#[derive(Deserialize)]
@ -83,6 +87,9 @@ enum ClientEvent {
SetSpeakingStatus {
is_speaking: bool,
},
PlaySound {
sound_url: String,
},
}
impl VoiceHub {
@ -245,6 +252,20 @@ impl VoiceHub {
}
}
}
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, sound_url: String) {
let rooms = self.rooms.read().await;
let Some(room) = rooms.get(&room_id) else {
return;
};
for peer in room.values() {
let _ = peer.tx.send(ServerEvent::PlaySound {
user_id,
sound_url: sound_url.clone(),
});
}
}
}
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
@ -305,6 +326,9 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
.set_speaking_status(room_id, user_id, is_speaking)
.await;
}
Ok(ClientEvent::PlaySound { sound_url }) => {
state.voice.play_sound(room_id, user_id, sound_url).await;
}
Err(err) => {
let _ = tx.send(ServerEvent::Error {
message: format!("invalid voice message: {err}"),