Major refactor

This commit is contained in:
2026-04-03 23:04:51 -04:00
parent e7f337c735
commit 35d07e8df1
124 changed files with 4929 additions and 2429 deletions

View File

@@ -0,0 +1,89 @@
use crate::error::{Error, Result};
use reqwest::Url;
use serenity::{
all::UserId,
client::Cache,
model::prelude::{ChannelId, GuildId},
};
use songbird::Songbird;
use std::sync::Arc;
pub mod mute;
pub mod pause;
pub mod play;
pub mod resume;
pub mod skip;
pub mod stop;
pub mod volume;
/**
* Finds a voice channel that the user is currently in, and attempts to join it.
*/
pub async fn join_voice_channel(
cache: &Arc<Cache>,
manager: &Arc<Songbird>,
guild_id: &GuildId,
user_id: &UserId,
) -> Result<ChannelId> {
let channel_id = find_voice_channel(cache, guild_id, user_id)?;
log::debug!("<{}> Joining channel {}", guild_id.get(), channel_id.get());
match manager
.join(guild_id.to_owned(), channel_id.to_owned())
.await
{
Ok(_) => Ok(channel_id),
Err(e) => {
if e.should_leave_server() || e.should_reconnect_driver() {
log::debug!("<{}> Cleaning up failed voice connection", guild_id.get());
let _ = manager.remove(*guild_id).await;
}
Err(e.into())
}
}
}
/**
* Leaves a voice channel.
*/
pub async fn leave_voice_channel(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<()> {
if manager.get(guild_id.to_owned()).is_some() {
log::debug!("<{}> Disconnecting from channel", guild_id.get());
manager.remove(*guild_id).await?;
}
Ok(())
}
/**
* Validates whether the given string is a properly formatted URL.
*
* Returns `true` if the input string is a valid URL, otherwise `false`.
*/
fn is_valid_url(url: &str) -> bool {
Url::parse(url).is_ok()
}
/**
* Finds a voice channel that the user is currently in.
*/
fn find_voice_channel(
cache: &Arc<Cache>,
guild_id: &GuildId,
user_id: &UserId,
) -> Result<ChannelId> {
let guild = match guild_id.to_guild_cached(cache) {
Some(g) => g,
None => return Err(Error::new(404, "Guild not found".to_string())),
};
match guild
.voice_states
.get(&user_id)
.and_then(|voice_state| voice_state.channel_id)
{
Some(channel) => Ok(channel),
None => Err(Error::new(
400,
"User is not in a voice channel".to_string(),
)),
}
}

View File

@@ -0,0 +1,54 @@
use crate::{
chat::{edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand},
prelude::*,
};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Mute the track
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let mut handler = handler_lock.lock().await;
let is_muted = handler.is_mute();
match handler.mute(!is_muted).await {
Ok(_) => {
if is_muted {
log::debug!("<{guild_id}> Unmuted");
edit_response(&ctx, &command, "Unmuted".to_string()).await;
} else {
log::debug!("<{guild_id}> Muted");
edit_response(&ctx, &command, "Muted".to_string()).await;
}
}
Err(err) => {
edit_response(&ctx, &command, format!("Failed to mute: {}", err)).await;
}
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("mute").description("Mute/unmute Siren")
}

View File

@@ -0,0 +1,63 @@
use crate::{
chat::{edit_response, process_message},
error::{Error, Result},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand, GuildId},
prelude::*,
};
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Pause the track
match pause_track(manager, guild_id).await {
Ok(_) => {
log::debug!("<{guild_id}> Paused the track");
edit_response(&ctx, &command, "Pausing the track".to_string()).await;
}
Err(err) => edit_response(&ctx, &command, format!("Failed to pause: {}", err)).await,
}
}
pub async fn pause_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<()> {
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
match handler.queue().current() {
Some(track) => track.pause()?,
None => {
return Err(Error {
status: 404,
details: "No track is currently playing".to_string(),
});
}
}
};
Ok(())
}
pub fn register() -> CreateCommand {
CreateCommand::new("pause").description("Pause the current track")
}

View File

