Added youtube playlist support

This commit is contained in:
Oupson 2021-04-17 17:30:48 +02:00
parent 1abb492dff
commit e6f60efbe7
10 changed files with 400 additions and 263 deletions

View File

@ -7,7 +7,7 @@ use serenity::{
model::{channel::Embed, guild::Member, Permissions}, 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 { pub(crate) async fn handle_interaction(ctx: &Context, object: Value) -> CommandResult {
let data = object.get("data").ok_or("Failed to get data")?; 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? goulag(ctx, data, guild_id, &author_member, id, token).await?
} else { } else {
Response::new_with_embed(4, |e| { Response::new_with_embed(4, |e| {
super::embed_author(e, Some(&author_member)) embed_author(e, Some(&author_member))
.title("Error") .title("Error")
.description("You don't have the right to do that") .description("You don't have the right to do that")
.colour((247, 76, 0)) .colour((247, 76, 0))
@ -129,7 +129,7 @@ async fn goulag(
member.add_role(&ctx.http, mute_role).await?; member.add_role(&ctx.http, mute_role).await?;
Response::new_with_embed(4, |e| { Response::new_with_embed(4, |e| {
super::embed_author(e, Some(author)) embed_author(e, Some(author))
.title("Mute") .title("Mute")
.description(format!("{} was muted", member.display_name())) .description(format!("{} was muted", member.display_name()))
.colour((247, 76, 0)) .colour((247, 76, 0))
@ -142,7 +142,7 @@ async fn goulag(
} }
Response::new_with_embed(4, |e| { Response::new_with_embed(4, |e| {
super::embed_author(e, Some(author)) embed_author(e, Some(author))
.title("Error") .title("Error")
.description("Unknown mute role") .description("Unknown mute role")
.colour((247, 76, 0)) .colour((247, 76, 0))

View File

@ -1,5 +1,3 @@
use serenity::{builder::CreateEmbed, model::guild::Member};
pub(crate) mod admin; pub(crate) mod admin;
pub(crate) mod general; pub(crate) mod general;
pub(crate) mod interaction; pub(crate) mod interaction;
@ -11,22 +9,3 @@ pub(crate) mod settings;
pub(crate) mod music; pub(crate) mod music;
pub(crate) type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; pub(crate) type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
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
}
}

View File

@ -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<CommandError> 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(())
}
}

View File

@ -2,27 +2,26 @@ use std::{sync::Arc, usize};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use crate::data::GuildOptionsKey;
use log::error; use log::error;
use serenity::{ use serenity::{
builder::CreateMessage,
client::Context, client::Context,
framework::standard::{ framework::standard::{
macros::{command, group}, macros::{command, group},
Args, CommandError, CommandResult, Args, CommandResult,
}, },
http::Http, http::Http,
model::{ model::{channel::Message, id::ChannelId, misc::Mentionable, permissions::Permissions},
channel::Message,
guild::{Member, PartialMember},
id::{ChannelId, GuildId},
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 { struct TrackStartNotifier {
chan_id: ChannelId, 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] #[group]
#[commands(join, leave, play, stop, next, pause, resume, remove, queue)] #[commands(join, leave, play, stop, next, pause, resume, remove, queue)]
struct Music; struct Music;
@ -285,7 +196,7 @@ async fn leave(ctx: &Context, msg: &Message) -> CommandResult {
let handler = handler.lock().await; let handler = handler.lock().await;
match can_use_voice_command(ctx, &handler, &msg, &member).await { match can_use_voice_command(ctx, &handler, &msg, &member).await {
Ok(()) => { Ok(()) => {
std::mem::drop(handler); std::mem::drop(handler); // Drop mutex
if let Err(e) = manager.remove(guild_id).await { if let Err(e) = manager.remove(guild_id).await {
log::error!("Failed to leave : {}", e); log::error!("Failed to leave : {}", e);
msg.channel_id msg.channel_id
@ -371,6 +282,88 @@ async fn play(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
match can_use_voice_command(ctx, &handler, &msg, &member).await { match can_use_voice_command(ctx, &handler, &msg, &member).await {
Ok(()) => { Ok(()) => {
let source = if url.starts_with("http") { let source = if url.starts_with("http") {
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 playlist",
Some(&member),
)
})
.await?;
}
return Ok(());
} else {
match songbird::ytdl(&url).await { match songbird::ytdl(&url).await {
Ok(source) => source, Ok(source) => source,
Err(why) => { Err(why) => {
@ -390,6 +383,7 @@ async fn play(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
return Ok(()); return Ok(());
} }
} }
}
} else { } else {
match songbird::input::ytdl_search(&url).await { match songbird::input::ytdl_search(&url).await {
Ok(source) => source, Ok(source) => source,
@ -422,12 +416,16 @@ async fn play(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let author = msg.member(&ctx).await?; let author = msg.member(&ctx).await?;
msg.channel_id msg.channel_id
.send_message(&ctx.http, |m| { .send_message(&ctx.http, |m| {
embed_song(
m,
if handler.queue().len() == 1 { if handler.queue().len() == 1 {
embed_song(m, "Now playing", meta, Some(&author)); "Now playing"
} else { } else {
embed_queued(m, meta, Some(&author)); "Queued"
} },
m meta,
Some(&author),
)
}) })
.await?; .await?;
} }
@ -692,8 +690,7 @@ async fn remove(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
handler.queue().skip()?; handler.queue().skip()?;
msg.channel_id msg.channel_id
.send_message(&ctx.http, |m| { .send_message(&ctx.http, |m| {
embed_song(m, "Removed", track.metadata(), Some(&member)); embed_song(m, "Removed", track.metadata(), Some(&member))
m
}) })
.await?; .await?;
} }
@ -710,8 +707,7 @@ async fn remove(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
Some(q) => { Some(q) => {
msg.channel_id msg.channel_id
.send_message(&ctx.http, |m| { .send_message(&ctx.http, |m| {
embed_song(m, "Removed", q.metadata(), Some(&member)); embed_song(m, "Removed", q.metadata(), Some(&member))
m
}) })
.await?; .await?;
} }
@ -811,124 +807,3 @@ async fn queue(ctx: &Context, msg: &Message) -> CommandResult<()> {
Ok(()) Ok(())
} }
async fn is_mute(ctx: &Context, member: &PartialMember, guild_id: GuildId) -> CommandResult<bool> {
let data = ctx.data.read().await;
let data = data
.get::<GuildOptionsKey>()
.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<CommandError> 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<bool> {
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)
}

123
src/commands/music/utils.rs Normal file
View File

@ -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<bool> {
let data = ctx.data.read().await;
let data = data
.get::<GuildOptionsKey>()
.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)
}
}

View File

@ -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<Vec<String>> {
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,
}

View File

@ -41,6 +41,7 @@ mod commands;
mod config; mod config;
mod data; mod data;
mod presence; mod presence;
mod utils;
const MINIMUM_MENTIONS: usize = 10; const MINIMUM_MENTIONS: usize = 10;

38
src/utils/message.rs Normal file
View File

@ -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
}
}

2
src/utils/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub(crate) mod message;
pub(crate) mod permissions;

29
src/utils/permissions.rs Normal file
View File

@ -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<bool> {
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)
}