From b6dd726c17d91a6f6d1d4e9141d1742fa540b214 Mon Sep 17 00:00:00 2001 From: trivernis Date: Thu, 8 Apr 2021 10:45:24 +0200 Subject: [PATCH] Reimplement queue and add guild settings Signed-off-by: trivernis --- .idea/csv-plugin.xml | 35 ++++ .idea/inspectionProfiles/Project_Default.xml | 10 ++ Cargo.lock | 1 - Cargo.toml | 3 +- src/client.rs | 10 +- src/commands/minecraft/enchantment.rs | 4 +- src/commands/minecraft/item.rs | 1 + src/commands/misc/help.rs | 4 +- src/commands/misc/mod.rs | 2 +- src/commands/misc/ping.rs | 2 +- src/commands/mod.rs | 2 + src/commands/music/clear.rs | 28 ++++ src/commands/music/current.rs | 17 +- src/commands/music/join.rs | 2 +- src/commands/music/leave.rs | 24 ++- src/commands/music/mod.rs | 162 ++++++++++++++++++- src/commands/music/play.rs | 81 ++++------ src/commands/music/play_next.rs | 57 +++++++ src/commands/music/queue.rs | 22 +-- src/commands/music/shuffle.rs | 32 ++-- src/commands/music/skip.rs | 20 +-- src/commands/music/utils.rs | 33 ---- src/commands/settings/get.rs | 60 +++++++ src/commands/settings/mod.rs | 12 ++ src/commands/settings/set.rs | 28 ++++ src/database/guild.rs | 9 +- src/database/mod.rs | 58 ++++++- src/database/scripts/create_tables.sql | 28 +--- src/database/scripts/update_tables.sql | 4 +- src/providers/mod.rs | 2 +- src/providers/music/mod.rs | 38 +++++ src/providers/music/queue.rs | 96 +++++++++++ src/providers/music/responses.rs | 19 +++ src/providers/ytdl/mod.rs | 32 ---- src/providers/ytdl/playlist_entry.rs | 9 -- src/utils/error.rs | 3 + src/utils/mod.rs | 14 ++ src/utils/store.rs | 7 +- 38 files changed, 728 insertions(+), 243 deletions(-) create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 src/commands/music/clear.rs create mode 100644 src/commands/music/play_next.rs delete mode 100644 src/commands/music/utils.rs create mode 100644 src/commands/settings/get.rs create mode 100644 src/commands/settings/mod.rs create mode 100644 src/commands/settings/set.rs create mode 100644 src/providers/music/mod.rs create mode 100644 src/providers/music/queue.rs create mode 100644 src/providers/music/responses.rs delete mode 100644 src/providers/ytdl/mod.rs delete mode 100644 src/providers/ytdl/playlist_entry.rs diff --git a/.idea/csv-plugin.xml b/.idea/csv-plugin.xml index 36b3458..623c7ae 100644 --- a/.idea/csv-plugin.xml +++ b/.idea/csv-plugin.xml @@ -17,6 +17,27 @@ + + + + + + + + + + + + + + + + + + @@ -24,6 +45,13 @@ + + + + + + @@ -38,6 +66,13 @@ + + + + + + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..a3f5ff6 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 20b934d..ae573b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1666,7 +1666,6 @@ version = "0.1.0" dependencies = [ "dotenv", "minecraft-data-rs", - "parking_lot", "rand 0.8.3", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4d28677..0486fe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,7 @@ rusqlite = "0.24" serde_derive = "1.0.125" serde = "1.0.125" thiserror = "1.0.24" -parking_lot = "0.11.1" minecraft-data-rs = "0.2.0" -songbird = {version = "0.1.5", features=["builtin-queue"]} +songbird = "0.1.5" serde_json = "1.0.64" rand = "0.8.3" \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index db0f31e..48dd0fc 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,10 +1,10 @@ use serenity::async_trait; use serenity::client::{Context, EventHandler}; -use serenity::framework::standard::macros::hook; +use serenity::Client; use serenity::framework::standard::{CommandResult, DispatchError}; +use serenity::framework::standard::macros::hook; use serenity::framework::StandardFramework; use serenity::model::channel::Message; -use serenity::Client; use songbird::SerenityInit; use crate::commands::*; @@ -41,12 +41,13 @@ pub fn get_framework() -> StandardFramework { .unwrap_or("~!".to_string()) .as_str(), ) - .allow_dm(true) - .ignore_bots(true) + .allow_dm(true) + .ignore_bots(true) }) .group(&MINECRAFT_GROUP) .group(&MISC_GROUP) .group(&MUSIC_GROUP) + .group(&SETTINGS_GROUP) .after(after_hook) .before(before_hook) .on_dispatch_error(dispatch_error) @@ -67,6 +68,7 @@ async fn before_hook(ctx: &Context, msg: &Message, _: &str) -> bool { let _ = msg.channel_id.broadcast_typing(ctx).await; true } + #[hook] async fn dispatch_error(ctx: &Context, msg: &Message, error: DispatchError) { match error { diff --git a/src/commands/minecraft/enchantment.rs b/src/commands/minecraft/enchantment.rs index 45dd8d1..e05db59 100644 --- a/src/commands/minecraft/enchantment.rs +++ b/src/commands/minecraft/enchantment.rs @@ -9,6 +9,7 @@ use crate::utils::store::Store; #[usage("enchantment ")] #[example("item unbreaking")] #[min_args(1)] +#[aliases("ench")] pub(crate) async fn enchantment(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let data = ctx.data.read().await; let store = data.get::().expect("Failed to get store"); @@ -31,7 +32,8 @@ pub(crate) async fn enchantment(ctx: &Context, msg: &Message, args: Args) -> Com e = e .title(enchantment.display_name) .field("Name", enchantment.name, false) - .field("Category", enchantment.category, false); + .field("Category", enchantment.category, false) + .thumbnail("https://minecraftitemids.com/item/128/enchanted_book.png"); if !enchantment.exclude.is_empty() { e = e.field("Incompatible With", enchantment.exclude.join(", "), false); } diff --git a/src/commands/minecraft/item.rs b/src/commands/minecraft/item.rs index dab5a3a..380d5bb 100644 --- a/src/commands/minecraft/item.rs +++ b/src/commands/minecraft/item.rs @@ -9,6 +9,7 @@ use crate::utils::store::Store; #[usage("item ")] #[example("item bread")] #[min_args(1)] +#[aliases("i")] pub(crate) async fn item(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let data = ctx.data.read().await; let store = data.get::().expect("Failed to get store"); diff --git a/src/commands/misc/help.rs b/src/commands/misc/help.rs index b8bbc36..9896e3c 100644 --- a/src/commands/misc/help.rs +++ b/src/commands/misc/help.rs @@ -1,9 +1,9 @@ use std::collections::HashSet; use serenity::client::Context; -use serenity::framework::standard::macros::help; -use serenity::framework::standard::{help_commands, Args}; +use serenity::framework::standard::{Args, help_commands}; use serenity::framework::standard::{CommandGroup, CommandResult, HelpOptions}; +use serenity::framework::standard::macros::help; use serenity::model::channel::Message; use serenity::model::id::UserId; diff --git a/src/commands/misc/mod.rs b/src/commands/misc/mod.rs index d1069fb..f175978 100644 --- a/src/commands/misc/mod.rs +++ b/src/commands/misc/mod.rs @@ -3,7 +3,7 @@ use serenity::framework::standard::macros::group; use ping::PING_COMMAND; pub(crate) mod help; -pub(crate) mod ping; +mod ping; #[group] #[commands(ping)] diff --git a/src/commands/misc/ping.rs b/src/commands/misc/ping.rs index 0b51d95..1198939 100644 --- a/src/commands/misc/ping.rs +++ b/src/commands/misc/ping.rs @@ -1,6 +1,6 @@ use serenity::client::Context; -use serenity::framework::standard::CommandResult; use serenity::framework::standard::macros::command; +use serenity::framework::standard::CommandResult; use serenity::model::channel::Message; #[command] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index aa01729..f378f65 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,7 +2,9 @@ pub use minecraft::MINECRAFT_GROUP; pub use misc::help::HELP; pub use misc::MISC_GROUP; pub use music::MUSIC_GROUP; +pub use settings::SETTINGS_GROUP; pub(crate) mod minecraft; pub(crate) mod misc; pub(crate) mod music; +pub(crate) mod settings; diff --git a/src/commands/music/clear.rs b/src/commands/music/clear.rs new file mode 100644 index 0000000..8d3c819 --- /dev/null +++ b/src/commands/music/clear.rs @@ -0,0 +1,28 @@ +use serenity::client::Context; +use serenity::framework::standard::CommandResult; +use serenity::framework::standard::macros::command; +use serenity::model::channel::Message; + +use crate::commands::music::get_queue_for_guild; + +#[command] +#[only_in(guilds)] +#[description("Clears the queue")] +#[usage("clear")] +#[aliases("cl")] +#[allowed_roles("DJ")] +async fn clear(ctx: &Context, msg: &Message) -> CommandResult { + let guild = msg.guild(&ctx.cache).await.unwrap(); + + let queue = get_queue_for_guild(ctx, &guild.id).await?; + { + let mut queue_lock = queue.lock().await; + queue_lock.clear(); + } + + msg.channel_id + .say(ctx, "The queue has been cleared") + .await?; + + Ok(()) +} diff --git a/src/commands/music/current.rs b/src/commands/music/current.rs index 856ae7e..91c3768 100644 --- a/src/commands/music/current.rs +++ b/src/commands/music/current.rs @@ -1,8 +1,10 @@ use serenity::client::Context; +use serenity::framework::standard::CommandResult; use serenity::framework::standard::macros::command; -use serenity::framework::standard::{CommandError, CommandResult}; use serenity::model::channel::Message; +use crate::commands::music::get_queue_for_guild; + #[command] #[only_in(guilds)] #[description("Displays the currently playing song")] @@ -11,17 +13,10 @@ use serenity::model::channel::Message; async fn current(ctx: &Context, msg: &Message) -> CommandResult { let guild = msg.guild(&ctx.cache).await.unwrap(); - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let handler_lock = manager - .get(guild.id) - .ok_or(CommandError::from("Not in a voice channel"))?; - let handler = handler_lock.lock().await; + let queue = get_queue_for_guild(ctx, &guild.id).await?; + let queue_lock = queue.lock().await; - if let Some(current) = handler.queue().current() { + if let Some(current) = queue_lock.current() { let metadata = current.metadata().clone(); msg.channel_id .send_message(ctx, |m| { diff --git a/src/commands/music/join.rs b/src/commands/music/join.rs index 15792ae..ce0b5ae 100644 --- a/src/commands/music/join.rs +++ b/src/commands/music/join.rs @@ -3,7 +3,7 @@ use serenity::framework::standard::macros::command; use serenity::framework::standard::CommandResult; use serenity::model::channel::Message; -use crate::commands::music::utils::{get_channel_for_author, join_channel}; +use crate::commands::music::{get_channel_for_author, join_channel}; #[command] #[only_in(guilds)] diff --git a/src/commands/music/leave.rs b/src/commands/music/leave.rs index 1b0d074..efcfb1e 100644 --- a/src/commands/music/leave.rs +++ b/src/commands/music/leave.rs @@ -3,22 +3,32 @@ use serenity::framework::standard::CommandResult; use serenity::framework::standard::macros::command; use serenity::model::channel::Message; +use crate::commands::music::{get_queue_for_guild, get_voice_manager}; + #[command] #[only_in(guilds)] #[description("Leaves a voice channel")] #[usage("leave")] #[aliases("stop")] +#[allowed_roles("DJ")] async fn leave(ctx: &Context, msg: &Message) -> CommandResult { let guild = msg.guild(&ctx.cache).await.unwrap(); - let guild_id = guild.id; - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); + let manager = get_voice_manager(ctx).await; + let queue = get_queue_for_guild(ctx, &guild.id).await?; + let queue_lock = queue.lock().await; + let handler = manager.get(guild.id); + + if let Some(handler) = handler { + let mut handler_lock = handler.lock().await; + handler_lock.remove_all_global_events(); + } + if let Some(current) = queue_lock.current() { + current.stop()?; + } - if manager.get(guild_id).is_some() { - manager.remove(guild_id).await?; + if manager.get(guild.id).is_some() { + manager.remove(guild.id).await?; msg.channel_id.say(ctx, "Left the voice channel").await?; } else { msg.channel_id.say(ctx, "Not in a voice channel").await?; diff --git a/src/commands/music/mod.rs b/src/commands/music/mod.rs index 7c8a064..93c35e1 100644 --- a/src/commands/music/mod.rs +++ b/src/commands/music/mod.rs @@ -1,23 +1,181 @@ +use std::sync::Arc; + +use serenity::async_trait; +use serenity::client::Context; use serenity::framework::standard::macros::group; +use serenity::http::Http; +use serenity::model::channel::Message; +use serenity::model::guild::Guild; +use serenity::model::id::{ChannelId, GuildId, UserId}; +use songbird::{ + Call, Event, EventContext, EventHandler as VoiceEventHandler, Songbird, TrackEvent, +}; +use tokio::sync::Mutex; +use clear::CLEAR_COMMAND; use current::CURRENT_COMMAND; use join::JOIN_COMMAND; use leave::LEAVE_COMMAND; use play::PLAY_COMMAND; +use play_next::PLAY_NEXT_COMMAND; use queue::QUEUE_COMMAND; use shuffle::SHUFFLE_COMMAND; use skip::SKIP_COMMAND; +use crate::providers::music::{get_video_information, get_videos_for_playlist}; +use crate::providers::music::queue::{MusicQueue, Song}; +use crate::utils::error::{BotError, BotResult}; +use crate::utils::store::Store; + +mod clear; mod current; mod join; mod leave; mod play; +mod play_next; mod queue; mod shuffle; mod skip; -mod utils; #[group] -#[commands(join, leave, play, queue, skip, shuffle, current)] +#[commands(join, leave, play, queue, skip, shuffle, current, play_next, clear)] #[prefix("m")] pub struct Music; + +/// Joins a voice channel +async fn join_channel(ctx: &Context, channel_id: ChannelId, guild_id: GuildId) -> Arc> { + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let (handler, _) = manager.join(guild_id, channel_id).await; + let mut data = ctx.data.write().await; + let store = data.get_mut::().unwrap(); + let queue = Arc::new(Mutex::new(MusicQueue::new())); + + store.music_queues.insert(guild_id, queue.clone()); + { + let mut handler_lock = handler.lock().await; + + handler_lock.add_global_event( + Event::Track(TrackEvent::End), + SongEndNotifier { + channel_id, + http: ctx.http.clone(), + queue: Arc::clone(&queue), + handler: handler.clone(), + }, + ); + } + + handler +} + +/// Returns the voice channel the author is in +fn get_channel_for_author(author_id: &UserId, guild: &Guild) -> BotResult { + guild + .voice_states + .get(author_id) + .and_then(|voice_state| voice_state.channel_id) + .ok_or(BotError::from("Not in a voice channel.")) +} + +/// Returns the voice manager from the context +async fn get_voice_manager(ctx: &Context) -> Arc { + songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone() +} + +/// Returns a reference to a guilds music queue +async fn get_queue_for_guild( + ctx: &Context, + guild_id: &GuildId, +) -> BotResult>> { + let data = ctx.data.read().await; + let store = data.get::().unwrap(); + + let queue = store + .music_queues + .get(guild_id) + .ok_or(BotError::from("No queue for server"))? + .clone(); + Ok(queue) +} + +struct SongEndNotifier { + channel_id: ChannelId, + http: Arc, + queue: Arc>, + handler: Arc>, +} + +#[async_trait] +impl VoiceEventHandler for SongEndNotifier { + async fn act(&self, _ctx: &EventContext<'_>) -> Option { + play_next_in_queue(&self.http, &self.channel_id, &self.queue, &self.handler).await; + + None + } +} + +/// Plays the next song in the queue +async fn play_next_in_queue( + http: &Arc, + channel_id: &ChannelId, + queue: &Arc>, + handler: &Arc>, +) { + let mut queue_lock = queue.lock().await; + + if let Some(next) = queue_lock.next() { + let source = match songbird::ytdl(&next.url).await { + Ok(s) => s, + Err(e) => { + let _ = channel_id + .say(&http, format!("Failed to enqueue {}: {:?}", next.title, e)) + .await; + return; + } + }; + let mut handler_lock = handler.lock().await; + let track = handler_lock.play_only_source(source); + queue_lock.set_current(track); + } else { + queue_lock.clear_current(); + } +} + +/// Returns the list of songs for a given url +async fn get_songs_for_url(ctx: &&Context, msg: &Message, url: &str) -> BotResult> { + let mut songs: Vec = get_videos_for_playlist(url)? + .into_iter() + .map(Song::from) + .collect(); + if songs.len() == 0 { + let song: Song = get_video_information(url)?.into(); + msg.channel_id + .send_message(&ctx.http, |m| { + m.embed(|mut e| { + e = e.description(format!("Added [{}]({}) to the queue", song.title, song.url)); + if let Some(thumb) = &song.thumbnail { + e = e.thumbnail(thumb); + } + + e + }) + }) + .await?; + songs.push(song); + } else { + msg.channel_id + .send_message(&ctx.http, |m| { + m.embed(|e| e.description(format!("Added {} songs to the queue", songs.len()))) + }) + .await?; + } + + Ok(songs) +} diff --git a/src/commands/music/play.rs b/src/commands/music/play.rs index 8fbe6c1..80c1904 100644 --- a/src/commands/music/play.rs +++ b/src/commands/music/play.rs @@ -1,10 +1,14 @@ use serenity::client::Context; -use serenity::framework::standard::macros::command; use serenity::framework::standard::{Args, CommandError, CommandResult}; +use serenity::framework::standard::macros::command; use serenity::model::channel::Message; -use crate::commands::music::utils::{get_channel_for_author, join_channel}; -use crate::providers::ytdl::get_videos_for_url; +use crate::commands::music::{ + get_channel_for_author, get_queue_for_guild, get_songs_for_url, get_voice_manager, + join_channel, play_next_in_queue, +}; +use crate::database::get_database_from_context; +use crate::database::guild::SETTING_AUTOSHUFFLE; #[command] #[only_in(guilds)] @@ -22,11 +26,7 @@ async fn play(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let guild = msg.guild(&ctx.cache).await.unwrap(); - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - + let manager = get_voice_manager(ctx).await; let mut handler = manager.get(guild.id); if handler.is_none() { @@ -36,56 +36,29 @@ async fn play(ctx: &Context, msg: &Message, args: Args) -> CommandResult { } let handler_lock = handler.ok_or(CommandError::from("Not in a voice channel"))?; - let mut handler = handler_lock.lock().await; - let mut videos: Vec = get_videos_for_url(url)? - .into_iter() - .map(|v| format!("https://www.youtube.com/watch?v={}", v.url)) - .collect(); - if videos.len() == 0 { - videos.push(url.to_string()); - } - - let mut metadata = None; - for video in &videos { - let source = match songbird::ytdl(video).await { - Ok(s) => s, - Err(e) => { - msg.channel_id - .say(ctx, format!("Failed to enqueue {}: {:?}", video, e)) - .await?; - continue; - } - }; + let songs = get_songs_for_url(&ctx, msg, url).await?; - metadata = Some(source.metadata.clone()); + let queue = get_queue_for_guild(ctx, &guild.id).await?; - handler.enqueue_source(source); - } - if videos.len() == 1 { - let metadata = metadata.unwrap(); - msg.channel_id - .send_message(&ctx.http, |m| { - m.embed(|mut e| { - e = e.description(format!( - "Added [{}]({}) to the queue", - metadata.title.unwrap(), - url - )); - if let Some(thumb) = metadata.thumbnail { - e = e.thumbnail(thumb); - } + let play_first = { + let mut queue_lock = queue.lock().await; + for song in songs { + queue_lock.add(song); + } + let database = get_database_from_context(ctx).await; + let database_lock = database.lock().await; + let autoshuffle = database_lock + .get_guild_setting(&guild.id, SETTING_AUTOSHUFFLE) + .unwrap_or(false); + if autoshuffle { + queue_lock.shuffle(); + } + queue_lock.current().is_none() + }; - e - }) - }) - .await?; - } else { - msg.channel_id - .send_message(&ctx.http, |m| { - m.embed(|e| e.description(format!("Added {} songs to the queue", videos.len()))) - }) - .await?; + if play_first { + play_next_in_queue(&ctx.http, &msg.channel_id, &queue, &handler_lock).await; } Ok(()) diff --git a/src/commands/music/play_next.rs b/src/commands/music/play_next.rs new file mode 100644 index 0000000..6c7d939 --- /dev/null +++ b/src/commands/music/play_next.rs @@ -0,0 +1,57 @@ +use serenity::client::Context; +use serenity::framework::standard::{Args, CommandError, CommandResult}; +use serenity::framework::standard::macros::command; +use serenity::model::channel::Message; + +use crate::commands::music::{ + get_channel_for_author, get_queue_for_guild, get_songs_for_url, get_voice_manager, + join_channel, play_next_in_queue, +}; + +#[command] +#[only_in(guilds)] +#[description("Puts a song as the next to play in the queue")] +#[usage("play_next ")] +#[min_args(1)] +#[max_args(2)] +#[aliases("pn")] +#[allowed_roles("DJ")] +async fn play_next(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + let url = args.message(); + + if !url.starts_with("http") { + return Err(CommandError::from("The provided url is not valid")); + } + + let guild = msg.guild(&ctx.cache).await.unwrap(); + + let manager = get_voice_manager(ctx).await; + let mut handler = manager.get(guild.id); + + if handler.is_none() { + msg.guild(&ctx.cache).await.unwrap(); + let channel_id = get_channel_for_author(&msg.author.id, &guild)?; + handler = Some(join_channel(ctx, channel_id, guild.id).await); + } + + let handler = handler.ok_or(CommandError::from("Not in a voice channel"))?; + + let mut songs = get_songs_for_url(&ctx, msg, url).await?; + + let queue = get_queue_for_guild(ctx, &guild.id).await?; + let play_first = { + let mut queue_lock = queue.lock().await; + songs.reverse(); + + for song in songs { + queue_lock.add_next(song); + } + queue_lock.current().is_none() + }; + + if play_first { + play_next_in_queue(&ctx.http, &msg.channel_id, &queue, &handler).await; + } + + Ok(()) +} diff --git a/src/commands/music/queue.rs b/src/commands/music/queue.rs index a89a89b..fc355da 100644 --- a/src/commands/music/queue.rs +++ b/src/commands/music/queue.rs @@ -1,10 +1,12 @@ use std::cmp::min; use serenity::client::Context; -use serenity::framework::standard::{CommandError, CommandResult}; +use serenity::framework::standard::CommandResult; use serenity::framework::standard::macros::command; use serenity::model::channel::Message; +use crate::commands::music::get_queue_for_guild; + #[command] #[only_in(guilds)] #[description("Shows the song queue")] @@ -13,20 +15,12 @@ use serenity::model::channel::Message; async fn queue(ctx: &Context, msg: &Message) -> CommandResult { let guild = msg.guild(&ctx.cache).await.unwrap(); - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let handler_lock = manager - .get(guild.id) - .ok_or(CommandError::from("Not in a voice channel"))?; - let handler = handler_lock.lock().await; - let songs: Vec<(usize, String)> = handler - .queue() - .current_queue() + let queue = get_queue_for_guild(ctx, &guild.id).await?; + let queue_lock = queue.lock().await; + let songs: Vec<(usize, String)> = queue_lock + .entries() .into_iter() - .map(|t| t.metadata().title.clone().unwrap()) + .map(|s| s.title.clone()) .enumerate() .collect(); diff --git a/src/commands/music/shuffle.rs b/src/commands/music/shuffle.rs index 3ca476d..18ef15a 100644 --- a/src/commands/music/shuffle.rs +++ b/src/commands/music/shuffle.rs @@ -1,42 +1,28 @@ -use std::collections::VecDeque; - -use rand::Rng; use serenity::client::Context; -use serenity::framework::standard::{CommandError, CommandResult}; use serenity::framework::standard::macros::command; +use serenity::framework::standard::CommandResult; use serenity::model::channel::Message; +use crate::commands::music::get_queue_for_guild; + #[command] #[only_in(guilds)] #[description("Shuffles the queue")] #[usage("shuffle")] #[aliases("sh")] +#[allowed_roles("DJ")] async fn shuffle(ctx: &Context, msg: &Message) -> CommandResult { let guild = msg.guild(&ctx.cache).await.unwrap(); - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); + let queue = get_queue_for_guild(ctx, &guild.id).await?; + { + let mut queue_lock = queue.lock().await; + queue_lock.shuffle(); + } - let handler_lock = manager - .get(guild.id) - .ok_or(CommandError::from("Not in a voice channel"))?; - let handler = handler_lock.lock().await; - handler.queue().modify_queue(shuffle_vec_deque); msg.channel_id .say(ctx, "The queue has been shuffled") .await?; Ok(()) } - -/// Fisher-Yates shuffle for VecDeque -fn shuffle_vec_deque(deque: &mut VecDeque) { - let mut rng = rand::thread_rng(); - let mut i = deque.len(); - while i >= 2 { - i -= 1; - deque.swap(i, rng.gen_range(0..i + 1)) - } -} diff --git a/src/commands/music/skip.rs b/src/commands/music/skip.rs index baa2c66..cbd2a48 100644 --- a/src/commands/music/skip.rs +++ b/src/commands/music/skip.rs @@ -1,30 +1,26 @@ use serenity::client::Context; -use serenity::framework::standard::{CommandError, CommandResult}; +use serenity::framework::standard::CommandResult; use serenity::framework::standard::macros::command; use serenity::model::channel::Message; +use crate::commands::music::get_queue_for_guild; + #[command] #[only_in(guilds)] #[description("Skips to the next song")] #[usage("skip")] #[aliases("next")] +#[allowed_roles("DJ")] async fn skip(ctx: &Context, msg: &Message) -> CommandResult { let guild = msg.guild(&ctx.cache).await.unwrap(); - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let handler_lock = manager - .get(guild.id) - .ok_or(CommandError::from("Not in a voice channel"))?; - let handler = handler_lock.lock().await; + let queue = get_queue_for_guild(ctx, &guild.id).await?; + let queue_lock = queue.lock().await; - if let Some(current) = handler.queue().current() { + if let Some(current) = queue_lock.current() { current.stop()?; } - handler.queue().skip()?; + msg.channel_id.say(ctx, "Skipped to the next song").await?; Ok(()) diff --git a/src/commands/music/utils.rs b/src/commands/music/utils.rs deleted file mode 100644 index a5db81d..0000000 --- a/src/commands/music/utils.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::sync::Arc; - -use serenity::client::Context; -use serenity::model::guild::Guild; -use serenity::model::id::{ChannelId, GuildId, UserId}; -use songbird::Call; -use tokio::sync::Mutex; - -use crate::utils::error::{BotError, BotResult}; - -/// Joins a voice channel -pub(crate) async fn join_channel( - ctx: &Context, - channel_id: ChannelId, - guild_id: GuildId, -) -> Arc> { - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let (handler, _) = manager.join(guild_id, channel_id).await; - handler -} - -/// Returns the voice channel the author is in -pub(crate) fn get_channel_for_author(author_id: &UserId, guild: &Guild) -> BotResult { - guild - .voice_states - .get(author_id) - .and_then(|voice_state| voice_state.channel_id) - .ok_or(BotError::from("Not in a voice channel.")) -} diff --git a/src/commands/settings/get.rs b/src/commands/settings/get.rs new file mode 100644 index 0000000..8ec708e --- /dev/null +++ b/src/commands/settings/get.rs @@ -0,0 +1,60 @@ +use serenity::client::Context; +use serenity::framework::standard::{Args, CommandResult}; +use serenity::framework::standard::macros::command; +use serenity::model::channel::Message; + +use crate::database::get_database_from_context; +use crate::database::guild::GUILD_SETTINGS; + +#[command] +#[only_in(guilds)] +#[description("Get a guild setting")] +#[usage("get ()")] +#[example("get music.autoshuffle")] +#[min_args(0)] +#[max_args(1)] +#[required_permissions("MANAGE_GUILD")] +async fn get(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + let database = get_database_from_context(ctx).await; + let guild = msg.guild(&ctx.cache).await.unwrap(); + + if let Some(key) = args.single::().ok() { + let database_lock = database.lock().await; + let setting = database_lock.get_guild_setting::(&guild.id, &key); + + match setting { + Ok(value) => { + msg.channel_id + .say(ctx, format!("`{}` is set to to `{}`", key, value)) + .await?; + } + Err(e) => { + eprintln!("Failed to get setting: {:?}", e); + msg.channel_id + .say(ctx, format!("`{}` is not set", key)) + .await?; + } + } + } else { + for key in GUILD_SETTINGS { + let mut kv_pairs = Vec::new(); + { + let database_lock = database.lock().await; + match database_lock.get_guild_setting::(&guild.id, &key) { + Ok(value) => kv_pairs.push(format!("`{}` = `{}`", key, value)), + Err(e) => { + eprintln!("Failed to get setting: {:?}", e); + kv_pairs.push(format!("`{}` not set", key)) + } + } + } + msg.channel_id + .send_message(ctx, |m| { + m.embed(|e| e.title("Guild Settings").description(kv_pairs.join("\n"))) + }) + .await?; + } + } + + Ok(()) +} diff --git a/src/commands/settings/mod.rs b/src/commands/settings/mod.rs new file mode 100644 index 0000000..fc894df --- /dev/null +++ b/src/commands/settings/mod.rs @@ -0,0 +1,12 @@ +use serenity::framework::standard::macros::group; + +use get::GET_COMMAND; +use set::SET_COMMAND; + +mod get; +mod set; + +#[group] +#[commands(set, get)] +#[prefix("settings")] +pub struct Settings; diff --git a/src/commands/settings/set.rs b/src/commands/settings/set.rs new file mode 100644 index 0000000..0d7cc88 --- /dev/null +++ b/src/commands/settings/set.rs @@ -0,0 +1,28 @@ +use serenity::client::Context; +use serenity::framework::standard::macros::command; +use serenity::framework::standard::{Args, CommandResult}; +use serenity::model::channel::Message; + +use crate::database::get_database_from_context; + +#[command] +#[only_in(guilds)] +#[description("Set a guild setting")] +#[usage("set ")] +#[example("set music.autoshuffle true")] +#[min_args(2)] +#[max_args(2)] +#[required_permissions("MANAGE_GUILD")] +async fn set(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + let key = args.single::().unwrap(); + let value = args.single::().unwrap(); + let database = get_database_from_context(ctx).await; + let database_lock = database.lock().await; + let guild = msg.guild(&ctx.cache).await.unwrap(); + database_lock.set_guild_setting(&guild.id, &key, value.clone())?; + msg.channel_id + .say(ctx, format!("Set `{}` to `{}`", key, value)) + .await?; + + Ok(()) +} diff --git a/src/database/guild.rs b/src/database/guild.rs index f4124ff..78ef394 100644 --- a/src/database/guild.rs +++ b/src/database/guild.rs @@ -7,7 +7,10 @@ pub struct Guild { #[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct GuildSettings { - guild_id: i32, - key: String, - value: String, + pub guild_id: String, + pub setting_key: String, + pub setting_value: String, } + +pub static SETTING_AUTOSHUFFLE: &str = "music.autoshuffle"; +pub static GUILD_SETTINGS: &[&str] = &[SETTING_AUTOSHUFFLE]; diff --git a/src/database/mod.rs b/src/database/mod.rs index 8df5e1d..1e103bd 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,7 +1,15 @@ -use rusqlite::{Connection, NO_PARAMS}; +use std::str::FromStr; +use std::sync::Arc; +use rusqlite::{Connection, NO_PARAMS, params}; +use serenity::client::Context; +use serenity::model::id::GuildId; +use tokio::sync::Mutex; + +use crate::database::guild::GuildSettings; use crate::database::scripts::{CREATE_SCRIPT, UPDATE_SCRIPT}; -use crate::utils::error::BotResult; +use crate::utils::error::{BotError, BotResult}; +use crate::utils::store::Store; pub mod guild; pub mod scripts; @@ -23,6 +31,44 @@ impl Database { Ok(()) } + + /// Returns a guild setting + pub fn get_guild_setting(&self, guild_id: &GuildId, key: &str) -> BotResult + where + T: Clone + FromStr, + { + self.connection + .query_row( + "SELECT guild_id, setting_key, setting_value FROM guild_settings WHERE guild_id = ?1 AND setting_key = ?2", + params![guild_id.to_string(), key], + |r| Ok(serde_rusqlite::from_row::(r).unwrap()), + ) + .map_err(BotError::from) + .and_then(|s| { + s.setting_value + .parse::() + .map_err(|_| BotError::from("Failed to parse Setting")) + }) + } + + /// Sets a guild setting and overrides it if it already exists + pub fn set_guild_setting(&self, guild_id: &GuildId, key: &str, value: T) -> BotResult<()> + where + T: ToString + FromStr + Clone, + { + if self.get_guild_setting::(guild_id, key).is_ok() { + self.connection.execute( + "DELETE FROM guild_settings WHERE guild_id = ?1 AND setting_key = ?2", + params![guild_id.to_string(), key], + )?; + } + self.connection.execute( + "INSERT INTO guild_settings (guild_id, setting_key, setting_value) VALUES (?1, ?2, ?3)", + params![guild_id.to_string(), key, value.to_string()], + )?; + + Ok(()) + } } pub fn get_database() -> BotResult { @@ -33,3 +79,11 @@ pub fn get_database() -> BotResult { Ok(database) } + +/// Returns a reference to a guilds music queue +pub(crate) async fn get_database_from_context(ctx: &Context) -> Arc> { + let data = ctx.data.read().await; + let store = data.get::().unwrap(); + + Arc::clone(&store.database) +} diff --git a/src/database/scripts/create_tables.sql b/src/database/scripts/create_tables.sql index 175a998..48a88e1 100644 --- a/src/database/scripts/create_tables.sql +++ b/src/database/scripts/create_tables.sql @@ -1,26 +1,6 @@ -CREATE TABLE IF NOT EXISTS guilds -( - guild_id INTEGER PRIMARY KEY -); - CREATE TABLE IF NOT EXISTS guild_settings ( - guild_id - INTEGER - NOT - NULL, - setting_key - TEXT - NOT - NULL, - setting_value - TEXT, - FOREIGN - KEY -( - guild_id -) REFERENCES guilds -( - guild_id -) - ); \ No newline at end of file + guild_id TEXT NOT NULL, + setting_key TEXT NOT NULL, + setting_value TEXT NOT NULL +); \ No newline at end of file diff --git a/src/database/scripts/update_tables.sql b/src/database/scripts/update_tables.sql index bcc5851..5b710fe 100644 --- a/src/database/scripts/update_tables.sql +++ b/src/database/scripts/update_tables.sql @@ -1,2 +1,2 @@ -SELECT NULL -FROM guilds; \ No newline at end of file +PRAGMA foreign_keys = false; +DROP TABLE IF EXISTS guilds; \ No newline at end of file diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 107aef4..bfb8134 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1 +1 @@ -pub(crate) mod ytdl; +pub(crate) mod music; diff --git a/src/providers/music/mod.rs b/src/providers/music/mod.rs new file mode 100644 index 0000000..b389cf3 --- /dev/null +++ b/src/providers/music/mod.rs @@ -0,0 +1,38 @@ +use std::io::Read; +use std::process::{Command, Stdio}; + +use crate::providers::music::responses::{PlaylistEntry, VideoInformation}; +use crate::utils::error::BotResult; + +pub(crate) mod queue; +pub(crate) mod responses; + +/// Returns a list of youtube videos for a given url +pub(crate) fn get_videos_for_playlist(url: &str) -> BotResult> { + let ytdl = Command::new("youtube-dl") + .args(&["--no-warnings", "--flat-playlist", "--dump-json", "-i", url]) + .stdout(Stdio::piped()) + .spawn()?; + + let mut output = String::new(); + ytdl.stdout.unwrap().read_to_string(&mut output)?; + + let videos = output + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect(); + + Ok(videos) +} + +/// Returns information for a single video by using youtube-dl +pub(crate) fn get_video_information(url: &str) -> BotResult { + let ytdl = Command::new("youtube-dl") + .args(&["--no-warnings", "--dump-json", "-i", url]) + .stdout(Stdio::piped()) + .spawn()?; + + let information = serde_json::from_reader(ytdl.stdout.unwrap())?; + + Ok(information) +} diff --git a/src/providers/music/queue.rs b/src/providers/music/queue.rs new file mode 100644 index 0000000..7435ed6 --- /dev/null +++ b/src/providers/music/queue.rs @@ -0,0 +1,96 @@ +use std::collections::VecDeque; + +use songbird::tracks::TrackHandle; + +use crate::providers::music::responses::{PlaylistEntry, VideoInformation}; +use crate::utils::shuffle_vec_deque; + +#[derive(Clone, Debug)] +pub struct MusicQueue { + inner: VecDeque, + current: Option, +} + +impl MusicQueue { + pub fn new() -> Self { + Self { + inner: VecDeque::new(), + current: None, + } + } + + /// Adds a song to the queue + pub fn add(&mut self, song: Song) { + self.inner.push_back(song); + } + + /// Adds a song to be played next in the queue + pub fn add_next(&mut self, song: Song) { + self.inner.push_front(song); + } + + /// Shuffles the queue + pub fn shuffle(&mut self) { + shuffle_vec_deque(&mut self.inner) + } + + /// Returns a reference to the inner deque + pub fn entries(&self) -> &VecDeque { + &self.inner + } + + /// Returns the next song from the queue + pub fn next(&mut self) -> Option { + self.inner.pop_front() + } + + /// Sets the currently playing song + pub fn set_current(&mut self, handle: TrackHandle) { + self.current = Some(handle) + } + + /// Clears the currently playing song + pub fn clear_current(&mut self) { + self.current = None; + } + + /// Returns the reference to the currently playing song + pub fn current(&self) -> &Option { + &self.current + } + + /// Clears the queue + pub fn clear(&mut self) { + self.inner.clear(); + } +} + +#[derive(Clone, Debug)] +pub struct Song { + pub url: String, + pub title: String, + pub author: String, + pub thumbnail: Option, +} + +impl From for Song { + fn from(info: VideoInformation) -> Self { + Self { + url: info.webpage_url, + title: info.title, + author: info.uploader, + thumbnail: info.thumbnail, + } + } +} + +impl From for Song { + fn from(entry: PlaylistEntry) -> Self { + Self { + url: format!("https://www.youtube.com/watch?v={}", entry.url), + title: entry.title, + author: entry.uploader, + thumbnail: None, + } + } +} diff --git a/src/providers/music/responses.rs b/src/providers/music/responses.rs new file mode 100644 index 0000000..9e78e38 --- /dev/null +++ b/src/providers/music/responses.rs @@ -0,0 +1,19 @@ +use serde_derive::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +pub(crate) struct PlaylistEntry { + ie_key: String, + id: String, + pub url: String, + pub title: String, + pub uploader: String, +} + +#[derive(Deserialize, Clone, Debug)] +pub(crate) struct VideoInformation { + id: String, + pub title: String, + pub thumbnail: Option, + pub webpage_url: String, + pub uploader: String, +} diff --git a/src/providers/ytdl/mod.rs b/src/providers/ytdl/mod.rs deleted file mode 100644 index f11d22c..0000000 --- a/src/providers/ytdl/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::io::Read; -use std::process::{Command, Stdio}; - -use crate::providers::ytdl::playlist_entry::PlaylistEntry; -use crate::utils::error::BotResult; - -mod playlist_entry; - -/// Returns a list of youtube videos for a given url -pub(crate) fn get_videos_for_url(url: &str) -> BotResult> { - let ytdl = Command::new("youtube-dl") - .args(&[ - "-f", - "--no-warnings", - "--flat-playlist", - "--dump-json", - "-i", - url, - ]) - .stdout(Stdio::piped()) - .spawn()?; - - let mut output = String::new(); - ytdl.stdout.unwrap().read_to_string(&mut output)?; - - let videos = output - .lines() - .map(|l| serde_json::from_str::(l).unwrap()) - .collect(); - - Ok(videos) -} diff --git a/src/providers/ytdl/playlist_entry.rs b/src/providers/ytdl/playlist_entry.rs deleted file mode 100644 index edd2402..0000000 --- a/src/providers/ytdl/playlist_entry.rs +++ /dev/null @@ -1,9 +0,0 @@ -use serde_derive::Deserialize; - -#[derive(Deserialize, Clone, Debug)] -pub(crate) struct PlaylistEntry { - ie_key: String, - id: String, - pub url: String, - pub title: String, -} diff --git a/src/utils/error.rs b/src/utils/error.rs index 79f7e77..c00b3ac 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -19,6 +19,9 @@ pub enum BotError { #[error("IO Error: {0}")] IOError(#[from] std::io::Error), + #[error("JSON Error: {0}")] + JsonError(#[from] serde_json::Error), + #[error("{0}")] Msg(String), } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 4f89971..05274c6 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,2 +1,16 @@ +use std::collections::VecDeque; + +use rand::Rng; + pub mod error; pub mod store; + +/// Fisher-Yates shuffle for VecDeque +pub fn shuffle_vec_deque(deque: &mut VecDeque) { + let mut rng = rand::thread_rng(); + let mut i = deque.len(); + while i >= 2 { + i -= 1; + deque.swap(i, rng.gen_range(0..i + 1)) + } +} diff --git a/src/utils/store.rs b/src/utils/store.rs index b648d0a..876b415 100644 --- a/src/utils/store.rs +++ b/src/utils/store.rs @@ -1,15 +1,19 @@ +use std::collections::HashMap; use std::sync::Arc; -use parking_lot::Mutex; +use serenity::model::id::GuildId; use serenity::prelude::TypeMapKey; +use tokio::sync::Mutex; use crate::database::Database; +use crate::providers::music::queue::MusicQueue; pub struct Store; pub struct StoreData { pub database: Arc>, pub minecraft_data_api: minecraft_data_rs::api::Api, + pub music_queues: HashMap>>, } impl StoreData { @@ -19,6 +23,7 @@ impl StoreData { minecraft_data_api: minecraft_data_rs::api::Api::new( minecraft_data_rs::api::versions::latest_stable().unwrap(), ), + music_queues: HashMap::new(), } } }