diff --git a/.TODO b/.TODO new file mode 100644 index 0000000..f6e1060 --- /dev/null +++ b/.TODO @@ -0,0 +1,2 @@ +handle disconnect by someone, other than command +disconnect after some time when not playing \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 63a2b9c..999bd3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,12 @@ 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 = { version = "0.4.6", features = ["driver"] } +songbird = { version = "0.4.6", features = ["driver", "builtin-queue"] } +reqwest = "0.11.27" # songbird depends on ^0.11 + +[dependencies.symphonia] +version = "0.5.2" +features = ["aac", "mp3", "isomp4", "alac"] [features] DEBUG = [] diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs index d44230b..0df6866 100644 --- a/src/commands/voice/mod.rs +++ b/src/commands/voice/mod.rs @@ -1 +1,5 @@ -pub mod player; \ No newline at end of file +pub mod util; +pub mod player; +pub mod radio; +// pub mod spotify; +// pub mod yt; \ No newline at end of file diff --git a/src/commands/voice/player.rs b/src/commands/voice/player.rs index ddffb7d..5444b86 100644 --- a/src/commands/voice/player.rs +++ b/src/commands/voice/player.rs @@ -1,122 +1,26 @@ 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}; +use crate::types::{Context, Error}; -#[poise::command( - slash_command, - description_localized("en-US", "Play song") -)] -pub async fn play(ctx: Context<'_>, - #[description = "Song: "] - _url: String -) -> Result<(), Error> { +use super::util::{connect, autocomplete_channel}; - ctx.reply("Done!").await?; - Ok(()) -} +// #[poise::command( +// slash_command, +// description_localized("en-US", "Play song") +// )] +// pub async fn play(ctx: Context<'_>, +// #[description = "Song: "] +// _url: String +// ) -> Result<(), Error> { -/// 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() - } -} +// ctx.reply("Done!").await?; +// Ok(()) +// } #[poise::command( slash_command, @@ -168,12 +72,12 @@ async fn autocomplete_song( #[poise::command( slash_command, - description_localized("en-US", "Connect to channel") + description_localized("en-US", "Play song from server storage") )] pub async fn play_local(ctx: Context<'_>, #[autocomplete = "autocomplete_channel"] #[description = "Voice channel name: "] - _channel: Option, + channel: Option, #[autocomplete = "autocomplete_song"] #[description = "Filename of local song: "] file_name: String @@ -194,17 +98,24 @@ pub async fn play_local(ctx: Context<'_>, return Ok(()) }; - + if manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).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); + let _ = handler.play_only_input(input); } else { - ctx.reply_ephemeral("Not in a voice channel").await?; return Ok(()) } @@ -212,44 +123,3 @@ pub async fn play_local(ctx: Context<'_>, 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/radio/mod.rs b/src/commands/voice/radio/mod.rs new file mode 100644 index 0000000..27ead6c --- /dev/null +++ b/src/commands/voice/radio/mod.rs @@ -0,0 +1,3 @@ +pub use radio_player::*; + +pub mod radio_player; diff --git a/src/commands/voice/radio/radio_player.rs b/src/commands/voice/radio/radio_player.rs new file mode 100644 index 0000000..b9ed39d --- /dev/null +++ b/src/commands/voice/radio/radio_player.rs @@ -0,0 +1,66 @@ +use std::vec; + +use reqwest::Client; + +use songbird::input::Input; +use songbird::input::HttpRequest; + +use crate::commands::util::connect; +use crate::util::poise_context_extension::ContextExt; +use crate::types::{Context, Error}; +use crate::commands::voice::util::autocomplete_channel; + +// TODO: autocomplete radio stream URLs +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from URL source") +)] +pub async fn radio(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[description = "Source URL: "] + url: 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 manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let client = Client::new(); + let request = HttpRequest::new(client, url); + let input = Input::from(request); + handler.play_only_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/spotify/player.rs b/src/commands/voice/spotify/player.rs deleted file mode 100644 index 5c00980..0000000 --- a/src/commands/voice/spotify/player.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::vec; - -use poise::{self, CreateReply}; -use serenity::all::{CacheHttp, Guild, PartialGuild, UserId}; -use serenity::{all::ChannelId, async_trait}; -use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; -use songbird::input::{File, Input}; -use songbird::tracks::Track; -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_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(()) -} \ No newline at end of file diff --git a/src/commands/voice/util.rs b/src/commands/voice/util.rs new file mode 100644 index 0000000..4208b51 --- /dev/null +++ b/src/commands/voice/util.rs @@ -0,0 +1,76 @@ +use serenity::all::{ChannelId, GuildId}; +use serenity::async_trait; + +use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; +use songbird::TrackEvent; + +use crate::{types::{Context, Error}, util::utilities::get_channel_by_name}; + +/// 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()) + } + } +} + +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 + } +} + +pub async fn connect(ctx: &Context<'_>, guild_id: GuildId, channel: Option) -> Result<(), Error> { + let voice_channel = get_voice_channel(&ctx, channel).await?; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + 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); + } + + Ok(()) +} + +pub 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() + } +} diff --git a/src/main.rs b/src/main.rs index 3eedf7d..6db17f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,6 +86,7 @@ async fn main() -> anyhow::Result<()> { commands::hug(), commands::player::play_local(), commands::player::disconnect(), + commands::radio::radio() ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()),