@@ -0,0 +1,218 @@
use super::{is_valid_url, join_voice_channel, leave_voice_channel};
use crate::{
chat::{create_message_response, edit_response, process_message},
error::{Error, Result},
handler::{get_client, get_songbird},
ytdlp::{YtDlp, YtDlpItem},
};
use serenity::{
all::{CommandInteraction, CommandOptionType, CreateCommand, CreateCommandOption},
async_trait,
model::prelude::GuildId,
prelude::*,
};
use siren_core::data::guilds::GuildCache;
use songbird::{
Event,
EventHandler,
Songbird,
TrackEvent,
input::{Input, YoutubeDl},
tracks::TrackHandle,
};
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Process the command options
let track_url = match command.data.options.first() {
Some(o) => o.value.as_str().unwrap(),
None => {
log::warn!(
"<{}> {} attempted to play a track without a track option",
command.guild_id.unwrap(),
command.user.id.get()
);
create_message_response(&ctx, &command, "Track option is missing".to_string(), false).await;
return;
}
};
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Join the user's voice channel
match join_voice_channel(&ctx.cache, &manager, guild_id, &command.user.id).await {
Ok(channel_id) => {
log::debug!(
"<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}"
);
// Handle the track url
match enqueue_track(manager, guild_id.to_owned(), track_url).await {
Ok(items) => {
let mut message = format!("Added {} tracks", items.len());
if items.len() == 0 {
message = "No tracks were played".to_string();
log::warn!("<{guild_id}> No tracks were played");
if let Err(err) = leave_voice_channel(&manager, guild_id).await {
log::error!("Failed to leave voice channel: {}", err);
};
} else if items.len() == 1 {
message = format!("Added **{}**", items[0].get_title());
}
edit_response(&ctx, &command, message).await;
}
Err(err) => {
log::error!("Failed to play track: {}", err);
if let Err(err) = leave_voice_channel(&manager, guild_id).await {
log::error!("Failed to leave voice channel: {}", err);
}
edit_response(&ctx, &command, format!("Failed to play track: {}", err)).await;
}
};
}
Err(err) => {
log::warn!("<{guild_id}> Failed to join voice channel: {}", err);
edit_response(&ctx, &command, format!("{}", err)).await;
}
}
}
pub async fn enqueue_track(
manager: &Arc<Songbird>,
guild_id: GuildId,
track_url: &str,
) -> Result<Vec<YtDlpItem>> {
let mut playlist_items: Vec<YtDlpItem> = Vec::new();
if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await;
let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
let valid = is_valid_url(&track_url);
// Check if the URL is valid
if !valid {
log::warn!("<{guild_id}> Invalid track url: {}", track_url);
return Err(Error::new(422, format!("Invalid track url: {}", track_url)));
}
playlist_items = get_ytdlp_items(&track_url)?;
// Add each track to the queue
for item in &playlist_items {
let volume = guild.volume as f32 / 100.0;
let http_client = get_client();
let source = YoutubeDl::new(http_client.to_owned(), item.get_url().to_owned());
let input: Input = source.into();
let track_title = item.get_title().to_owned();
let track_handle: TrackHandle;
track_handle = handler.enqueue_input(input).await;
// Set the volume
let _ = track_handle.set_volume(volume);
log::debug!("<{guild_id}> Added track: {}", track_title);
handler.remove_all_global_events();
handler.add_global_event(
Event::Track(TrackEvent::End),
TrackEndNotifier {
guild_id,
call: manager.clone(),
},
);
}
if handler.queue().is_empty() {
let _ = handler.queue().resume();
}
}
Ok(playlist_items)
}
pub fn get_ytdlp_items(url: &str) -> Result<Vec<YtDlpItem>> {
let output = YtDlp::new()
.arg("--flat-playlist")
.arg("--dump-json")
.arg("--no-check-formats")
.arg(url)
.execute()?;
// Check if yt-dlp exited successfully; log stderr if not
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::new(
500,
format!("yt-dlp failed ({}): {}", output.status, stderr.trim()),
));
}
let stdout = String::from_utf8(output.stdout)?;
let items: Vec<YtDlpItem> = stdout
.split('\n')
.filter_map(|line| {
if line.is_empty() {
None
} else {
Some(
serde_json::from_slice::<YtDlpItem>(line.as_bytes())
.map_err(|err| Error::new(500, err.to_string())),
)
}
})
.filter_map(|parsed| match parsed {
Ok(item) => Some(item),
Err(err) => {
log::warn!("Failed to parse yt-dlp item: {}", err);
None
}
})
.collect();
Ok(items)
}
pub fn register() -> CreateCommand {
CreateCommand::new("play")
.description("Plays the given track")
.add_option(
CreateCommandOption::new(CommandOptionType::String, "track", "The track to be played")
.required(true),
)
}
struct TrackEndNotifier {
pub call: Arc<Songbird>,
pub guild_id: GuildId,
}
#[async_trait]
impl EventHandler for TrackEndNotifier {
async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option<songbird::events::Event> {
if let songbird::EventContext::Track(_track_list) = ctx {
if let Some(call) = self.call.get(self.guild_id) {
let mut handler = call.lock().await;
if handler.queue().is_empty() {
log::debug!("Queue is empty, leaving voice channel");
handler.leave().await.unwrap();
}
}
}
None
}
}

