diff --git a/.yt-dlprc b/.yt-dlprc index 92d83c0..c002223 100644 --- a/.yt-dlprc +++ b/.yt-dlprc @@ -1 +1 @@ -2023.07.06 +2023.11.16 diff --git a/Cargo.toml b/Cargo.toml index b7048f8..29d6bd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "cadency", "examples/*" ] +resolver = "2" [workspace.dependencies] env_logger = "0.10.0" @@ -16,13 +17,13 @@ serde_json = "1.0.99" derive_builder = "0.12.0" [workspace.dependencies.serenity] -version = "0.11.6" +version = "0.12.0" default-features = false features = ["client", "gateway", "rustls_backend", "model", "voice", "cache"] [workspace.dependencies.songbird] -version = "0.3.2" -features = ["builtin-queue", "yt-dlp"] +version = "0.4.0" +features = ["builtin-queue"] [workspace.dependencies.tokio] version = "1.29.0" @@ -35,4 +36,8 @@ features = ["derive"] [workspace.dependencies.reqwest] version = "0.11.18" default-features = false -features = ["rustls-tls"] +features = ["rustls-tls", "json"] + +[workspace.dependencies.symphonia] +version = "0.5" +features = ["all-formats"] diff --git a/Dockerfile b/Dockerfile index 6943609..a3ce6c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM lukemathwalker/cargo-chef:latest-rust-1.68-slim-bullseye as build_base +FROM lukemathwalker/cargo-chef:latest-rust-1.74-slim-bullseye as build_base FROM build_base as planner WORKDIR /cadency @@ -28,28 +28,16 @@ ENV CARGO_TERM_COLOR=always # Build and cache only the cadency app with the previously builded dependencies RUN cargo build --release --bin cadency +# Downloads yt-dlp FROM bitnami/minideb:bullseye as packages -# Downloads both ffmpeg and yt-dlp WORKDIR /packages COPY --from=builder /cadency/.yt-dlprc . -# tar: (x) extract, (J) from .xz, (f) a file. (--wildcards */bin/ffmpeg) any path with /bin/ffmpeg, (--transform) remove all previous paths -# FFMPEG is staticly compiled, so platform specific -# If statement: converts architecture from docker to a correct link. Default is amd64 = desktop 64 bit -ARG TARGETARCH -RUN if [ "$TARGETARCH" = "arm64" ]; then \ - export LINK="https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz"; \ - else \ - export LINK="https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz"; \ - fi && \ - apt-get update && apt-get install -y curl tar xz-utils && \ - curl -L $LINK > ffmpeg.tar.xz && \ - tar -xJf ffmpeg.tar.xz --wildcards */bin/ffmpeg --transform='s/^.*\///' && rm ffmpeg.tar.xz RUN YTDLP_VERSION=$(cat .yt-dlprc) && \ curl -L https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/yt-dlp_linux > yt-dlp && chmod +x yt-dlp -FROM bitnami/minideb:bullseye as python-builder # Based on: https://github.com/zarmory/docker-python-minimal/blob/master/Dockerfile # Removes Python build and developmenttools like pip. +FROM bitnami/minideb:bullseye as python-builder RUN apt-get update && apt-get install -y python3-minimal binutils && \ rm -rf /usr/local/lib/python*/ensurepip && \ rm -rf /usr/local/lib/python*/idlelib && \ diff --git a/cadency_codegen/src/derive.rs b/cadency_codegen/src/derive.rs index 95bb0d4..19a2292 100644 --- a/cadency_codegen/src/derive.rs +++ b/cadency_codegen/src/derive.rs @@ -186,7 +186,7 @@ pub(crate) fn impl_command_baseline(derive_input: DeriveInput) -> TokenStream { let deferred = command.deferred; quote! { use cadency_core::{CadencyCommandBaseline as __CadencyCommandBaseline, CadencyCommandOption as __CadencyCommandOption}; - use serenity::model::application::command::CommandOptionType as __CommandOptionType; + use serenity::model::application::CommandOptionType as __CommandOptionType; impl __CadencyCommandBaseline for #struct_name { fn name(&self) -> String { String::from(#command_name) diff --git a/cadency_commands/src/fib.rs b/cadency_commands/src/fib.rs index 083ab61..751e10c 100644 --- a/cadency_commands/src/fib.rs +++ b/cadency_commands/src/fib.rs @@ -5,9 +5,7 @@ use cadency_core::{ use serenity::{ async_trait, client::Context, - model::application::interaction::application_command::{ - ApplicationCommandInteraction, CommandDataOptionValue, - }, + model::application::{CommandDataOptionValue, CommandInteraction}, }; #[derive(CommandBaseline, Default)] @@ -34,7 +32,7 @@ impl CadencyCommand for Fib { async fn execute<'a>( &self, _ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let number = utils::get_option_value_at_position(command.data.options.as_ref(), 0) diff --git a/cadency_commands/src/inspire.rs b/cadency_commands/src/inspire.rs index a7cf345..7bffcaa 100644 --- a/cadency_commands/src/inspire.rs +++ b/cadency_commands/src/inspire.rs @@ -2,10 +2,7 @@ use cadency_core::{ response::{Response, ResponseBuilder}, CadencyCommand, CadencyError, }; -use serenity::{ - async_trait, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, -}; +use serenity::{async_trait, client::Context, model::application::CommandInteraction}; #[derive(CommandBaseline, Default)] #[description = "Say something really inspiring!"] @@ -26,7 +23,7 @@ impl CadencyCommand for Inspire { async fn execute<'a>( &self, _ctx: &Context, - _command: &'a mut ApplicationCommandInteraction, + _command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let inspire_url = Self::request_inspire_image_url().await.map_err(|err| { diff --git a/cadency_commands/src/lib.rs b/cadency_commands/src/lib.rs index 1ba5d24..ee5c9d4 100644 --- a/cadency_commands/src/lib.rs +++ b/cadency_commands/src/lib.rs @@ -38,7 +38,6 @@ mod test { fn impl_commandbaseline_trait_with_macro() { #[derive(cadency_codegen::CommandBaseline)] struct Test {} - assert!(true) } #[test] @@ -97,9 +96,8 @@ mod test { #[description = "123"] struct Test {} let test = Test {}; - assert_eq!( - test.deferred(), - false, + assert!( + !test.deferred(), "Test command should not be deferred by default" ) } @@ -128,7 +126,7 @@ mod test { #[test] fn return_derived_option() { - use serenity::model::application::command::CommandOptionType; + use serenity::model::application::CommandOptionType; #[derive(cadency_codegen::CommandBaseline)] #[argument( name = "say", @@ -144,7 +142,7 @@ mod test { assert_eq!(argument.name, "say"); assert_eq!(argument.description, "Word to say"); assert_eq!(argument.kind, CommandOptionType::String); - assert_eq!(argument.required, false); + assert!(!argument.required); } #[test] @@ -166,7 +164,7 @@ mod test { #[test] fn return_multiple_options() { - use serenity::model::application::command::CommandOptionType; + use serenity::model::application::CommandOptionType; #[derive(cadency_codegen::CommandBaseline)] #[argument(name = "say", description = "Word to say", kind = "String")] diff --git a/cadency_commands/src/now.rs b/cadency_commands/src/now.rs index c1f2036..43e097c 100644 --- a/cadency_commands/src/now.rs +++ b/cadency_commands/src/now.rs @@ -1,11 +1,10 @@ use cadency_core::{ response::{Response, ResponseBuilder}, - utils, CadencyCommand, CadencyError, -}; -use serenity::{ - async_trait, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, + utils::{self, voice::TrackMetaKey}, + CadencyCommand, CadencyError, }; +use serenity::{async_trait, client::Context, model::application::CommandInteraction}; +use songbird::tracks::LoopState; #[derive(CommandBaseline, Default)] #[description = "Shows current song"] @@ -16,7 +15,7 @@ impl CadencyCommand for Now { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let guild_id = command.guild_id.ok_or(CadencyError::Command { @@ -30,11 +29,37 @@ impl CadencyCommand for Now { let track = handler.queue().current().ok_or(CadencyError::Command { message: ":x: **No song is playing**".to_string(), })?; - Ok(response_builder - .message(Some(track.metadata().title.as_ref().map_or( + + // Extract Loop State from Track + let loop_state = track.get_info().await.unwrap().loops; + + // Create message from track metadata. This is scoped to drop the read lock on the + // trackmeta as soon as possible. + let message = { + let track_map = track.typemap().read().await; + let metadata = track_map + .get::() + .expect("Metadata to be present in track map"); + + metadata.title.as_ref().map_or( String::from(":x: **Could not add audio source to the queue!**"), - |title| format!(":newspaper: `{title}`"), - ))) - .build()?) + |title| { + let mut track_info = format!(":newspaper: `{title}`"); + match loop_state { + LoopState::Infinite => { + track_info.push_str("\n:repeat: `Infinite`"); + } + LoopState::Finite(loop_amount) => { + if loop_amount > 0 { + track_info.push_str(&format!("\n:repeat: `{}`", loop_amount)); + } + } + } + track_info + }, + ) + }; + + Ok(response_builder.message(Some(message)).build()?) } } diff --git a/cadency_commands/src/pause.rs b/cadency_commands/src/pause.rs index e979149..dee3e29 100644 --- a/cadency_commands/src/pause.rs +++ b/cadency_commands/src/pause.rs @@ -2,10 +2,7 @@ use cadency_core::{ response::{Response, ResponseBuilder}, utils, CadencyCommand, CadencyError, }; -use serenity::{ - async_trait, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, -}; +use serenity::{async_trait, client::Context, model::application::CommandInteraction}; #[derive(CommandBaseline, Default)] #[description = "Pause the current song"] @@ -17,7 +14,7 @@ impl CadencyCommand for Pause { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let guild_id = command.guild_id.ok_or(CadencyError::Command { diff --git a/cadency_commands/src/ping.rs b/cadency_commands/src/ping.rs index 8a4ece1..56a4353 100644 --- a/cadency_commands/src/ping.rs +++ b/cadency_commands/src/ping.rs @@ -2,10 +2,7 @@ use cadency_core::{ response::{Response, ResponseBuilder}, CadencyCommand, CadencyError, }; -use serenity::{ - async_trait, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, -}; +use serenity::{async_trait, client::Context, model::application::CommandInteraction}; #[derive(CommandBaseline, Default)] #[description = "Play Ping-Pong"] @@ -16,7 +13,7 @@ impl CadencyCommand for Ping { async fn execute<'a>( &self, _ctx: &Context, - _command: &'a mut ApplicationCommandInteraction, + _command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { Ok(response_builder diff --git a/cadency_commands/src/play.rs b/cadency_commands/src/play.rs index a56f9fa..d2a02c8 100644 --- a/cadency_commands/src/play.rs +++ b/cadency_commands/src/play.rs @@ -7,9 +7,7 @@ use reqwest::Url; use serenity::{ async_trait, client::Context, - model::application::interaction::application_command::{ - ApplicationCommandInteraction, CommandDataOptionValue, - }, + model::application::{CommandDataOptionValue, CommandInteraction}, }; use songbird::events::Event; @@ -42,7 +40,7 @@ impl CadencyCommand for Play { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let (search_payload, is_url, is_playlist) = @@ -67,10 +65,7 @@ impl CadencyCommand for Play { message: ":x: **No search string provided**".to_string(), })?; let (manager, call, guild_id) = utils::voice::join(ctx, command).await?; - let mut is_queue_empty = { - let call_handler = call.lock().await; - call_handler.queue().is_empty() - }; + let response_builder = if is_playlist { let playlist_items = cadency_yt_playlist::fetch_playlist_songs(search_payload.clone()).unwrap(); @@ -87,19 +82,11 @@ impl CadencyCommand for Play { if amount_added_playlist_songs <= self.playlist_song_limit && song.duration <= self.song_length_limit { - match utils::voice::add_song( - call.clone(), - song.url, - true, - !is_queue_empty, // Don't add first song lazy to the queue - ) - .await - { - Ok(added_song) => { + match utils::voice::add_song(ctx, call.clone(), song.url, true).await { + Ok((added_song_meta, _)) => { amount_added_playlist_songs += 1; amount_total_added_playlist_duration += song.duration; - is_queue_empty = false; - debug!("➕ Added song '{:?}' from playlist", added_song.title); + debug!("➕ Added song '{:?}' from playlist", added_song_meta.title); } Err(err) => { error!("❌ Failed to add song: {err}"); @@ -108,40 +95,42 @@ impl CadencyCommand for Play { } } amount_total_added_playlist_duration /= 60_f32; - let mut handler = call.lock().await; - handler.remove_all_global_events(); - handler.add_global_event( - Event::Periodic(std::time::Duration::from_secs(120), None), - InactiveHandler { guild_id, manager }, - ); - drop(handler); + // This call interaction is scoped to drop the mutex lock as soon as possible + { + let mut handler = call.lock().await; + handler.remove_all_global_events(); + handler.add_global_event( + Event::Periodic(std::time::Duration::from_secs(120), None), + InactiveHandler { guild_id, manager }, + ); + } response_builder.message(Some(format!( ":white_check_mark: **Added ___{amount_added_playlist_songs}___ songs to the queue with a duration of ___{amount_total_added_playlist_duration:.2}___ mins** \n**Playing** :notes: `{search_payload}`", ))) } else { - let added_song = utils::voice::add_song( - call.clone(), - search_payload.clone(), - is_url, - !is_queue_empty, // Don't add first song lazy to the queue - ) - .await - .map_err(|err| { - error!("❌ Failed to add song to queue: {}", err); - CadencyError::Command { - message: ":x: **Couldn't add audio source to the queue!**".to_string(), - } - })?; - let mut handler = call.lock().await; - handler.remove_all_global_events(); - handler.add_global_event( - Event::Periodic(std::time::Duration::from_secs(120), None), - InactiveHandler { guild_id, manager }, - ); + let (added_song_meta, _) = + utils::voice::add_song(ctx, call.clone(), search_payload.clone(), is_url) + .await + .map_err(|err| { + error!("❌ Failed to add song to queue: {}", err); + CadencyError::Command { + message: ":x: **Couldn't add audio source to the queue!**".to_string(), + } + })?; + // This call interaction is scoped to drop the mutex lock as soon as possible + { + let mut handler = call.lock().await; + handler.remove_all_global_events(); + handler.add_global_event( + Event::Periodic(std::time::Duration::from_secs(120), None), + InactiveHandler { guild_id, manager }, + ); + } + let song_url = if is_url { search_payload } else { - added_song + added_song_meta .source_url .as_ref() .map_or("unknown url", |url| url) @@ -149,7 +138,7 @@ impl CadencyCommand for Play { response_builder.message(Some(format!( ":white_check_mark: **Added song to the queue and started playing:** \n:notes: `{}` \n:link: `{}`", song_url, - added_song + added_song_meta .title .as_ref() .map_or(":x: **Unknown title**", |title| title) diff --git a/cadency_commands/src/resume.rs b/cadency_commands/src/resume.rs index 7ffa5cb..0b3f1e2 100644 --- a/cadency_commands/src/resume.rs +++ b/cadency_commands/src/resume.rs @@ -2,10 +2,7 @@ use cadency_core::{ response::{Response, ResponseBuilder}, utils, CadencyCommand, CadencyError, }; -use serenity::{ - async_trait, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, -}; +use serenity::{async_trait, client::Context, model::application::CommandInteraction}; #[derive(CommandBaseline, Default)] #[description = "Resume current song if paused"] @@ -17,7 +14,7 @@ impl CadencyCommand for Resume { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let guild_id = command.guild_id.ok_or(CadencyError::Command { diff --git a/cadency_commands/src/skip.rs b/cadency_commands/src/skip.rs index 23f2adf..87a22a2 100644 --- a/cadency_commands/src/skip.rs +++ b/cadency_commands/src/skip.rs @@ -2,10 +2,7 @@ use cadency_core::{ response::{Response, ResponseBuilder}, utils, CadencyCommand, CadencyError, }; -use serenity::{ - async_trait, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, -}; +use serenity::{async_trait, client::Context, model::application::CommandInteraction}; #[derive(CommandBaseline, Default)] #[description = "Skip current song"] @@ -17,7 +14,7 @@ impl CadencyCommand for Skip { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let guild_id = command.guild_id.ok_or(CadencyError::Command { diff --git a/cadency_commands/src/slap.rs b/cadency_commands/src/slap.rs index 2770a85..d8881c2 100644 --- a/cadency_commands/src/slap.rs +++ b/cadency_commands/src/slap.rs @@ -3,12 +3,12 @@ use cadency_core::{ utils, CadencyCommand, CadencyError, }; use serenity::{ + all::Mentionable, async_trait, client::Context, - model::application::interaction::application_command::{ - ApplicationCommandInteraction, CommandDataOptionValue, - }, + model::application::{CommandDataOptionValue, CommandInteraction}, }; +use std::num::NonZeroU64; #[derive(CommandBaseline, Default)] #[description = "Slap someone with a large trout!"] @@ -24,13 +24,13 @@ impl CadencyCommand for Slap { async fn execute<'a>( &self, _ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { - let user = utils::get_option_value_at_position(command.data.options.as_ref(), 0) + let user_id = utils::get_option_value_at_position(command.data.options.as_ref(), 0) .and_then(|option_value| { - if let CommandDataOptionValue::User(user, _) = option_value { - Some(user) + if let CommandDataOptionValue::User(user_id) = option_value { + Some(user_id) } else { error!("Command option is not a user"); None @@ -40,20 +40,22 @@ impl CadencyCommand for Slap { message: ":x: *Invalid user provided*".to_string(), })?; - let response_builder = if user.id == command.user.id { + let response_builder = if user_id == &command.user.id { response_builder.message(Some(format!( "**Why do you want to slap yourself, {}?**", - command.user + command.user.mention() ))) - } else if user.id.0 == command.application_id.0 { + } else if NonZeroU64::from(*user_id) == NonZeroU64::from(command.application_id) { response_builder.message(Some(format!( "**Nope!\n{} slaps {} around a bit with a large trout!**", - user, command.user + user_id.mention(), + command.user.mention() ))) } else { response_builder.message(Some(format!( "**{} slaps {} around a bit with a large trout!**", - command.user, user + command.user.mention(), + user_id.mention() ))) }; Ok(response_builder.build()?) diff --git a/cadency_commands/src/stop.rs b/cadency_commands/src/stop.rs index 5fe44a8..7b0bc77 100644 --- a/cadency_commands/src/stop.rs +++ b/cadency_commands/src/stop.rs @@ -2,10 +2,7 @@ use cadency_core::{ response::{Response, ResponseBuilder}, utils, CadencyCommand, CadencyError, }; -use serenity::{ - async_trait, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, -}; +use serenity::{async_trait, client::Context, model::application::CommandInteraction}; #[derive(CommandBaseline, Default)] #[description = "Stop music and clear the track list"] @@ -17,7 +14,7 @@ impl CadencyCommand for Stop { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let guild_id = command.guild_id.ok_or(CadencyError::Command { diff --git a/cadency_commands/src/track_loop.rs b/cadency_commands/src/track_loop.rs index 4f2b01f..6cd85a1 100644 --- a/cadency_commands/src/track_loop.rs +++ b/cadency_commands/src/track_loop.rs @@ -3,11 +3,8 @@ use cadency_core::{ utils, CadencyCommand, CadencyError, }; use serenity::{ - async_trait, - client::Context, - model::application::{ - command::CommandOptionType, interaction::application_command::ApplicationCommandInteraction, - }, + all::CommandDataOptionValue, async_trait, client::Context, + model::application::CommandInteraction, }; #[derive(Default, CommandBaseline)] @@ -32,7 +29,7 @@ impl CadencyCommand for TrackLoop { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { // Validate if command can be executed @@ -53,14 +50,28 @@ impl CadencyCommand for TrackLoop { .data .options .iter() - .find(|option| option.kind == CommandOptionType::Integer) - .and_then(|option_amount| option_amount.value.as_ref().unwrap().as_u64()); + .find(|option| option.name == "amount") + .and_then(|option_amount| { + if let CommandDataOptionValue::Integer(amount) = option_amount.value { + Some(amount) + } else { + error!("Command option 'amount' is not a integer"); + None + } + }); let stop_argument = command .data .options .iter() - .find(|option| option.kind == CommandOptionType::Boolean) - .and_then(|option_stop| option_stop.value.as_ref().unwrap().as_bool()); + .find(|option| option.name == "stop") + .and_then(|option_stop| { + if let CommandDataOptionValue::Boolean(stop) = option_stop.value { + Some(stop) + } else { + error!("Command option 'stop' is not a boolean"); + None + } + }); // Cancel looping if the stop argument is true if let Some(stop) = stop_argument { diff --git a/cadency_commands/src/tracks.rs b/cadency_commands/src/tracks.rs index bc20f3e..aba5d6c 100644 --- a/cadency_commands/src/tracks.rs +++ b/cadency_commands/src/tracks.rs @@ -1,12 +1,15 @@ use cadency_core::{ response::{Response, ResponseBuilder}, - utils, CadencyCommand, CadencyError, + utils::{self, voice::TrackMetaKey}, + CadencyCommand, CadencyError, }; use serenity::{ - async_trait, builder::CreateEmbed, client::Context, - model::application::interaction::application_command::ApplicationCommandInteraction, - utils::Color, + async_trait, + builder::CreateEmbed, + client::Context, + model::{application::CommandInteraction, Color}, }; +use songbird::tracks::LoopState; #[derive(CommandBaseline, Default)] #[description = "List all tracks in the queue"] @@ -18,7 +21,7 @@ impl CadencyCommand for Tracks { async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let guild_id = command.guild_id.ok_or(CadencyError::Command { @@ -33,23 +36,46 @@ impl CadencyCommand for Tracks { response_builder.message(Some(":x: **No tracks in the queue**".to_string())) } else { let queue_snapshot = handler.queue().current_queue(); - let mut embeded_tracks = CreateEmbed::default(); - embeded_tracks.color(Color::BLURPLE); - embeded_tracks.title("Track List"); + let mut embeded_tracks = CreateEmbed::default() + .color(Color::BLURPLE) + .title("Track List"); for (index, track) in queue_snapshot.into_iter().enumerate() { - let position = index + 1; - let metadata = track.metadata(); - let title = metadata - .title - .as_ref() - .map_or("**No title provided**", |t| t); - let url = metadata - .source_url - .as_ref() - .map_or("**No url provided**", |u| u); - embeded_tracks.field( - format!("{position}. :newspaper: `{title}`"), - format!(":notes: `{url}`"), + let track_position = index + 1; + // Extract title and url of the track. This is scoped to drop the read lock on + // the track meta as soon as possible. + let (title, url, loop_state) = { + // Extract track Metadata from tracks TyeMap + let track_map = track.typemap().read().await; + let metadata = track_map + .get::() + .expect("Metadata to be present in track map"); + let title = metadata + .title + .as_ref() + .map_or("**No title provided**", |t| t); + let url = metadata + .source_url + .as_ref() + .map_or("**No url provided**", |u| u); + + // Extract loop state from track state + let track_info = track.get_info().await.unwrap(); + (title.to_owned(), url.to_owned(), track_info.loops) + }; + let mut embed_value = format!(":notes: `{url}`"); + match loop_state { + LoopState::Infinite => { + embed_value.push_str("\n:repeat: `Infinite`"); + } + LoopState::Finite(loop_amount) => { + if loop_amount > 0 { + embed_value.push_str(&format!("\n:repeat: `{}`", loop_amount)); + } + } + } + embeded_tracks = embeded_tracks.field( + format!("{track_position}. :newspaper: `{title}`"), + embed_value, false, ); } diff --git a/cadency_commands/src/urban.rs b/cadency_commands/src/urban.rs index ed89130..177e426 100644 --- a/cadency_commands/src/urban.rs +++ b/cadency_commands/src/urban.rs @@ -6,10 +6,10 @@ use serenity::{ async_trait, builder::CreateEmbed, client::Context, - model::application::interaction::application_command::{ - ApplicationCommandInteraction, CommandDataOptionValue, + model::{ + application::{CommandDataOptionValue, CommandInteraction}, + Color, }, - utils::Color, }; #[derive(Deserialize, Debug)] @@ -52,24 +52,24 @@ impl Urban { if index >= 3 { break; } - let mut embed_urban_entry = CreateEmbed::default(); - embed_urban_entry.color(Color::from_rgb(255, 255, 0)); - embed_urban_entry.title(&urban.word.replace(['[', ']'], "")); - embed_urban_entry.url(&urban.permalink); - embed_urban_entry.field( - "Definition", - &urban.definition.replace(['[', ']'], ""), - false, - ); - embed_urban_entry.field("Example", &urban.example.replace(['[', ']'], ""), false); - embed_urban_entry.field( - "Rating", - format!( - "{} :thumbsup: {} :thumbsdown:", - urban.thumbs_up, urban.thumbs_down - ), - false, - ); + let embed_urban_entry = CreateEmbed::default() + .color(Color::from_rgb(255, 255, 0)) + .title(&urban.word.replace(['[', ']'], "")) + .url(&urban.permalink) + .field( + "Definition", + &urban.definition.replace(['[', ']'], ""), + false, + ) + .field("Example", &urban.example.replace(['[', ']'], ""), false) + .field( + "Rating", + format!( + "{} :thumbsup: {} :thumbsdown:", + urban.thumbs_up, urban.thumbs_down + ), + false, + ); embeds.push(embed_urban_entry); } embeds @@ -81,7 +81,7 @@ impl CadencyCommand for Urban { async fn execute<'a>( &self, _ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, respone_builder: &'a mut ResponseBuilder, ) -> Result { let query = utils::get_option_value_at_position(command.data.options.as_ref(), 0) diff --git a/cadency_core/Cargo.toml b/cadency_core/Cargo.toml index 006c76a..0cc10d9 100644 --- a/cadency_core/Cargo.toml +++ b/cadency_core/Cargo.toml @@ -14,3 +14,4 @@ tokio = { workspace = true } reqwest = { workspace = true } thiserror = { workspace = true } derive_builder = { workspace = true } +symphonia = { workspace = true } diff --git a/cadency_core/src/client.rs b/cadency_core/src/client.rs index 1a49385..c50b648 100644 --- a/cadency_core/src/client.rs +++ b/cadency_core/src/client.rs @@ -1,6 +1,6 @@ use crate::{ - command::Commands, error::CadencyError, handler::command::Handler, intents::CadencyIntents, - CadencyCommand, + command::Commands, error::CadencyError, handler::command::Handler, http::HttpClientKey, + intents::CadencyIntents, CadencyCommand, }; use serenity::{client::Client, model::gateway::GatewayIntents}; use songbird::SerenityInit; @@ -25,12 +25,10 @@ impl Cadency { let mut client = Client::builder(self.token, self.intents) .event_handler(Handler) .register_songbird() + .type_map_insert::(self.commands) + .type_map_insert::(reqwest::Client::new()) .await .map_err(|err| CadencyError::Start { source: err })?; - { - let mut data = client.data.write().await; - data.insert::(self.commands); - } client .start() .await diff --git a/cadency_core/src/command.rs b/cadency_core/src/command.rs index 0c8186e..cdb4cb9 100644 --- a/cadency_core/src/command.rs +++ b/cadency_core/src/command.rs @@ -5,16 +5,12 @@ use crate::{ }; use serenity::{ async_trait, - client::Context, - model::{ - application::{ - command::Command, - interaction::{ - application_command::ApplicationCommandInteraction, InteractionResponseType, - }, - }, - prelude::command::CommandOptionType, + builder::{ + CreateCommand, CreateCommandOption, CreateInteractionResponse, + CreateInteractionResponseMessage, }, + client::Context, + model::application::{Command, CommandInteraction, CommandOptionType}, prelude::TypeMapKey, }; use std::sync::Arc; @@ -51,27 +47,23 @@ pub struct CadencyCommandOption { pub trait CadencyCommand: Sync + Send + CadencyCommandBaseline { /// Construct the slash command that will be submited to the discord api async fn register(&self, ctx: &Context) -> Result { - Ok( - Command::create_global_application_command(&ctx.http, |command| { - let command_builder = command.name(self.name()).description(self.description()); - for cadency_option in self.options() { - command_builder.create_option(|option_res| { - option_res - .name(cadency_option.name) - .description(cadency_option.description) - .kind(cadency_option.kind) - .required(cadency_option.required) - }); - } - command_builder + let command_options: Vec = self + .options() + .into_iter() + .map(|option| { + CreateCommandOption::new(option.kind, option.name, option.description) + .required(option.required) }) - .await?, - ) + .collect(); + let command_builder = CreateCommand::new(self.name()) + .description(self.description()) + .set_options(command_options); + Ok(Command::create_global_command(&ctx.http, command_builder).await?) } async fn execute<'a>( &self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result; } @@ -96,15 +88,17 @@ pub(crate) async fn setup_commands(ctx: &Context) -> Result<(), serenity::Error> pub(crate) async fn command_not_implemented( ctx: &Context, - command: &ApplicationCommandInteraction, + command: &CommandInteraction, ) -> Result<(), CadencyError> { error!("The following command is not known: {:?}", command); + command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| message.content("Unknown command")) - }) + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content("Unknown command"), + ), + ) .await .map_err(|err| { error!("Interaction response failed: {}", err); diff --git a/cadency_core/src/handler/command.rs b/cadency_core/src/handler/command.rs index b42a1d4..7a18d8d 100644 --- a/cadency_core/src/handler/command.rs +++ b/cadency_core/src/handler/command.rs @@ -1,14 +1,14 @@ use crate::{ command::{command_not_implemented, setup_commands}, response::{ResponseBuilder, ResponseTiming}, - utils, - utils::set_bot_presence, - CadencyError, + utils, CadencyError, }; use serenity::{ + all::OnlineStatus, async_trait, client::{Context, EventHandler}, - model::{application::interaction::Interaction, event::ResumedEvent, gateway::Ready}, + gateway::ActivityData, + model::{application::Interaction, event::ResumedEvent, gateway::Ready}, }; pub(crate) struct Handler; @@ -17,7 +17,9 @@ pub(crate) struct Handler; impl EventHandler for Handler { async fn ready(&self, ctx: Context, _data_about_bot: Ready) { info!("🚀 Start Cadency Discord Bot"); - set_bot_presence(&ctx).await; + // Set the bot presence to "Listening to music" + ctx.set_presence(Some(ActivityData::listening("music")), OnlineStatus::Online); + info!("⏳ Started to submit commands, please wait..."); match setup_commands(&ctx).await { Ok(_) => info!("✅ Application commands submitted"), @@ -30,7 +32,7 @@ impl EventHandler for Handler { } async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - if let Interaction::ApplicationCommand(mut command) = interaction { + if let Interaction::Command(mut command) = interaction { let cmd_target = utils::get_commands(&ctx) .await .into_iter() diff --git a/cadency_core/src/http.rs b/cadency_core/src/http.rs new file mode 100644 index 0000000..a5c21d2 --- /dev/null +++ b/cadency_core/src/http.rs @@ -0,0 +1,14 @@ +use serenity::prelude::TypeMapKey; + +pub struct HttpClientKey; + +impl TypeMapKey for HttpClientKey { + type Value = reqwest::Client; +} + +pub(crate) async fn get_http_client(ctx: &serenity::client::Context) -> reqwest::Client { + let data = ctx.data.read().await; + data.get::() + .expect("Expected HttpClientKey in TypeMap.") + .clone() +} diff --git a/cadency_core/src/lib.rs b/cadency_core/src/lib.rs index 50b296d..0c31dcf 100644 --- a/cadency_core/src/lib.rs +++ b/cadency_core/src/lib.rs @@ -9,6 +9,7 @@ pub use command::{CadencyCommand, CadencyCommandBaseline, CadencyCommandOption}; mod error; pub use error::CadencyError; pub mod handler; +pub mod http; mod intents; pub mod response; pub mod utils; diff --git a/cadency_core/src/response.rs b/cadency_core/src/response.rs index f650ff2..6fa2f5f 100644 --- a/cadency_core/src/response.rs +++ b/cadency_core/src/response.rs @@ -1,10 +1,11 @@ use crate::CadencyError; use derive_builder::Builder; use serenity::{ - builder::CreateEmbed, - model::prelude::interaction::{ - application_command::ApplicationCommandInteraction, InteractionResponseType, + builder::{ + CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, + EditInteractionResponse, }, + model::prelude::CommandInteraction, prelude::Context, }; @@ -37,39 +38,45 @@ impl Response { pub async fn submit<'a>( self, ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, ) -> Result<(), CadencyError> { match self.timing { + // Create a regular text response that might has embeds ResponseTiming::Instant => { + let response = if let Some(msg) = self.message { + CreateInteractionResponseMessage::new() + .content(msg) + .add_embeds(self.embeds) + } else { + CreateInteractionResponseMessage::new().add_embeds(self.embeds) + }; command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| { - if let Some(msg) = self.message { - message.content(msg); - } - message.add_embeds(self.embeds) - }) - }) + .create_response(&ctx.http, CreateInteractionResponse::Message(response)) .await } + // Just indicate that the command is being processed ResponseTiming::DeferredInfo => { command - .create_interaction_response(&ctx.http, |response| { - response.kind(InteractionResponseType::DeferredChannelMessageWithSource) - }) + .create_response( + &ctx.http, + CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new()), + ) .await } - ResponseTiming::Deferred => command - .edit_original_interaction_response(&ctx.http, |previous_response| { - if let Some(msg) = self.message { - previous_response.content(msg); - } - previous_response.add_embeds(self.embeds) - }) - .await - .map(|_| ()), + // Edit the deferred response with the actual response + ResponseTiming::Deferred => { + let edit_response = if let Some(msg) = self.message { + EditInteractionResponse::new() + .content(msg) + .add_embeds(self.embeds) + } else { + EditInteractionResponse::new().add_embeds(self.embeds) + }; + command + .edit_response(&ctx.http, edit_response) + .await + .map(|_| ()) + } } .map_err(|err| { error!("Failed to submit response: {}", err); diff --git a/cadency_core/src/utils/mod.rs b/cadency_core/src/utils/mod.rs index 17d202a..a896516 100644 --- a/cadency_core/src/utils/mod.rs +++ b/cadency_core/src/utils/mod.rs @@ -1,25 +1,12 @@ use crate::{command::Commands, CadencyCommand}; use serenity::{ client::Context, - model::{ - application::interaction::application_command::{ - CommandDataOption, CommandDataOptionValue, - }, - gateway::Activity, - user::OnlineStatus, - }, + model::application::{CommandDataOption, CommandDataOptionValue}, }; use std::sync::Arc; pub mod voice; -/// Set the online status and activity of the bot. -/// Should not be set before the `ready` event. -pub(crate) async fn set_bot_presence(ctx: &Context) { - ctx.set_presence(Some(Activity::listening("music")), OnlineStatus::Online) - .await; -} - pub(crate) async fn get_commands(ctx: &Context) -> Vec> { let data_read = ctx.data.read().await; data_read @@ -32,7 +19,5 @@ pub fn get_option_value_at_position( options: &[CommandDataOption], position: usize, ) -> Option<&CommandDataOptionValue> { - options - .get(position) - .and_then(|option| option.resolved.as_ref()) + options.get(position).map(|option| &option.value) } diff --git a/cadency_core/src/utils/voice.rs b/cadency_core/src/utils/voice.rs index fee7ab1..5217a57 100644 --- a/cadency_core/src/utils/voice.rs +++ b/cadency_core/src/utils/voice.rs @@ -1,16 +1,27 @@ -use crate::{error::CadencyError, utils}; +use crate::{error::CadencyError, http::get_http_client, utils}; use reqwest::Url; use serenity::{ + all::{Guild, GuildId}, + cache::CacheRef, client::Context, model, - model::application::interaction::application_command::{ - ApplicationCommandInteraction, CommandDataOption, CommandDataOptionValue, - }, + model::application::{CommandDataOption, CommandDataOptionValue, CommandInteraction}, }; -use songbird::{input::Input, input::Restartable, Songbird}; +use songbird::{ + input::{AuxMetadata, Input, YoutubeDl}, + tracks::TrackHandle, + typemap::TypeMapKey, + Songbird, +}; + +pub struct TrackMetaKey; + +impl TypeMapKey for TrackMetaKey { + type Value = AuxMetadata; +} pub fn get_active_voice_channel_id( - guild: model::guild::Guild, + guild: CacheRef<'_, GuildId, Guild>, user_id: model::id::UserId, ) -> Option { guild @@ -21,7 +32,7 @@ pub fn get_active_voice_channel_id( pub async fn join( ctx: &Context, - command: &ApplicationCommandInteraction, + command: &CommandInteraction, ) -> Result< ( std::sync::Arc, @@ -37,7 +48,9 @@ pub async fn join( let channel_id = ctx .cache .guild(guild_id) - .and_then(|guild| utils::voice::get_active_voice_channel_id(guild, command.user.id)) + .and_then(|guild_cache_ref| { + utils::voice::get_active_voice_channel_id(guild_cache_ref, command.user.id) + }) .ok_or(CadencyError::Join)?; debug!("Try to join guild with id: {:?}", guild_id); // Skip channel join if already connected @@ -51,48 +64,65 @@ pub async fn join( return Ok((manager, call, guild_id)); } } - // join the channel - let (call, join) = manager.join(guild_id, channel_id).await; - join.map_err(|err| { - error!("Voice channel join failed: {err:?}"); + // Construct the call for the channel, don't join just yet + let call = manager.join(guild_id, channel_id).await.map_err(|err| { + error!("Unable to construct call for channel: {err:?}"); CadencyError::Join })?; + // Join the channel, this is scoped to drop the lock as soon as possible + { + let mut locked_call = call.lock().await; + locked_call.join(channel_id).await.map_err(|err| { + error!("Voice channel join failed: {err:?}"); + CadencyError::Join + })?; + } Ok((manager, call, guild_id)) } pub async fn add_song( + context: &Context, call: std::sync::Arc>, payload: String, is_url: bool, - add_lazy: bool, -) -> Result { +) -> Result<(songbird::input::AuxMetadata, TrackHandle), songbird::input::AuxMetadataError> { debug!("Add song to playlist: '{payload}'"); + let request_client = get_http_client(context).await; + // Create the YoutubeDL source from url or search string let source = if is_url { - Restartable::ytdl(payload, add_lazy).await? + YoutubeDl::new(request_client, payload) } else { - Restartable::ytdl_search(payload, add_lazy).await? + YoutubeDl::new(request_client, format!("ytsearch1:{payload}")) }; let mut handler = call.lock().await; - let track: Input = source.into(); - let metadata = *track.metadata.clone(); - handler.enqueue_source(track); - Ok(metadata) + + // Extract metadata and enqueue the source + let mut input: Input = source.into(); + let metadata = input.aux_metadata().await?; + + let track_handle = handler.enqueue_input(input).await; + // Store the metadata for later use + track_handle + .typemap() + .write() + .await + .insert::(metadata.clone()); + + Ok((metadata, track_handle)) } pub fn parse_valid_url(command_options: &[CommandDataOption]) -> Option { - command_options - .get(0) - .and_then(|option| match option.resolved.as_ref() { - Some(value) => { - if let CommandDataOptionValue::String(url) = value { - Some(url) - } else { - None - } + match utils::get_option_value_at_position(command_options, 0) { + Some(value) => { + if let CommandDataOptionValue::String(url) = value { + Some(url) + } else { + None } - None => None, - }) - .and_then(|url| Url::parse(url).ok()) + } + None => None, + } + .and_then(|url| Url::parse(url).ok()) } pub async fn get_songbird(ctx: &Context) -> std::sync::Arc { diff --git a/examples/custom_commands/examples/custom_commands.rs b/examples/custom_commands/examples/custom_commands.rs index 0ad54ce..aeb9409 100644 --- a/examples/custom_commands/examples/custom_commands.rs +++ b/examples/custom_commands/examples/custom_commands.rs @@ -9,11 +9,10 @@ use cadency_core::{ setup_commands, utils, Cadency, CadencyCommand, CadencyError, }; use serenity::{ + all::Mentionable, async_trait, client::Context, - model::application::interaction::application_command::{ - ApplicationCommandInteraction, CommandDataOptionValue, - }, + model::application::{CommandDataOptionValue, CommandInteraction}, }; // This is your custom command with the name "hello" @@ -28,13 +27,13 @@ impl CadencyCommand for Hello { async fn execute<'a>( &self, _ctx: &Context, - command: &'a mut ApplicationCommandInteraction, + command: &'a mut CommandInteraction, response_builder: &'a mut ResponseBuilder, ) -> Result { let user_arg = utils::get_option_value_at_position(command.data.options.as_ref(), 0) .and_then(|option_value| { - if let CommandDataOptionValue::User(user, _) = option_value { - Some(user) + if let CommandDataOptionValue::User(user_id) = option_value { + Some(user_id) } else { error!("Command argument is not a user"); None @@ -42,7 +41,7 @@ impl CadencyCommand for Hello { }) .expect("A user as command argument"); Ok(response_builder - .message(Some(format!("**Hello {user_arg}!**"))) + .message(Some(format!("**Hello {}!**", user_arg.mention()))) .build()?) } }