From e6f60efbe701729a212f43b4d2dabb1dacc91ed0 Mon Sep 17 00:00:00 2001 From: Oupson Date: Sat, 17 Apr 2021 17:30:48 +0200 Subject: [PATCH] Added youtube playlist support --- src/commands/interaction.rs | 8 +- src/commands/mod.rs | 21 -- src/commands/music/error.rs | 58 ++++ src/commands/{music.rs => music/mod.rs} | 351 ++++++++---------------- src/commands/music/utils.rs | 123 +++++++++ src/commands/music/youtube.rs | 32 +++ src/main.rs | 1 + src/utils/message.rs | 38 +++ src/utils/mod.rs | 2 + src/utils/permissions.rs | 29 ++ 10 files changed, 400 insertions(+), 263 deletions(-) create mode 100644 src/commands/music/error.rs rename src/commands/{music.rs => music/mod.rs} (76%) create mode 100644 src/commands/music/utils.rs create mode 100644 src/commands/music/youtube.rs create mode 100644 src/utils/message.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/permissions.rs diff --git a/src/commands/interaction.rs b/src/commands/interaction.rs index efc87a9..d1eefdd 100644 --- a/src/commands/interaction.rs +++ b/src/commands/interaction.rs @@ -7,7 +7,7 @@ use serenity::{ model::{channel::Embed, guild::Member, Permissions}, }; -use crate::data::GuildOptionsKey; +use crate::{data::GuildOptionsKey, utils::message::embed_author}; pub(crate) async fn handle_interaction(ctx: &Context, object: Value) -> CommandResult { let data = object.get("data").ok_or("Failed to get data")?; @@ -64,7 +64,7 @@ pub(crate) async fn handle_interaction(ctx: &Context, object: Value) -> CommandR goulag(ctx, data, guild_id, &author_member, id, token).await? } else { Response::new_with_embed(4, |e| { - super::embed_author(e, Some(&author_member)) + embed_author(e, Some(&author_member)) .title("Error") .description("You don't have the right to do that") .colour((247, 76, 0)) @@ -129,7 +129,7 @@ async fn goulag( member.add_role(&ctx.http, mute_role).await?; Response::new_with_embed(4, |e| { - super::embed_author(e, Some(author)) + embed_author(e, Some(author)) .title("Mute") .description(format!("{} was muted", member.display_name())) .colour((247, 76, 0)) @@ -142,7 +142,7 @@ async fn goulag( } Response::new_with_embed(4, |e| { - super::embed_author(e, Some(author)) + embed_author(e, Some(author)) .title("Error") .description("Unknown mute role") .colour((247, 76, 0)) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 666dee7..076ba06 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,3 @@ -use serenity::{builder::CreateEmbed, model::guild::Member}; - pub(crate) mod admin; pub(crate) mod general; pub(crate) mod interaction; @@ -11,22 +9,3 @@ pub(crate) mod settings; pub(crate) mod music; pub(crate) type Result = std::result::Result>; - -fn embed_author<'a>(e: &'a mut CreateEmbed, author: Option<&Member>) -> &'a mut CreateEmbed { - if let Some(author) = author { - e.footer(|f| { - f.text(if let Some(nick) = &author.nick { - nick - } else { - &author.user.name - }); - - if let Some(url) = &author.user.avatar_url() { - f.icon_url(url); - } - f - }) - } else { - e - } -} diff --git a/src/commands/music/error.rs b/src/commands/music/error.rs new file mode 100644 index 0000000..7273046 --- /dev/null +++ b/src/commands/music/error.rs @@ -0,0 +1,58 @@ +use serenity::{ + client::Context, + framework::standard::{CommandError, CommandResult}, + model::{guild::Member, id::ChannelId}, +}; +use std::fmt::{self, Display}; + +use crate::utils::message::embed_response; + +#[derive(Debug)] +pub(crate) enum UseVoiceError { + NotInGuild, + NotInVoiceChannel, + NotInSameVoiceChannel, + NotEnoughPermission, + CommandError(CommandError), + Unknown, +} + +impl From for UseVoiceError { + fn from(err: CommandError) -> Self { + Self::CommandError(err) + } +} + +impl std::error::Error for UseVoiceError {} + +impl Display for UseVoiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotInGuild => write!(f, "DM are not supported"), + Self::NotInVoiceChannel => write!(f, "Not in a voice channel"), + Self::NotEnoughPermission => write!(f, "You don't have the right to do that"), + Self::NotInSameVoiceChannel => { + write!(f, "You must on the same voice channel than the bot") + } + Self::CommandError(e) => write!(f, "Something went wrong : {}", e), + Self::Unknown => write!(f, "Something went wrong"), + } + } +} + +impl UseVoiceError { + pub(crate) async fn send_error_message( + &self, + ctx: &Context, + channel_id: &ChannelId, + member: Option<&Member>, + ) -> CommandResult<()> { + channel_id + .send_message(&ctx.http, |m| { + embed_response(m, "Error", &format!("{}", self), member) + }) + .await?; + + Ok(()) + } +} diff --git a/src/commands/music.rs b/src/commands/music/mod.rs similarity index 76% rename from src/commands/music.rs rename to src/commands/music/mod.rs index 3907e51..4a053fd 100644 --- a/src/commands/music.rs +++ b/src/commands/music/mod.rs @@ -2,27 +2,26 @@ use std::{sync::Arc, usize}; use tokio::sync::Mutex as TokioMutex; -use crate::data::GuildOptionsKey; use log::error; use serenity::{ - builder::CreateMessage, client::Context, framework::standard::{ macros::{command, group}, - Args, CommandError, CommandResult, + Args, CommandResult, }, http::Http, - model::{ - channel::Message, - guild::{Member, PartialMember}, - id::{ChannelId, GuildId}, - misc::Mentionable, - permissions::Permissions, - }, + model::{channel::Message, id::ChannelId, misc::Mentionable, permissions::Permissions}, }; -use songbird::{input::Metadata, Call, Event, EventContext, TrackEvent}; +use songbird::{Call, Event, EventContext, TrackEvent}; -use super::embed_author; +mod error; +mod utils; +mod youtube; + +use crate::utils::{message::embed_response, permissions::has_permission}; +use utils::embed_song; + +use self::utils::{can_use_voice_command, is_mute}; struct TrackStartNotifier { chan_id: ChannelId, @@ -62,94 +61,6 @@ impl songbird::EventHandler for TrackStartNotifier { } } -fn embed_song(msg: &mut CreateMessage, title: &str, metadata: &Metadata, author: Option<&Member>) { - msg.embed(|e| { - e.title(title); - - if let Some(title) = &metadata.title { - e.field("Title", title, true); - } - - if let Some(url) = &metadata.source_url { - e.url(url); - } - - if let Some(duration) = &metadata.duration { - let seconds = duration.as_secs(); - - let hours = seconds / (60 * 60); - let min = (seconds - (hours * 60 * 60)) / 60; - let seconds = seconds - (min * 60); - - e.field( - "Duration", - if hours > 0 { - format!("{}:{:02}:{:02}", hours, min, seconds) - } else { - format!("{}:{:02}", min, seconds) - }, - false, - ); - } - - if let Some(img) = &metadata.thumbnail { - e.image(img); - } - - embed_author(e, author).colour((247, 76, 0)) - }); -} - -fn embed_queued(msg: &mut CreateMessage, metadata: &Metadata, author: Option<&Member>) { - msg.embed(|e| { - e.title("Queued"); - - if let Some(title) = &metadata.title { - e.field("Title", title, true); - } - - if let Some(url) = &metadata.source_url { - e.url(url); - } - - if let Some(duration) = &metadata.duration { - let seconds = duration.as_secs(); - - let hours = seconds / (60 * 60); - let min = (seconds - (hours * 60 * 60)) / 60; - let seconds = seconds - (min * 60); - - e.field( - "Duration", - if hours > 0 { - format!("{}:{:02}:{:02}", hours, min, seconds) - } else { - format!("{}:{:02}", min, seconds) - }, - false, - ); - } - - if let Some(img) = &metadata.thumbnail { - e.image(img); - } - - embed_author(e, author).colour((247, 76, 0)) - }); -} - -fn embed_response<'a, 'b>( - msg: &'a mut CreateMessage<'b>, - title: &str, - content: &str, - author: Option<&Member>, -) -> &'a mut CreateMessage<'b> { - msg.embed(|e| { - e.title(title).description(content); - embed_author(e, author).colour((247, 76, 0)) - }) -} - #[group] #[commands(join, leave, play, stop, next, pause, resume, remove, queue)] struct Music; @@ -285,7 +196,7 @@ async fn leave(ctx: &Context, msg: &Message) -> CommandResult { let handler = handler.lock().await; match can_use_voice_command(ctx, &handler, &msg, &member).await { Ok(()) => { - std::mem::drop(handler); + std::mem::drop(handler); // Drop mutex if let Err(e) = manager.remove(guild_id).await { log::error!("Failed to leave : {}", e); msg.channel_id @@ -371,23 +282,106 @@ async fn play(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { match can_use_voice_command(ctx, &handler, &msg, &member).await { Ok(()) => { let source = if url.starts_with("http") { - match songbird::ytdl(&url).await { - Ok(source) => source, - Err(why) => { - error!("Err starting source: {:?}", why); + if url.contains("youtube.com") && url.contains("list=") { + msg.channel_id + .send_message(&ctx.http, |m| { + embed_response( + m, + "Loading the playlist", + "This could take some time", + Some(&member), + ) + }) + .await?; + let list = match youtube::get_list_of_urls(&url).await { + Ok(v) => v, + Err(e) => { + error!("Failed to load playlist : {}", e); + msg.channel_id + .send_message(&ctx.http, |m| { + embed_response( + m, + "Error", + "Failed to load the playlist", + Some(&member), + ) + }) + .await?; + + return Ok(()); + } + }; + + if clean { + handler.queue().stop(); + } + + for music in list { + let s = match songbird::ytdl(&music).await { + Ok(source) => source, + Err(why) => { + error!("Err starting source: {:?}", why); + + msg.channel_id + .send_message(&ctx.http, |m| { + embed_response( + m, + "Error", + "Failed to load the song", + Some(&member), + ) + }) + .await?; + + return Ok(()); + } + }; + + handler.enqueue_source(s); + } + + let track = handler.queue().current(); + + if let Some(track) = &track { + let author = msg.member(&ctx).await?; + msg.channel_id + .send_message(&ctx.http, |m| { + embed_song(m, "Now playing", track.metadata(), Some(&author)) + }) + .await?; + } else { msg.channel_id .send_message(&ctx.http, |m| { embed_response( m, "Error", - "Failed to load the song", + "Failed to load the playlist", Some(&member), ) }) .await?; + } + return Ok(()); + } else { + match songbird::ytdl(&url).await { + Ok(source) => source, + Err(why) => { + error!("Err starting source: {:?}", why); - return Ok(()); + msg.channel_id + .send_message(&ctx.http, |m| { + embed_response( + m, + "Error", + "Failed to load the song", + Some(&member), + ) + }) + .await?; + + return Ok(()); + } } } } else { @@ -422,12 +416,16 @@ async fn play(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let author = msg.member(&ctx).await?; msg.channel_id .send_message(&ctx.http, |m| { - if handler.queue().len() == 1 { - embed_song(m, "Now playing", meta, Some(&author)); - } else { - embed_queued(m, meta, Some(&author)); - } - m + embed_song( + m, + if handler.queue().len() == 1 { + "Now playing" + } else { + "Queued" + }, + meta, + Some(&author), + ) }) .await?; } @@ -692,8 +690,7 @@ async fn remove(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { handler.queue().skip()?; msg.channel_id .send_message(&ctx.http, |m| { - embed_song(m, "Removed", track.metadata(), Some(&member)); - m + embed_song(m, "Removed", track.metadata(), Some(&member)) }) .await?; } @@ -710,8 +707,7 @@ async fn remove(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { Some(q) => { msg.channel_id .send_message(&ctx.http, |m| { - embed_song(m, "Removed", q.metadata(), Some(&member)); - m + embed_song(m, "Removed", q.metadata(), Some(&member)) }) .await?; } @@ -811,124 +807,3 @@ async fn queue(ctx: &Context, msg: &Message) -> CommandResult<()> { Ok(()) } - -async fn is_mute(ctx: &Context, member: &PartialMember, guild_id: GuildId) -> CommandResult { - let data = ctx.data.read().await; - - let data = data - .get::() - .expect("Failed to get guild cache"); - - if let Some(mute_role) = data.get(&guild_id).and_then(|o| o.get_mute_role()) { - Ok(member.roles.contains(&mute_role)) - } else { - Ok(false) - } -} - -#[derive(Debug)] -enum UseVoiceError { - NotInGuild, - NotInVoiceChannel, - NotInSameVoiceChannel, - NotEnoughPermission, - CommandError(CommandError), - Unknown, -} - -impl From for UseVoiceError { - fn from(err: CommandError) -> Self { - Self::CommandError(err) - } -} - -impl UseVoiceError { - async fn send_error_message( - &self, - ctx: &Context, - channel_id: &ChannelId, - member: Option<&Member>, - ) -> CommandResult<()> { - let msg = match self { - Self::NotInGuild => "DM are not supported", - Self::NotInVoiceChannel => "Not in a voice channel", - Self::NotEnoughPermission => "You don't have the right to do that", - Self::NotInSameVoiceChannel => "You must on the same voice channel than the bot", - Self::CommandError(_) => "Something went wrong", - Self::Unknown => "Something went wrong", - }; - - channel_id - .send_message(&ctx.http, |m| embed_response(m, "Error", msg, member)) - .await?; - - Ok(()) - } -} - -async fn can_use_voice_command( - ctx: &Context, - call: &Call, - msg: &Message, - member: &Member, -) -> Result<(), UseVoiceError> { - if has_permission(ctx, member, &[Permissions::MANAGE_GUILD]).await? { - return Ok(()); - } - - let guild = match msg.guild(&ctx.cache).await { - Some(guild) => guild, - None => return Err(UseVoiceError::NotInGuild), - }; - - let channel_id = match guild.voice_states.get(&msg.author.id) { - Some(voice_state) => match voice_state.channel_id { - Some(c) => c, - None => return Err(UseVoiceError::Unknown), - }, - None => { - return Err(UseVoiceError::NotInVoiceChannel); - } - }; - - let bot_channel_id = match call.current_channel() { - Some(c) => c, - None => return Ok(()), - }; - - if bot_channel_id.0 != channel_id.0 { - Err(UseVoiceError::NotInSameVoiceChannel) - } else if let Some(member) = &msg.member { - if is_mute(ctx, member, guild.id).await? { - Err(UseVoiceError::NotEnoughPermission) - } else { - Ok(()) - } - } else { - Err(UseVoiceError::Unknown) - } -} - -async fn has_permission( - ctx: &Context, - member: &Member, - permissions: &[Permissions], -) -> CommandResult { - let p = member.permissions(ctx).await?; - for perm in permissions { - if p.contains(*perm) { - return Ok(true); - } - } - - let roles = member.roles(ctx).await.unwrap(); - for role in roles { - for perm in permissions { - if role.has_permissions(*perm, false) { - return Ok(true); - } - } - } - - Ok(false) -} diff --git a/src/commands/music/utils.rs b/src/commands/music/utils.rs new file mode 100644 index 0000000..7eb9346 --- /dev/null +++ b/src/commands/music/utils.rs @@ -0,0 +1,123 @@ +use serenity::{ + builder::CreateMessage, + client::Context, + framework::standard::CommandResult, + model::{ + channel::Message, + guild::{Member, PartialMember}, + id::GuildId, + Permissions, + }, +}; +use songbird::{input::Metadata, Call}; + +use crate::{ + data::GuildOptionsKey, + utils::{message::embed_author, permissions::has_permission}, +}; + +use super::error::UseVoiceError; + +pub(crate) fn embed_song<'a, 'b>( + msg: &'a mut CreateMessage<'b>, + title: &str, + metadata: &Metadata, + author: Option<&Member>, +) -> &'a mut CreateMessage<'b> { + msg.embed(|e| { + e.title(title); + + if let Some(title) = &metadata.title { + e.field("Title", title, true); + } + + if let Some(url) = &metadata.source_url { + e.url(url); + } + + if let Some(duration) = &metadata.duration { + let seconds = duration.as_secs(); + + let hours = seconds / (60 * 60); + let min = (seconds - (hours * 60 * 60)) / 60; + let seconds = seconds - (min * 60); + + e.field( + "Duration", + if hours > 0 { + format!("{}:{:02}:{:02}", hours, min, seconds) + } else { + format!("{}:{:02}", min, seconds) + }, + false, + ); + } + + if let Some(img) = &metadata.thumbnail { + e.image(img); + } + + embed_author(e, author).colour((247, 76, 0)) + }) +} + +pub(crate) async fn is_mute( + ctx: &Context, + member: &PartialMember, + guild_id: GuildId, +) -> CommandResult { + let data = ctx.data.read().await; + + let data = data + .get::() + .expect("Failed to get guild cache"); + + if let Some(mute_role) = data.get(&guild_id).and_then(|o| o.get_mute_role()) { + Ok(member.roles.contains(&mute_role)) + } else { + Ok(false) + } +} + +pub(crate) async fn can_use_voice_command( + ctx: &Context, + call: &Call, + msg: &Message, + member: &Member, +) -> Result<(), UseVoiceError> { + if has_permission(ctx, member, &[Permissions::MANAGE_GUILD]).await? { + return Ok(()); + } + + let guild = match msg.guild(&ctx.cache).await { + Some(guild) => guild, + None => return Err(UseVoiceError::NotInGuild), + }; + + let channel_id = match guild.voice_states.get(&msg.author.id) { + Some(voice_state) => match voice_state.channel_id { + Some(c) => c, + None => return Err(UseVoiceError::Unknown), + }, + None => { + return Err(UseVoiceError::NotInVoiceChannel); + } + }; + + let bot_channel_id = match call.current_channel() { + Some(c) => c, + None => return Ok(()), + }; + + if bot_channel_id.0 != channel_id.0 { + Err(UseVoiceError::NotInSameVoiceChannel) + } else if let Some(member) = &msg.member { + if is_mute(ctx, member, guild.id).await? { + Err(UseVoiceError::NotEnoughPermission) + } else { + Ok(()) + } + } else { + Err(UseVoiceError::Unknown) + } +} diff --git a/src/commands/music/youtube.rs b/src/commands/music/youtube.rs new file mode 100644 index 0000000..22b7990 --- /dev/null +++ b/src/commands/music/youtube.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; +use tokio::{ + io::{Error, ErrorKind, Result}, + process::Command, +}; +pub(crate) async fn get_list_of_urls(url: &str) -> Result> { + let output = Command::new("youtube-dl") + .args(&["-j", "--flat-playlist", &url]) + .output() + .await?; + + if !output.status.success() { + Err(Error::new( + ErrorKind::Other, + String::from_utf8_lossy(&output.stderr), + )) + } else { + let output = String::from_utf8_lossy(&output.stdout); + let mut json_output = vec![]; + for line in output.lines() { + let json: YtdlResponse = serde_json::from_str(line)?; + json_output.push(String::from("https://youtube.com/watch?v=") + &json.url); + } + + Ok(json_output) + } +} + +#[derive(Debug, Deserialize)] +struct YtdlResponse { + url: String, +} diff --git a/src/main.rs b/src/main.rs index 4185843..d0f7ef6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ mod commands; mod config; mod data; mod presence; +mod utils; const MINIMUM_MENTIONS: usize = 10; diff --git a/src/utils/message.rs b/src/utils/message.rs new file mode 100644 index 0000000..9a45f53 --- /dev/null +++ b/src/utils/message.rs @@ -0,0 +1,38 @@ +use serenity::{ + builder::{CreateEmbed, CreateMessage}, + model::guild::Member, +}; + +pub(crate) fn embed_response<'a, 'b>( + msg: &'a mut CreateMessage<'b>, + title: &str, + content: &str, + author: Option<&Member>, +) -> &'a mut CreateMessage<'b> { + msg.embed(|e| { + e.title(title).description(content); + embed_author(e, author).colour((247, 76, 0)) + }) +} + +pub(crate) fn embed_author<'a>( + e: &'a mut CreateEmbed, + author: Option<&Member>, +) -> &'a mut CreateEmbed { + if let Some(author) = author { + e.footer(|f| { + f.text(if let Some(nick) = &author.nick { + nick + } else { + &author.user.name + }); + + if let Some(url) = &author.user.avatar_url() { + f.icon_url(url); + } + f + }) + } else { + e + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..9364a8c --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod message; +pub(crate) mod permissions; diff --git a/src/utils/permissions.rs b/src/utils/permissions.rs new file mode 100644 index 0000000..fe7365a --- /dev/null +++ b/src/utils/permissions.rs @@ -0,0 +1,29 @@ +use serenity::{ + client::Context, + framework::standard::CommandResult, + model::{guild::Member, Permissions}, +}; + +pub(crate) async fn has_permission( + ctx: &Context, + member: &Member, + permissions: &[Permissions], +) -> CommandResult { + let p = member.permissions(ctx).await?; + for perm in permissions { + if p.contains(*perm) { + return Ok(true); + } + } + + let roles = member.roles(ctx).await.unwrap(); + for role in roles { + for perm in permissions { + if role.has_permissions(*perm, false) { + return Ok(true); + } + } + } + + Ok(false) +}