View File

@@ -0,0 +1,63 @@
use crate::{
chat::{edit_response, process_message},
error::{Error, Result},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand, GuildId},
prelude::*,
};
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Resume the track
match resume_track(manager, guild_id).await {
Ok(_) => {
log::debug!("<{guild_id}> Resumed the track");
edit_response(&ctx, &command, "resuming the track".to_string()).await;
}
Err(err) => edit_response(&ctx, &command, format!("Failed to resume: {}", err)).await,
}
}
pub async fn resume_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<()> {
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
match handler.queue().current() {
Some(track) => track.play()?,
None => {
return Err(Error {
status: 404,
details: "No track is currently playing".to_string(),
});
}
}
};
Ok(())
}
pub fn register() -> CreateCommand {
CreateCommand::new("resume").description("Resume the current track")
}

View File

@@ -0,0 +1,48 @@
use crate::{
chat::{edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand},
prelude::*,
};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Skip the track
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
match handler.queue().skip() {
Ok(_) => {
log::debug!("<{guild_id}> Skipped the track");
edit_response(&ctx, &command, "Skipping the track".to_string()).await;
}
Err(err) => {
edit_response(&ctx, &command, format!("Failed to skip: {}", err)).await;
}
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("skip").description("Skip the current track")
}

View File

@@ -0,0 +1,42 @@
use crate::{
chat::{edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand},
prelude::*,
};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match command.guild_id {
Some(g) => g,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Stop the track and clear the queue
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
handler.queue().stop();
log::debug!("<{guild_id}> Stopped the track");
edit_response(&ctx, &command, "Stopping the tracks".to_string()).await;
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("stop").description("Stop the current track and clear the queue")
}

View File

@@ -0,0 +1,92 @@
use crate::{
chat::{create_message_response, edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CommandOptionType, CreateCommand, CreateCommandOption},
model::prelude::GuildId,
prelude::*,
};
use siren_core::data::guilds::GuildCache;
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Process the command options
let volume = match command.data.options.first() {
Some(o) => o.value.as_i64().unwrap() as i32,
None => {
log::warn!(
"{} attempted to change the volume without a volume option",
command.user.id.get()
);
create_message_response(
&ctx,
&command,
"Volume option is missing".to_string(),
false,
)
.await;
return;
}
};
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Set the volume
set_volume(&manager, guild_id, volume).await;
log::debug!("<{guild_id}> Setting the volume to {}", volume);
edit_response(&ctx, &command, format!("Setting the volume to {}", volume)).await;
}
pub async fn set_volume(manager: &Arc<Songbird>, guild_id: &GuildId, volume: i32) {
// Format volume to f32 bound between 0.0 and 1.0
let volume = std::cmp::min(100, std::cmp::max(0, volume));
let bound_volume = volume as f32 / 100.0;
// Update the guild cache
let mut guild_cache = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
guild_cache.volume = volume;
guild_cache.update().await.unwrap();
// Update the volume of the songbird handler
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
for (_, track_handle) in handler.queue().current_queue().iter().enumerate() {
if let Err(err) = track_handle.set_volume(bound_volume) {
log::error!("Unable to set volume: {err}");
}
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("volume")
.description("Set the audio player volume")
.add_option(
CreateCommandOption::new(
CommandOptionType::Integer,
"volume",
"Volume between 0 and 100",
)
.required(true),
)
}