From 439f5680572231197e5d2989e27565172991e986 Mon Sep 17 00:00:00 2001 From: Ladislav Hano Date: Tue, 17 Dec 2024 15:28:52 +0100 Subject: [PATCH] feat: implemented local song player --- Cargo.toml | 2 +- makefile | 5 +- src/commands/voice/mod.rs | 2 +- src/commands/voice/player.rs | 255 +++++++++++++++++++++ src/commands/voice/soundcloud/souncloud.rs | 0 src/commands/voice/spotify/spotify.rs | 0 src/main.rs | 13 +- src/util/mod.rs | 3 +- src/util/poise_context_extension.rs | 22 ++ src/util/utilities.rs | 33 ++- 10 files changed, 317 insertions(+), 18 deletions(-) create mode 100644 src/commands/voice/player.rs create mode 100644 src/commands/voice/soundcloud/souncloud.rs create mode 100644 src/commands/voice/spotify/spotify.rs create mode 100644 src/util/poise_context_extension.rs diff --git a/Cargo.toml b/Cargo.toml index 6f16d12..b53e91f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" tenorv2 = { path = "./tenor-v2/tenorv2" } librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } -songbird = "0.4.6" +songbird = { version = "0.4.6", features = ["driver"] } [features] DEBUG = [] diff --git a/makefile b/makefile index 6b47a53..ad262f5 100644 --- a/makefile +++ b/makefile @@ -4,9 +4,8 @@ dev: release: cargo build --release --features RELEASE -run: - cargo build --features DEBUG,GUILD_COMMAND +run: dev ./target/debug/moover_rust -run_rel: +run_rel: release ./target/release/moover_rust \ No newline at end of file diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs index 9c66493..d44230b 100644 --- a/src/commands/voice/mod.rs +++ b/src/commands/voice/mod.rs @@ -1 +1 @@ -pub mod spotify; \ No newline at end of file +pub mod player; \ No newline at end of file diff --git a/src/commands/voice/player.rs b/src/commands/voice/player.rs new file mode 100644 index 0000000..ddffb7d --- /dev/null +++ b/src/commands/voice/player.rs @@ -0,0 +1,255 @@ +use std::vec; + +use serenity::{all::ChannelId, async_trait}; +use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; +use songbird::input::{File, Input}; +use songbird::TrackEvent; + +use crate::util::debug::send_error; +use crate::util::poise_context_extension::ContextExt; +use crate::util::utilities::get_local_songs; +use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; + +#[poise::command( + slash_command, + description_localized("en-US", "Play song") +)] +pub async fn play(ctx: Context<'_>, + #[description = "Song: "] + _url: String +) -> Result<(), Error> { + + ctx.reply("Done!").await?; + Ok(()) +} + +/// Returns either voice channel to which the user is currently connected to or the one passed via name +async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { + if name.is_none() || name.as_ref().is_some_and(|n| n.is_empty()) { + match ctx.guild().and_then(|guild| + guild.voice_states.get(&ctx.author().id).and_then(|voice_state| + voice_state.channel_id + ) + ) { + Some(c) => Ok(c), + None => Err("You must be in a voice channel or specify explicit voice channel by name".to_string()) + } + } + else { + match ctx.guild().and_then(|guild| + get_channel_by_name(guild, name.unwrap()) + ) { + Some(c) => Ok(c), + None => Err("Channel with this name does not exist".to_string()) + } + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Connect to channel") +)] +pub async fn connect(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + name: Option +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let voice_channel = match get_voice_channel(&ctx, name).await { + Ok(c) => c, + Err(e) => { + ctx.reply_ephemeral(e.as_str()).await?; + return Ok(()) + } + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + if let Ok(handler_lock) = manager.join(guild_id, voice_channel).await { + let mut handler = handler_lock.lock().await; + handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} + +struct TrackErrorNotifier; + +#[async_trait] +impl VoiceEventHandler for TrackErrorNotifier { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + if let EventContext::Track(track_list) = ctx { + for (state, handle) in *track_list { + println!( + "Track {:?} encountered an error: {:?}", + handle.uuid(), + state.playing + ); + } + } + + None + } +} + +async fn autocomplete_channel( + ctx: Context<'_>, + _partial: &str, +) -> Vec { + + use crate::util::utilities::get_vc_names; + match ctx.guild() { + Some(guild) => get_vc_names(guild), + None => [].to_vec() + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Disconnect from voice channel") +)] +pub async fn disconnect( + ctx: Context<'_> +) -> Result<(), Error> { + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply("Can't use this outside of guild").await?; + return Ok(()); + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let has_handler = manager.get(guild_id).is_some(); + + if ! has_handler { + ctx.reply("I am not connected to a channel!").await?; + return Ok(()) + } + + match manager.remove(guild_id).await { + Ok(()) => { + ctx.reply("Disconnected").await?; + return Ok(()) + } + Err(e) => { + let _ = send_error(ctx.serenity_context().http.clone(), e.to_string()).await; + return Ok(()) + } + } +} + +async fn autocomplete_song( + _ctx: Context<'_>, + partial: &str, +) -> Vec { + + match get_local_songs(partial) { + Ok(names) => names, + Err(_) => vec![] + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Connect to channel") +)] +pub async fn play_local(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + _channel: Option, + #[autocomplete = "autocomplete_song"] + #[description = "Filename of local song: "] + file_name: String +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let input_file = File::new(format!("/home/emil/Music/{file_name}")); + let input = Input::Lazy(Box::new(input_file)); + let _ = handler.play_input(input); + } + else { + + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} + +// #[poise::command( +// slash_command, +// description_localized("en-US", "Connect to channel") +// )] +// pub async fn play_local(ctx: Context<'_>, +// #[autocomplete = "autocomplete_song"] +// #[description = "Voice channel name: "] +// file_name: String +// ) -> Result<(), Error> { + +// if ctx.guild().is_none() { +// ctx.reply_ephemeral("Can't use this outside of guild").await?; +// return Ok(()); +// } + +// let manager = songbird::get(ctx.serenity_context()) +// .await +// .expect("Songbird Voice client placed in at initialisation.") +// .clone(); + +// let Some(guild_id) = ctx.guild_id() else { +// ctx.reply_ephemeral("Guild id not found").await?; +// return Ok(()) +// }; + +// if let Some(handler_lock) = manager.get(guild_id) { +// let mut handler = handler_lock.lock().await; + +// let input_file = File::new(format!("/home/emil/Music/{file_name}")); +// let input = Input::Lazy(Box::new(input_file)); +// let _ = handler.play_input(input); +// } +// else { +// ctx.reply_ephemeral("Not in a voice channel").await?; +// return Ok(()) +// } + +// ctx.reply_ephemeral("Done!").await?; +// Ok(()) +// } diff --git a/src/commands/voice/soundcloud/souncloud.rs b/src/commands/voice/soundcloud/souncloud.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/voice/spotify/spotify.rs b/src/commands/voice/spotify/spotify.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/main.rs b/src/main.rs index abfaf56..e4d139f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,7 @@ impl EventHandler for Handler { #[cfg(feature="RELEASE")] { use util::debug::hello; - hello(ctx.http.clone()).await; + let _ = hello(ctx.http.clone()).await; } let sched = JobScheduler::new().await.unwrap(); @@ -84,8 +84,9 @@ async fn main() -> anyhow::Result<()> { commands: vec![ commands::say(), commands::hug(), - // commands::spotify::player::play(), - commands::spotify::player::connect(), + commands::player::play_local(), + commands::player::connect(), + commands::player::disconnect(), ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -107,7 +108,6 @@ async fn main() -> anyhow::Result<()> { let framework = poise::Framework::builder() .setup(move |ctx, _ready, framework| { Box::pin(async move { - #[cfg(feature="GUILD_COMMAND")] { use poise::samples::register_in_guild; @@ -119,10 +119,12 @@ async fn main() -> anyhow::Result<()> { register_in_guild(ctx, &framework.options().commands, GuildId::new(debug_guild_id)).await?; } + #[cfg(not(feature="GUILD_COMMAND"))] { use poise::samples::register_globally; register_globally(ctx, &framework.options().commands).await?; } + Ok(()) }) }) @@ -136,7 +138,8 @@ async fn main() -> anyhow::Result<()> { let token = env::var(token_str).context("TOKEN not found in env")?; - let intents = GatewayIntents::GUILD_MESSAGES + let intents = GatewayIntents::GUILDS + | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_VOICE_STATES | GatewayIntents::GUILD_MESSAGE_REACTIONS diff --git a/src/util/mod.rs b/src/util/mod.rs index a259bb4..f268cfa 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ pub mod debug; pub mod utilities; -pub mod gifs; \ No newline at end of file +pub mod gifs; +pub mod poise_context_extension; \ No newline at end of file diff --git a/src/util/poise_context_extension.rs b/src/util/poise_context_extension.rs new file mode 100644 index 0000000..39a9475 --- /dev/null +++ b/src/util/poise_context_extension.rs @@ -0,0 +1,22 @@ +use poise::CreateReply; +use serenity::async_trait; + +use crate::types::Context; + + +/// Trait to extend `Context` with additional methods. +#[async_trait] +pub trait ContextExt { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()>; +} + +#[async_trait] +impl ContextExt for Context<'_> { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()> { + self.send(CreateReply::default() + .ephemeral(true) + .content(content) + ).await?; + Ok(()) + } +} diff --git a/src/util/utilities.rs b/src/util/utilities.rs index 3bac776..c36912b 100644 --- a/src/util/utilities.rs +++ b/src/util/utilities.rs @@ -1,11 +1,9 @@ -use std::sync::Arc; +use std::{fs, io, path::Path, sync::Arc, vec}; -use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildChannel, GuildId, GuildRef, Message}, http::Http}; - -use crate::types; -use anyhow::Context; +use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildId, GuildRef, Message}, http::Http}; pub async fn get_system_channel(guild_id: GuildId, http: &Http) -> anyhow::Result { + use anyhow::Context; return http.get_guild(guild_id).await?.system_channel_id .context(format!("System channel of guild: {} not found", guild_id.get())); } @@ -31,11 +29,32 @@ pub fn get_vc_names(guild: GuildRef) -> Vec { pub fn get_channel_by_name(guild: GuildRef, name: String) -> Option { let mut result = None; for (_, channel) in &guild.channels { - if channel.name == name { + if channel.name.contains(&name) { result = Some(channel.id); break; } } result -} \ No newline at end of file +} + +pub fn get_local_songs(partial: &str) -> io::Result> { + let mut result: Vec = vec![]; + let path = Path::new("/home/emil/Music"); + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + continue; + } + + let name = entry.file_name(); + if name.to_str().is_some_and(|name| !name.to_lowercase().contains(&partial.to_lowercase())) { + continue; + } + + result.push(name.to_str().unwrap().into()); + } + + Ok(result) +}