diff --git a/src/commands/voice.rs b/src/commands/voice.rs index 98fd8f5..a86fe29 100644 --- a/src/commands/voice.rs +++ b/src/commands/voice.rs @@ -4,6 +4,7 @@ mod voice_utils; pub mod player_common; pub mod radio; pub mod general_player; +pub mod voice_types; // ! not working // pub mod yt; diff --git a/src/commands/voice/general_player.rs b/src/commands/voice/general_player.rs index 860d22f..3ad9b7b 100644 --- a/src/commands/voice/general_player.rs +++ b/src/commands/voice/general_player.rs @@ -2,8 +2,7 @@ use std::vec; use songbird::input::YoutubeDl; -use crate::utils::poise_context_extension::ContextExt; -use crate::types::{Context, Error}; +use crate::types::{Context, ContextExt, Error}; use crate::commands::voice::voice_utils::autocomplete_channel; use super::connect; diff --git a/src/commands/voice/player_common.rs b/src/commands/voice/player_common.rs index f87031c..8eee740 100644 --- a/src/commands/voice/player_common.rs +++ b/src/commands/voice/player_common.rs @@ -1,11 +1,11 @@ use std::vec; +use poise::CreateReply; use songbird::input::{File, Input}; use crate::utils::debug::send_error; -use crate::utils::poise_context_extension::ContextExt; +use crate::types::{Context, ContextExt, Error}; use crate::utils::utilities::get_local_songs; -use crate::types::{Context, Error}; use super::voice_utils::{connect, autocomplete_channel}; @@ -114,3 +114,48 @@ pub async fn play_local(ctx: Context<'_>, ctx.reply_ephemeral("Done!").await?; Ok(()) } + + +#[poise::command( + slash_command, + description_localized("en-US", "Display currently playing info") +)] +pub async fn playing(ctx: Context<'_>) -> Result<(), Error> { + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(_) = manager.get(guild_id) else { + ctx.reply_ephemeral("I am not connected to any voice channel right now").await?; + return Ok(()) + }; + + // println!("here"); + + let embed = { + let mutex_hashmap = ctx.data().playing_info.lock().await; + let Some(playing_info) = mutex_hashmap.get(&guild_id) else { + ctx.reply_ephemeral("Entry not found, try to reconnect me").await?; + return Ok(()); + }; + + playing_info.generate_embed().await + }; + + dbg!(&embed); + + ctx.send( + CreateReply::default() + .embed(embed) + ).await?; + + Ok(()) +} + diff --git a/src/commands/voice/radio/radio_player.rs b/src/commands/voice/radio/radio_player.rs index 599c610..f669429 100644 --- a/src/commands/voice/radio/radio_player.rs +++ b/src/commands/voice/radio/radio_player.rs @@ -1,17 +1,22 @@ use std::vec; -use radiobrowser::RadioBrowserAPI; +use poise::ChoiceParameter; use reqwest::Client; use songbird::input::Input; use songbird::input::HttpRequest; use super::super::connect; -use crate::utils::poise_context_extension::ContextExt; +use super::link_or_string; +use super::parse_radio_autocomplete; +use super::LinkString; +use crate::commands::voice_types::NumberOfEntries; +use crate::commands::voice_types::PlayingInfoType; +use crate::types::ContextExt; use crate::types::{Context, Error}; use crate::commands::voice::voice_utils::autocomplete_channel; -use super::radio_utils::paginate_search; +use super::radio_utils::paginate_search_stations; use super::super::voice_utils::MAX_ENTRIES; @@ -24,6 +29,8 @@ pub async fn radio(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } +// TODO: deduplicate you use a lot of the same code + // TODO: autocomplete radio stream URLs #[poise::command( slash_command, @@ -43,6 +50,47 @@ pub async fn play(ctx: Context<'_>, return Ok(()); } + let api = &ctx.data().radio_browser; + + let stations_result = match link_or_string(&name) { + LinkString::Link => { + if name.ends_with(".m3u") { + ctx.reply_ephemeral("Sorry, currently I don't support m3u files").await?; + return Ok(()); + } + + api.get_stations().byurl(name).await + }, + LinkString::String => { + let (name, country, language) = match parse_radio_autocomplete(&name) { + Some(tuple) => tuple, + None => { + ctx.reply_ephemeral("Please use either direct URL or provided autocomplete").await?; + return Ok(()) + } + }; + + api.get_stations() + .name_exact(true) + .name(name) + .country_exact(true) + .country(country) + .language_exact(true) + .language(language) + .send().await + } + }; + + let Ok(stations) = stations_result else { + ctx.reply_ephemeral("There was an error with radio API!").await?; + return Ok(()) + }; + + let Some(station) = stations.first() else { + ctx.reply_ephemeral("Radio station not found!").await?; + return Ok(()) + }; + let manager = songbird::get(ctx.serenity_context()) .await .expect("Songbird Voice client placed in at initialisation.") @@ -54,8 +102,9 @@ pub async fn play(ctx: Context<'_>, }; if manager.get(guild_id).is_none() { - if let Err(e) = connect(&ctx, guild_id, channel).await { + if let Err(e) = connect(&ctx, guild_id, channel).await { ctx.reply_ephemeral(&e.to_string()).await?; + println!("SONGBIRD MANAGER ERROR: {}", e.to_string()); return Ok(()) } } @@ -64,7 +113,7 @@ pub async fn play(ctx: Context<'_>, let mut handler = handler_lock.lock().await; let client = Client::new(); - let request = HttpRequest::new(client, name); + let request = HttpRequest::new(client, station.url.clone()); let input = Input::from(request); handler.play_only_input(input); } @@ -73,6 +122,11 @@ pub async fn play(ctx: Context<'_>, return Ok(()) } + { + let mut hash_map = ctx.data().playing_info.lock().await; + hash_map.insert(guild_id, PlayingInfoType::RadioInfo(station.clone())); + } + ctx.reply_ephemeral("Done!").await?; Ok(()) } @@ -82,17 +136,7 @@ async fn autocomplete_radio( partial: &str ) -> Vec { - let api = { - match &ctx.data().radio_browser { - Some(v) => v.clone(), - None => { - let Ok(radio_browser) = RadioBrowserAPI::new().await else { - return vec!["There was an error with radio API!".to_owned()] - }; - radio_browser - } - } - }; + let api = &ctx.data().radio_browser; let stations: Vec = match api.get_stations() .limit(MAX_ENTRIES) @@ -100,14 +144,14 @@ async fn autocomplete_radio( .hidebroken(true) .send().await { Ok(stations) => { - stations.iter().map(|station| - format!("Name: {} Language: {} Bitrate: {}", station.name, station.countrycode, station.bitrate) + stations.iter().map(|station: &radiobrowser::ApiStation| + format!("Name: {} Country: {} Language: {}", station.name, station.country, station.language) ).collect() }, Err(_) => return vec!["".to_owned()] }; - dbg!(&stations); + // dbg!(&stations); return stations // return vec![]; @@ -118,7 +162,11 @@ async fn autocomplete_radio( // search with buttons and list in embed with direct links to stream // embed showing currently playing song +// use trace::trace; +// trace::init_depth_var!(); + // TODO: autocomplete radio stream URLs +// #[trace] #[poise::command( slash_command, description_localized("en-US", "Search for a radio") @@ -129,27 +177,23 @@ pub async fn search(ctx: Context<'_>, #[description = "Country: "] country: Option, #[description = "Tag: "] - tag: Option + tag: Option, + #[description = "Number of entries on page: "] + entries: Option ) -> Result<(), Error> { - let api = match &ctx.data().radio_browser { - Some(v) => v.clone(), - None => { - let Ok(radio_browser) = RadioBrowserAPI::new().await else { - let _ = ctx.reply_ephemeral("There was an error with radio API!").await; - return Ok(()) - }; - radio_browser - } - }; + let api = &ctx.data().radio_browser; - let station_search_builder = api.get_stations() + let entries = entries.unwrap_or(NumberOfEntries::Ten); + + let search_builder = api.get_stations() + .limit(entries.name()) .name(name) .tag(tag.unwrap_or("".to_owned())) .country(country.unwrap_or("".to_owned())) .hidebroken(true); - paginate_search(ctx, station_search_builder); + paginate_search_stations(&ctx, &search_builder, entries).await?; Ok(()) } diff --git a/src/commands/voice/radio/radio_utils.rs b/src/commands/voice/radio/radio_utils.rs index de1832c..9889928 100644 --- a/src/commands/voice/radio/radio_utils.rs +++ b/src/commands/voice/radio/radio_utils.rs @@ -1,83 +1,144 @@ -use poise::{ChoiceParameter, CreateReply}; -use radiobrowser::StationSearchBuilder; -use serenity::{all::{CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage}, futures::SinkExt}; +use poise::CreateReply; +use radiobrowser::{ApiStation, StationSearchBuilder}; +use regex::Regex; +use serenity::all::{CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseMessage}; -use crate::types::Context; +use crate::{commands::voice_types::NumberOfEntries, types::Context}; -#[derive(ChoiceParameter)] -pub enum WelcomeChoice { - #[name = "5"] - A, - #[name = "10"] - B, - #[name = "15"] - C -} - -pub async fn paginate_search( - ctx: Context<'_>, - search_builder: StationSearchBuilder, +pub async fn paginate_search_stations( + ctx: &Context<'_>, + search_builder: &StationSearchBuilder, + limit: NumberOfEntries ) -> Result<(), serenity::Error> { // Define some unique identifiers for the navigation buttons let ctx_id = ctx.id(); let prev_button_id = format!("{}prev", ctx_id); let next_button_id = format!("{}next", ctx_id); - - let Ok(stations) = search_builder.send().await else { - let _ = ctx.reply("Something went wrong, try searching again").await; + + let search_builder = search_builder; + + let Ok(stations) = search_builder.clone().send().await else { + ctx.reply("Something went wrong, try searching again").await?; return Ok(()) }; - + + let mut page = 0; + + let embed = create_station_list_embed(&stations, page); + // Send the embed with the first page as content let reply = { let components = CreateActionRow::Buttons(vec![ CreateButton::new(&prev_button_id).emoji('◀'), CreateButton::new(&next_button_id).emoji('▶'), - ]); - - CreateReply::default() - .embed(CreateEmbed::new()) + ]); + + CreateReply::default() + .embed(embed) .components(vec![components]) - }; - - ctx.send(reply).await?; - - // Loop through incoming interactions with the navigation buttons - let mut current_page = 0; - while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) + }; + + ctx.send(reply).await?; + + // Loop through incoming interactions with the navigation buttons + let mut offset = 0; + let limit_int = limit as u32; + while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) // We defined our button IDs to start with `ctx_id`. If they don't, some other command's // button was pressed .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) // Timeout when no navigation button has been pressed for 24 hours .timeout(std::time::Duration::from_secs(3600 * 24)) .await - { - // Depending on which button was pressed, go to next or previous page + { + // Depending on which button was pressed, go to next or previous page if press.data.custom_id == next_button_id { - current_page += 1; - // TODO find a way to check end - // if current_page >= pages.len() { - // current_page = 0; - // } + offset += limit_int; + page += 1; } else if press.data.custom_id == prev_button_id { - // TODO find a way to get pages len - // current_page = current_page.checked_sub(1).unwrap_or(pages.len() - 1); + offset = if offset < limit_int { offset } else { offset - limit_int }; + page = if page == 0 { 0 } else { page - 1 }; } else { // This is an unrelated button interaction continue; } + let Ok(mut stations) = search_builder.clone().offset(offset.to_string()).send().await else { + ctx.reply("Something went wrong, try searching again").await?; + return Ok(()) + }; + + if stations.is_empty() { + offset = 0; + page = 0; + + let Ok(new_stations) = search_builder.clone().offset(offset.to_string()).send().await else { + ctx.reply("Something went wrong, try searching again").await?; + return Ok(()) + }; + stations = new_stations; + } + + let embed = create_station_list_embed(&stations, page); + // Update the message with the new page contents press - .create_response( - ctx.serenity_context(), - CreateInteractionResponse::UpdateMessage( - CreateInteractionResponseMessage::new() - .embed(CreateEmbed::new()), - ), - ) - .await?; - } - - Ok(()) + .create_response( + ctx.serenity_context(), + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(embed), + ), + ) + .await?; +} + +Ok(()) +} + +fn create_station_list_embed(stations: &Vec, page: u32) -> CreateEmbed { + let result = CreateEmbed::new() + .fields( + stations.iter().map(|station| { + ( + station.name.clone(), + format!("Country: {} Stream: {}", + station.country, station.url), + false + ) + }) + ) + .footer(CreateEmbedFooter::new(format!("Page: {}", page + 1))); + + result +} + +pub enum LinkString { + Link, + String +} + + +pub fn link_or_string(haystack: &str) -> LinkString { + let Ok(re) = Regex::new(r"^https?://([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$") else { + panic!("Wrong regex expression!"); + }; + + return if re.is_match(haystack) { LinkString::Link } else { LinkString::String } +} + +pub fn parse_radio_autocomplete(haystack: &str) -> Option<(String, String, String)> { + let Ok(re) = Regex::new(r"^Name: (.*) Country: (.*) Language: (.*)") else { + panic!("Wrong regex expression!"); + }; + + let Some(captures) = re.captures(haystack) else { + return None + }; + + return Some(( + String::from(&captures[1]), + String::from(&captures[2]), + String::from(&captures[3]) + )) } diff --git a/src/commands/voice/voice_types.rs b/src/commands/voice/voice_types.rs new file mode 100644 index 0000000..60fe865 --- /dev/null +++ b/src/commands/voice/voice_types.rs @@ -0,0 +1,91 @@ +use poise::ChoiceParameter; +use radiobrowser::ApiStation; +use serenity::{all::CreateEmbed, async_trait}; + +use crate::utils::utilities::hash_to_u32; + +#[derive(ChoiceParameter, Debug)] +pub enum NumberOfEntries { + #[name = "5"] + Five = 5, + #[name = "10"] + Ten = 10, + #[name = "15"] + Fifteen = 15 +} + +#[async_trait] +pub trait GenerateEmbed { + async fn generate_embed(&self) -> CreateEmbed; +} + +#[allow(dead_code)] +pub struct SpotifyInfo { + pub name: String, + pub interpret: String, + // client + pub duration: String, +} + +#[allow(dead_code)] +pub struct GeneralInfo { + pub name: String, + pub site: String, + // client + pub duration: Option +} + + +#[async_trait] +impl GenerateEmbed for GeneralInfo { + async fn generate_embed(&self) -> CreateEmbed { + CreateEmbed::new() + } +} + +#[async_trait] +impl GenerateEmbed for SpotifyInfo { + async fn generate_embed(&self) -> CreateEmbed { + CreateEmbed::new() + } +} + +#[async_trait] +impl GenerateEmbed for ApiStation { + async fn generate_embed(&self) -> CreateEmbed { + let embed= CreateEmbed::new() + .title("Current station:") + .thumbnail(&self.favicon) + .field("Name:", &self.name, true) + .field("Country:", &self.country, true) + .field("Language:", &self.language, true) + .field("Website:", &self.homepage, true) + .field("Stream:", &self.url, true) + .field("Tags:", &self.tags, false) + .color(hash_to_u32(&self.stationuuid)); + + embed + } +} + +// pub struct PlayingInfo { +// pub data: T, +// pub create_embed: Box CreateEmbed + Send + Sync> +// } + +#[allow(dead_code)] +pub enum PlayingInfoType { + SpotifyInfo(SpotifyInfo), + RadioInfo(ApiStation), + GeneralInfo(GeneralInfo) +} + +impl PlayingInfoType { + pub async fn generate_embed(&self) -> CreateEmbed { + match self { + PlayingInfoType::SpotifyInfo(v) => v.generate_embed().await, + PlayingInfoType::RadioInfo(v) => v.generate_embed().await, + PlayingInfoType::GeneralInfo(v) => v.generate_embed().await + } + } +} diff --git a/src/commands/voice/voice_utils.rs b/src/commands/voice/voice_utils.rs index d9e4a8c..45f490b 100644 --- a/src/commands/voice/voice_utils.rs +++ b/src/commands/voice/voice_utils.rs @@ -1,5 +1,4 @@ -use poise::{CreateReply}; -use serenity::all::{ChannelId, CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, Embed, GuildId}; +use serenity::all::{ChannelId, GuildId}; use serenity::async_trait; use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; diff --git a/src/main.rs b/src/main.rs index a36e45a..417f31c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -6,6 +7,7 @@ use std::error; use std::env; use serenity::async_trait; +use serenity::futures::lock::Mutex; use serenity::prelude::GatewayIntents; use serenity::client::Context; use serenity::model::gateway::Ready; @@ -19,6 +21,8 @@ use dotenv::dotenv; use songbird::SerenityInit; use tokio_cron_scheduler::{JobScheduler, Job}; +use radiobrowser::RadioBrowserAPI; + mod message_handler; use message_handler::handle; @@ -90,6 +94,7 @@ async fn main() -> anyhow::Result<()> { commands::radio::radio(), commands::general_player::play(), commands::player_common::disconnect(), + commands::player_common::playing() ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -131,7 +136,8 @@ async fn main() -> anyhow::Result<()> { Ok(Data { http_client: HttpClient::new(), - radio_browser: RadioBrowserAPI::new().await.ok() + radio_browser: RadioBrowserAPI::new().await?, + playing_info: Mutex::new(HashMap::new()) }) }) }) diff --git a/src/types.rs b/src/types.rs index 068d8e4..00ef4f9 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,23 +1,37 @@ -// pub struct Data {} - -use std::{ffi::OsString, path::PathBuf}; +use std::collections::HashMap; +use poise::CreateReply; use radiobrowser::RadioBrowserAPI; use reqwest::Client as HttpClient; +use serenity::{all::{futures::lock::Mutex, GuildId}, async_trait}; + +use crate::commands::voice_types::PlayingInfoType; // TODO: this should be probably expanded also to be used by multiple servers and commands // radio and spotify commands always create new HttpClient - pub struct Data { pub http_client: HttpClient, - pub radio_browser: Option, + pub radio_browser: RadioBrowserAPI, + pub playing_info: Mutex> } pub type Error = Box; // replace () with Data if you ever need to store some additional data pub type Context<'a> = poise::Context<'a, Data, Error>; -pub struct Track { - pub path: PathBuf, - pub name: OsString, +/// 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/utils.rs b/src/utils.rs index c23251b..fabda60 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ +pub use utilities::*; + pub mod debug; pub mod utilities; pub mod gifs; -pub mod poise_context_extension; diff --git a/src/utils/poise_context_extension.rs b/src/utils/poise_context_extension.rs deleted file mode 100644 index 39a9475..0000000 --- a/src/utils/poise_context_extension.rs +++ /dev/null @@ -1,22 +0,0 @@ -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/utils/utilities.rs b/src/utils/utilities.rs index c36912b..f24e427 100644 --- a/src/utils/utilities.rs +++ b/src/utils/utilities.rs @@ -1,7 +1,13 @@ -use std::{fs, io, path::Path, sync::Arc, vec}; +use std::{fs, hash::{DefaultHasher, Hash, Hasher}, io, path::Path, sync::Arc, vec}; use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildId, GuildRef, Message}, http::Http}; +use poise::CreateReply; +use serenity::async_trait; + +use crate::types::Context; + + 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 @@ -58,3 +64,10 @@ pub fn get_local_songs(partial: &str) -> io::Result> { Ok(result) } + +pub fn hash_to_u32(from: &T) -> u32 { + let mut hasher = DefaultHasher::new(); + from.hash(&mut hasher); + let hash_val = hasher.finish(); + (hash_val & 0xFFFFFF) as u32 +}