diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5d647f9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/spotify-parser"] + path = src/spotify-parser + url = https://github.com/eRgo35/spotify-parser diff --git a/Cargo.lock b/Cargo.lock index 60deb83..c23785e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" + [[package]] name = "lazy_static" version = "1.4.0" @@ -963,11 +969,14 @@ version = "0.6.0" dependencies = [ "dotenv", "fancy-regex", + "json", "openssl", "poise", "rand", "regex", "reqwest", + "serde", + "serde_json", "serenity", "songbird", "symphonia", @@ -975,6 +984,7 @@ dependencies = [ "tracing", "tracing-futures", "tracing-subscriber", + "url", ] [[package]] @@ -1802,9 +1812,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] @@ -1832,9 +1842,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", @@ -1843,9 +1853,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.113" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ "itoa", "ryu", diff --git a/Cargo.toml b/Cargo.toml index 95d9ee0..4532edb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lyra" -version = "0.6.0" +version = "0.7.0" authors = ["Michał Czyż "] edition = "2021" description = "A featureful Discord bot written in Rust." @@ -14,11 +14,14 @@ keywords = ["discord", "bot", "rust", "music", "featureful"] [dependencies] dotenv = "0.15.0" fancy-regex = "0.13.0" +json = "0.12.4" openssl = { version = "0.10.63", features = ["vendored"] } poise = "0.6.1" rand = "0.8.5" regex = "1.10.3" -reqwest = "0.11.23" +reqwest = { version = "0.11.23", features = ["json"]} +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.114" serenity = { version = "0.12.0", features = ["cache", "framework", "standard_framework", "voice"] } songbird = { version = "0.4.0", features = ["builtin-queue", "serenity"] } symphonia = { version = "0.5.3", features = ["aac", "adpcm", "alac", "flac", "mpa", "isomp4"] } @@ -26,3 +29,4 @@ tokio = { version = "1.35.1", features = ["macros", "full", "signal"] } tracing = "0.1.40" tracing-futures = "0.2.5" tracing-subscriber = "0.3.18" +url = "2.5.0" diff --git a/src/commands/music/play.rs b/src/commands/music/play.rs index 58f9d08..c112399 100644 --- a/src/commands/music/play.rs +++ b/src/commands/music/play.rs @@ -42,9 +42,12 @@ pub async fn play( r"^((?:https?:)\/\/)?((?:www|m)\.)?((?:youtube\.com)).*(youtu.be\/|list=)([^#&?]*).*", ) .unwrap(); + let regex_spotify_playlist = Regex::new(r"https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:(album|playlist)\/|\?uri=spotify:playlist:)((\w|-)+)(?:(?=\?)(?:[?&]foo=(\d*)(?=[&#]|$)|(?![?&]foo=)[^#])+)?(?=#|$)").unwrap(); - let is_playlist = regex_youtube_playlist.is_match(&song).unwrap(); - let is_spotify = regex_spotify.is_match(&song).unwrap(); + let is_playlist = regex_youtube_playlist.is_match(&song).unwrap() + || regex_spotify_playlist.is_match(&song).unwrap(); + let is_spotify = + regex_spotify.is_match(&song).unwrap() || regex_spotify_playlist.is_match(&song).unwrap(); let is_query = !song.starts_with("http"); let guild_id = ctx.guild_id().unwrap(); @@ -83,6 +86,43 @@ pub async fn play( handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); + if is_playlist && is_spotify { + let raw_list = Command::new("node") + .args(["./src/spotify-parser", &song]) + .output() + .expect("failed to execute process") + .stdout; + let list = String::from_utf8(raw_list.clone()).expect("Invalid UTF-8"); + + let tracks: Vec = list.split("\n").map(str::to_string).collect(); + + for (index, url) in tracks.clone().iter().enumerate() { + if url.is_empty() { + break; + } + let src = YoutubeDl::new_ytdl_like( + "yt-dlp", + http_client.clone(), + format!("ytsearch:{}", url.to_string()), + ); + let aux_metadata = src.clone().aux_metadata().await.unwrap(); + let track = handler.enqueue_input(src.clone().into()).await; + let _ = track + .typemap() + .write() + .await + .insert::(aux_metadata); + + if index == 0 { + let embed = generate_playlist_embed(ctx, track, tracks.len()).await; + let response = CreateReply::default().embed(embed.unwrap()); + ctx.send(response).await?; + } + } + + return Ok(()); + } + if is_playlist { let raw_list = Command::new("yt-dlp") .args(["-j", "--flat-playlist", &song]) @@ -98,6 +138,9 @@ pub async fn play( .collect(); for (index, url) in urls.clone().iter().enumerate() { + if url.is_empty() { + break; + } let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client.clone(), url.to_string()); let aux_metadata = src.clone().aux_metadata().await.unwrap(); let track = handler.enqueue_input(src.clone().into()).await; @@ -113,37 +156,37 @@ pub async fn play( ctx.send(response).await?; } } - } else { - if is_spotify { - let exec = format!("node ./src/spotify --url {}", song); - let query = Command::new("sh") - .arg("-c") - .arg(exec) - .output() - .expect("failed to execute process") - .stdout; - let query_str = String::from_utf8(query.clone()).expect("Invalid UTF-8"); - song = format!("ytsearch:{}", query_str.to_string()); - } - if is_query { - song = format!("ytsearch:{}", song); - } - - let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client, song); - let embed = generate_embed(ctx, src.clone(), handler.queue()).await; - let response = CreateReply::default().embed(embed.unwrap()); - ctx.send(response).await?; - - let aux_metadata = src.clone().aux_metadata().await.unwrap(); - - let track = handler.enqueue_input(src.clone().into()).await; - let _ = track - .typemap() - .write() - .await - .insert::(aux_metadata); + return Ok(()); } + + if is_spotify { + let query = Command::new("node") + .args(["./src/spotify-parser", &song]) + .output() + .expect("failed to execute process") + .stdout; + let query_str = String::from_utf8(query.clone()).expect("Invalid UTF-8"); + song = format!("ytsearch:{}", query_str.to_string()); + } + + if is_query { + song = format!("ytsearch:{}", song); + } + + let src = YoutubeDl::new_ytdl_like("yt-dlp", http_client, song); + let embed = generate_embed(ctx, src.clone(), handler.queue()).await; + let response = CreateReply::default().embed(embed.unwrap()); + ctx.send(response).await?; + + let aux_metadata = src.clone().aux_metadata().await.unwrap(); + + let track = handler.enqueue_input(src.clone().into()).await; + let _ = track + .typemap() + .write() + .await + .insert::(aux_metadata); } Ok(()) @@ -166,7 +209,7 @@ async fn generate_embed( let timestamp = Timestamp::now(); let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60; let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60; - let mut description = format!("Song added to queue @ {}", queue.len()); + let mut description = format!("Song added to queue @ {}", queue.len() + 1); if queue.len() == 0 { description = format!("Playing now!"); diff --git a/src/commands/music/queue.rs b/src/commands/music/queue.rs index 3603c11..f42b1f0 100644 --- a/src/commands/music/queue.rs +++ b/src/commands/music/queue.rs @@ -10,6 +10,8 @@ use serenity::{ }; use songbird::input::AuxMetadata; +const QUEUE_DISPLAY_LENGTH: usize = 10; + /// Shows next tracks in queue; \ /// aliases: queue, q #[poise::command(prefix_command, slash_command, aliases("q"), category = "Music")] @@ -25,8 +27,9 @@ pub async fn queue(ctx: Context<'_>) -> Result<(), Error> { let handler = handler_lock.lock().await; let queue = handler.queue(); let mut queue_res = String::from(""); + let mut too_long = false; - for (index, song) in queue.current_queue().iter().enumerate() { + for (index, song) in queue.clone().current_queue().iter().enumerate() { let meta_typemap = song.typemap().read().await; let metadata = meta_typemap.get::().unwrap(); let AuxMetadata { @@ -39,8 +42,6 @@ pub async fn queue(ctx: Context<'_>) -> Result<(), Error> { let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60; let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60; - // println!("{:?}", metadata.clone()); - queue_res.push_str(&format!( "{}. {} - {} [{:02}:{:02}] \n", index, @@ -49,6 +50,17 @@ pub async fn queue(ctx: Context<'_>) -> Result<(), Error> { duration_minutes, duration_seconds )); + + if index + 1 == QUEUE_DISPLAY_LENGTH { + too_long = true; + break; + } + } + if too_long { + queue_res.push_str(&format!( + "and {} more...", + queue.len() - QUEUE_DISPLAY_LENGTH + )); } ctx.send(CreateReply::default().embed(embed(ctx, queue_res).await.unwrap())) diff --git a/src/commands/music/skip.rs b/src/commands/music/skip.rs index 49cd4ad..629ca29 100644 --- a/src/commands/music/skip.rs +++ b/src/commands/music/skip.rs @@ -1,8 +1,16 @@ +use crate::commands::music::metadata::Metadata; +use std::time::Duration; + use crate::{ commands::embeds::{embed, error_embed}, Context, Error, }; use poise::CreateReply; +use serenity::{ + builder::{CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter}, + model::{Colour, Timestamp}, +}; +use songbird::{input::AuxMetadata, tracks::TrackHandle}; /// Skips the currently playing song #[poise::command(prefix_command, slash_command, category = "Music")] @@ -17,21 +25,31 @@ pub async fn skip(ctx: Context<'_>) -> Result<(), Error> { if let Some(handler_lock) = manager.get(guild_id) { let handler = handler_lock.lock().await; let queue = handler.queue(); - let _ = queue.skip(); + let _ = queue.clone().skip(); + let track_raw = queue.clone().current_queue(); + let track = track_raw.get(1); + let queue_length = queue.len() - 1; - ctx.send( - CreateReply::default().embed( - embed( - ctx, - "Skipped!", - "Next song: {song}", - &format!("Songs left in queue: {}", queue.len()), - ) - .await - .unwrap(), - ), - ) - .await?; + let response; + + match track { + Some(track) => { + response = CreateReply::default().embed( + generate_embed(ctx, track.clone(), queue_length) + .await + .unwrap(), + ); + } + None => { + response = CreateReply::default().embed( + embed(ctx, "Skipped!", "The queue is empty!", "") + .await + .unwrap(), + ); + } + }; + + ctx.send(response).await?; } else { let msg = "I am not in a voice channel!"; ctx.send(CreateReply::default().embed(error_embed(ctx, msg).await.unwrap())) @@ -40,3 +58,55 @@ pub async fn skip(ctx: Context<'_>) -> Result<(), Error> { Ok(()) } + +async fn generate_embed( + ctx: Context<'_>, + track: TrackHandle, + queue_length: usize, +) -> Result { + let meta_typemap = track.typemap().read().await; + let metadata = meta_typemap.get::().unwrap(); + let AuxMetadata { + title, + thumbnail, + source_url, + artist, + duration, + .. + } = metadata; + let timestamp = Timestamp::now(); + let duration_minutes = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() / 60; + let duration_seconds = duration.unwrap_or(Duration::new(0, 0)).clone().as_secs() % 60; + + let description = format!("Song skipped! Queue length is {}", queue_length); + + let embed = CreateEmbed::default() + .author(CreateEmbedAuthor::new("Skipped!").icon_url(ctx.author().clone().face())) + .colour(Colour::from_rgb(255, 58, 97)) + .title(title.as_ref().unwrap()) + .url(source_url.as_ref().unwrap()) + .thumbnail( + thumbnail + .as_ref() + .unwrap_or(&ctx.cache().current_user().face()), + ) + .field( + "Artist", + artist.as_ref().unwrap_or(&"Unknown Artist".to_string()), + true, + ) + .field( + "Duration", + format!("{:02}:{:02}", duration_minutes, duration_seconds), + true, + ) + .field("DJ", ctx.author().name.clone(), true) + .description(description) + .timestamp(timestamp) + .footer( + CreateEmbedFooter::new(ctx.cache().current_user().name.to_string()) + .icon_url(ctx.cache().current_user().face()), + ); + + Ok(embed) +} diff --git a/src/commands/tools/qr.rs b/src/commands/tools/qr.rs index a27bfba..1e95036 100644 --- a/src/commands/tools/qr.rs +++ b/src/commands/tools/qr.rs @@ -1,12 +1,48 @@ use poise::CreateReply; +use serenity::{ + builder::{CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter}, + model::{Colour, Timestamp}, +}; -use crate::{commands::embeds::embed, Context, Error}; +use crate::{Context, Error}; +use url::form_urlencoded; /// Creates a qr code from text #[poise::command(prefix_command, slash_command, category = "Tools")] -pub async fn qr(ctx: Context<'_>) -> Result<(), Error> { - ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) - .await?; +pub async fn qr( + ctx: Context<'_>, + #[description = "Message to encode"] + #[rest] + message: String, +) -> Result<(), Error> { + let response = CreateReply::default().embed(generate_embed(ctx, message).await.unwrap()); + ctx.send(response).await?; Ok(()) } + +async fn generate_embed(ctx: Context<'_>, message: String) -> Result { + let timestamp = Timestamp::now(); + let data: String = form_urlencoded::byte_serialize(message.as_bytes()).collect(); + let url = format!( + "http://api.qrserver.com/v1/create-qr-code/?data={}&size=1000x1000&ecc=Q&margin=8", + data + ); + + let embed = CreateEmbed::default() + .author( + CreateEmbedAuthor::new("Your message as a QR Code!") + .icon_url(ctx.author().clone().face()), + ) + .colour(Colour::from_rgb(255, 58, 97)) + .title("Your QR Code:") + .url(url.clone()) + .image(url) + .timestamp(timestamp) + .footer( + CreateEmbedFooter::new(ctx.cache().current_user().name.to_string()) + .icon_url(ctx.cache().current_user().face()), + ); + + Ok(embed) +} diff --git a/src/commands/tools/verse.rs b/src/commands/tools/verse.rs index 8598c73..83860f5 100644 --- a/src/commands/tools/verse.rs +++ b/src/commands/tools/verse.rs @@ -1,12 +1,78 @@ +use crate::{ + commands::embeds::{embed, error_embed}, + Context, Error, +}; use poise::CreateReply; - -use crate::{commands::embeds::embed, Context, Error}; +use serde::{Deserialize, Serialize}; +use url::form_urlencoded; /// Reference Bible by verse #[poise::command(prefix_command, slash_command, category = "Tools")] -pub async fn verse(ctx: Context<'_>) -> Result<(), Error> { - ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) - .await?; +pub async fn verse( + ctx: Context<'_>, + #[description = "Latin?"] + #[flag] + latin: bool, + #[description = "BOOK+CHAPTER:VERSE"] + #[rest] + verse: String, +) -> Result<(), Error> { + let data: String = form_urlencoded::byte_serialize(verse.as_bytes()).collect(); + let translation = if latin { "clementine" } else { "web" }; + let client = reqwest::Client::new(); + let response = client + .get(format!( + "https://bible-api.com/{}?translation={}", + data, translation + )) + .send() + .await + .unwrap(); + + match response.status() { + reqwest::StatusCode::OK => { + match response.json::().await { + Ok(parsed) => { + if parsed.text.len() > 4000 { + ctx.send( + CreateReply::default() + .embed(error_embed(ctx, "Quoted text is too long!").await.unwrap()), + ) + .await?; + return Ok(()); + } + ctx.send( + CreateReply::default().embed( + embed( + ctx, + &parsed.translation_name, + &parsed.text, + &parsed.reference, + ) + .await + .unwrap(), + ), + ) + .await?; + } + Err(err) => println!("Something is messed up! {:?}", err), + }; + } + reqwest::StatusCode::UNAUTHORIZED => { + println!("Unauthorized.. Uoops!!"); + } + error => { + println!("Something went wrong: {:?}", error); + } + } Ok(()) } + +#[derive(Serialize, Deserialize, Debug)] +struct APIResponse { + reference: String, + text: String, + translation_name: String, + translation_note: String, +} diff --git a/src/commands/tools/weather.rs b/src/commands/tools/weather.rs index 9bf5039..0c1f794 100644 --- a/src/commands/tools/weather.rs +++ b/src/commands/tools/weather.rs @@ -4,7 +4,12 @@ use crate::{commands::embeds::embed, Context, Error}; /// Shows weather for provided location #[poise::command(prefix_command, slash_command, category = "Tools")] -pub async fn weather(ctx: Context<'_>) -> Result<(), Error> { +pub async fn weather( + ctx: Context<'_>, + #[description = "Provide a city name"] + #[rest] + _location: String, +) -> Result<(), Error> { ctx.send(CreateReply::default().embed(embed(ctx, "", "", "").await.unwrap())) .await?; diff --git a/src/spotify b/src/spotify deleted file mode 160000 index ea246e9..0000000 --- a/src/spotify +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ea246e9ed2c85e6a4256d73f4f3f0881f9f3f293 diff --git a/src/spotify-parser b/src/spotify-parser new file mode 160000 index 0000000..e3b3c0f --- /dev/null +++ b/src/spotify-parser @@ -0,0 +1 @@ +Subproject commit e3b3c0fb6ea6a874d7b35bf953f35421d94a0457