From d530b182b3180d128a0fabea6af5cc7b2ec91030 Mon Sep 17 00:00:00 2001 From: DaXcess Date: Mon, 31 Oct 2022 22:46:34 +0100 Subject: [PATCH] Uhm so yeah here's some c o d e --- .dockerignore | 5 + .gitignore | 7 +- Cargo.lock | 34 ++- Cargo.toml | 8 +- Dockerfile | 23 ++ LICENSE | 201 +++++++++++++++ README.md | 3 + src/audio/backend.rs | 2 +- src/bot/commands/core/help.rs | 34 +++ src/bot/commands/core/link.rs | 120 +++++---- src/bot/commands/core/mod.rs | 3 + src/bot/commands/core/rename.rs | 165 ++++++++++++ src/bot/commands/core/unlink.rs | 82 +++--- src/bot/commands/core/version.rs | 49 ++++ src/bot/commands/mod.rs | 47 +++- src/bot/commands/music/join.rs | 191 +++++++++----- src/bot/commands/music/leave.rs | 93 +++---- src/bot/commands/music/mod.rs | 1 + src/bot/commands/music/playing.rs | 170 ++++++++++++ src/bot/events.rs | 8 +- src/bot/mod.rs | 1 + src/database.rs | 86 +++++++ src/ipc/packet.rs | 12 + src/main.rs | 78 +++++- src/player/mod.rs | 94 +++++-- src/session/manager.rs | 39 ++- src/session/mod.rs | 411 ++++++++++++++++++++++++++++-- src/stats/mod.rs | 26 ++ src/utils/consts.rs | 3 + src/utils/discord.rs | 10 + src/utils/embed.rs | 99 +++++++ src/utils/mod.rs | 28 ++ src/utils/spotify.rs | 99 +++++++ 33 files changed, 1934 insertions(+), 298 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/bot/commands/core/help.rs create mode 100644 src/bot/commands/core/rename.rs create mode 100644 src/bot/commands/core/version.rs create mode 100644 src/bot/commands/music/playing.rs create mode 100644 src/stats/mod.rs create mode 100644 src/utils/consts.rs create mode 100644 src/utils/discord.rs create mode 100644 src/utils/embed.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ade9286 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target/ +.env +.gitignore +.dockerignore +Dockerfile \ No newline at end of file diff --git a/.gitignore b/.gitignore index d04cad5..924b3f5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,9 @@ *.sqlite # Secrets -.env \ No newline at end of file +.env + +# Editors +.vscode +.vs +.fleet \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 485151c..51dda63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,16 @@ dependencies = [ "cc", ] +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "command_attr" version = "0.4.1" @@ -1897,6 +1907,20 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "redis" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513b3649f1a111c17954296e4a3b9eecb108b766c803e2b99f179ebe27005985" +dependencies = [ + "combine", + "itoa", + "percent-encoding", + "ryu", + "sha1_smol", + "url", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -2287,6 +2311,12 @@ dependencies = [ "digest 0.10.3", ] +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "shannon" version = "0.2.0" @@ -2395,7 +2425,7 @@ dependencies = [ [[package]] name = "spoticord" -version = "2.0.0-indev" +version = "2.0.0-beta" dependencies = [ "chrono", "dotenv", @@ -2403,12 +2433,12 @@ dependencies = [ "ipc-channel", "librespot", "log", + "redis", "reqwest", "samplerate", "serde", "serde_json", "serenity", - "shell-words", "songbird", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 5b99233..81067b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "spoticord" -version = "2.0.0-indev" +version = "2.0.0-beta" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[[bin]] +name = "spoticord" +path = "src/main.rs" [profile.release] lto = true @@ -18,12 +20,12 @@ env_logger = "0.9.0" ipc-channel = { version = "0.16.0", features = ["async"] } librespot = { version = "0.4.2", default-features = false } log = "0.4.17" +redis = "0.22.1" reqwest = "0.11.11" samplerate = "0.2.4" serde = "1.0.144" serde_json = "1.0.85" serenity = { version = "0.11.5", features = ["voice"] } -shell-words = "1.1.0" songbird = "0.3.0" thiserror = "1.0.33" tokio = { version = "1.20.1", features = ["rt", "full"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..817e333 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Builder +FROM rust:1.62-buster as builder + +WORKDIR /app + +# Add extra build dependencies here +RUN apt-get update && apt-get install -y cmake + +COPY . . +RUN cargo install --path . + +# Runtime +FROM debian:buster-slim + +WORKDIR /app + +# Add extra runtime dependencies here +RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* + +# Copy spoticord binary from builder +COPY --from=builder /usr/local/cargo/bin/spoticord ./spoticord + +CMD ["./spoticord"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6adda8 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Spoticord + +Spoticord is a Discord music bot that allows you to control your music using the Spotify app. \ No newline at end of file diff --git a/src/audio/backend.rs b/src/audio/backend.rs index 328037f..583edf6 100644 --- a/src/audio/backend.rs +++ b/src/audio/backend.rs @@ -154,7 +154,7 @@ impl SinkAsBytes for StdoutSink { buffer.len() }; - while get_buffer_len() > BUFFER_SIZE * 5 { + while get_buffer_len() > BUFFER_SIZE * 2 { std::thread::sleep(Duration::from_millis(15)); } diff --git a/src/bot/commands/core/help.rs b/src/bot/commands/core/help.rs new file mode 100644 index 0000000..071113f --- /dev/null +++ b/src/bot/commands/core/help.rs @@ -0,0 +1,34 @@ +use serenity::{ + builder::CreateApplicationCommand, + model::prelude::interaction::application_command::ApplicationCommandInteraction, + prelude::Context, +}; + +use crate::{ + bot::commands::{respond_message, CommandOutput}, + utils::embed::{EmbedBuilder, Status}, +}; + +pub const NAME: &str = "help"; + +pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { + Box::pin(async move { + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Spoticord Help") + .icon_url("https://spoticord.com/img/logo-standard.webp") + .description(format!("Click **[here](https://spoticord.com/commands)** for a list of commands.\n{}", + "If you need help setting Spoticord up you can check out the **[Documentation](https://spoticord.com/documentation)** page on the Spoticord website.\n\n")) + .status(Status::Info) + .build(), + false, + ) + .await; + }) +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name(NAME).description("Shows the help message") +} diff --git a/src/bot/commands/core/link.rs b/src/bot/commands/core/link.rs index c4dd16f..403f8e0 100644 --- a/src/bot/commands/core/link.rs +++ b/src/bot/commands/core/link.rs @@ -1,53 +1,34 @@ use log::error; use serenity::{ builder::CreateApplicationCommand, - model::prelude::interaction::{ - application_command::ApplicationCommandInteraction, InteractionResponseType, - }, + model::prelude::interaction::application_command::ApplicationCommandInteraction, prelude::Context, - Result as SerenityResult, }; -use crate::{bot::commands::CommandOutput, database::Database}; +use crate::{ + bot::commands::{respond_message, CommandOutput}, + database::Database, + utils::embed::{EmbedBuilder, Status}, +}; pub const NAME: &str = "link"; -async fn respond_message( - ctx: &Context, - command: &ApplicationCommandInteraction, - msg: impl Into, - ephemeral: bool, -) -> SerenityResult<()> { - command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| message.content(msg.into()).ephemeral(ephemeral)) - }) - .await -} - -fn check_msg(result: SerenityResult<()>) { - if let Err(why) = result { - error!("Error sending message: {:?}", why); - } -} - pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; let database = data.get::().unwrap(); if let Ok(_) = database.get_user_account(command.user.id.to_string()).await { - check_msg( - respond_message( - &ctx, - &command, - "You have already linked your Spotify account.", - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("You have already linked your Spotify account.") + .status(Status::Error) + .build(), + true, + ) + .await; return; } @@ -56,15 +37,22 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap(); let link = format!("{}/spotify/{}", base, request.token); - check_msg( - respond_message( - &ctx, - &command, - format!("Go to the following URL to link your account:\n{}", link), - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Link your Spotify account") + .title_url(&link) + .icon_url("https://spoticord.com/img/spotify-logo.png") + .description(format!( + "Go to [this link]({}) to connect your Spotify account.", + link + )) + .status(Status::Info) + .build(), + true, + ) + .await; return; } @@ -77,30 +65,38 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap(); let link = format!("{}/spotify/{}", base, request.token); - check_msg( - respond_message( - &ctx, - &command, - format!("Go to the following URL to link your account:\n{}", link), - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Link your Spotify account") + .title_url(&link) + .icon_url("https://spoticord.com/img/spotify-logo.png") + .description(format!( + "Go to [this link]({}) to connect your Spotify account.", + link + )) + .status(Status::Info) + .build(), + true, + ) + .await; return; } Err(why) => { error!("Error creating user request: {:?}", why); - check_msg( - respond_message( - &ctx, - &command, - "An error occurred while serving your request. Please try again later.", - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("An error occurred while serving your request. Please try again later.") + .status(Status::Error) + .build(), + true, + ) + .await; return; } diff --git a/src/bot/commands/core/mod.rs b/src/bot/commands/core/mod.rs index c48030d..39aa8fd 100644 --- a/src/bot/commands/core/mod.rs +++ b/src/bot/commands/core/mod.rs @@ -1,2 +1,5 @@ +pub mod help; pub mod link; +pub mod rename; pub mod unlink; +pub mod version; diff --git a/src/bot/commands/core/rename.rs b/src/bot/commands/core/rename.rs new file mode 100644 index 0000000..a82dd54 --- /dev/null +++ b/src/bot/commands/core/rename.rs @@ -0,0 +1,165 @@ +use log::error; +use reqwest::StatusCode; +use serenity::{ + builder::CreateApplicationCommand, + model::prelude::{ + command::CommandOptionType, interaction::application_command::ApplicationCommandInteraction, + }, + prelude::Context, +}; + +use crate::{ + bot::commands::{respond_message, CommandOutput}, + database::{Database, DatabaseError}, + utils::{ + self, + embed::{EmbedBuilder, Status}, + }, +}; + +pub const NAME: &str = "rename"; + +pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { + Box::pin(async move { + let data = ctx.data.read().await; + let database = data.get::().unwrap(); + + // Check if user exists, if not, create them + if let Err(why) = database.get_user(command.user.id.to_string()).await { + match why { + DatabaseError::InvalidStatusCode(StatusCode::NOT_FOUND) => { + if let Err(why) = database.create_user(command.user.id.to_string()).await { + error!("Error creating user: {:?}", why); + + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("Something went wrong while trying to rename your Spoticord device.") + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + } + + _ => { + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("Something went wrong while trying to rename your Spoticord device.") + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + } + } + + let device_name = match command.data.options.get(0) { + Some(option) => match option.value { + Some(ref value) => value.as_str().unwrap().to_string(), + None => { + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("You need to provide a name for your Spoticord device.") + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + }, + None => { + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("You need to provide a name for your Spoticord device.") + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + }; + + if let Err(why) = database + .update_user_device_name(command.user.id.to_string(), &device_name) + .await + { + if let DatabaseError::InvalidInputBody(_) = why { + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description( + "Your device name must not exceed 16 characters and be at least 1 character long.", + ) + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + + error!("Error updating user device name: {:?}", why); + + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("Something went wrong while trying to rename your Spoticord device.") + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description(format!( + "Successfully changed the Spotify device name to **{}**", + utils::discord::escape(device_name) + )) + .status(Status::Success) + .build(), + true, + ) + .await; + }) +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name(NAME) + .description("Set a new device name that is displayed in Spotify") + .create_option(|option| { + option + .name("name") + .description("The new device name") + .kind(CommandOptionType::String) + .max_length(16) + .required(true) + }) +} diff --git a/src/bot/commands/core/unlink.rs b/src/bot/commands/core/unlink.rs index c403354..415a8c2 100644 --- a/src/bot/commands/core/unlink.rs +++ b/src/bot/commands/core/unlink.rs @@ -1,42 +1,19 @@ use log::error; use serenity::{ builder::CreateApplicationCommand, - model::prelude::interaction::{ - application_command::ApplicationCommandInteraction, InteractionResponseType, - }, + model::prelude::interaction::application_command::ApplicationCommandInteraction, prelude::Context, - Result as SerenityResult, }; use crate::{ - bot::commands::CommandOutput, + bot::commands::{respond_message, CommandOutput}, database::{Database, DatabaseError}, session::manager::SessionManager, + utils::embed::{EmbedBuilder, Status}, }; pub const NAME: &str = "unlink"; -async fn respond_message( - ctx: &Context, - command: &ApplicationCommandInteraction, - msg: impl Into, - ephemeral: bool, -) -> SerenityResult<()> { - command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| message.content(msg.into()).ephemeral(ephemeral)) - }) - .await -} - -fn check_msg(result: SerenityResult<()>) { - if let Err(why) = result { - error!("Error sending message: {:?}", why); - } -} - pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; @@ -45,9 +22,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu // Disconnect session if user has any if let Some(session) = session_manager.find(command.user.id).await { - if let Err(why) = session.disconnect().await { - error!("Error disconnecting session: {:?}", why); - } + session.disconnect().await; } // Check if user exists in the first place @@ -57,15 +32,16 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu { if let DatabaseError::InvalidStatusCode(status) = why { if status == 404 { - check_msg( - respond_message( - &ctx, - &command, - "You cannot unlink your Spotify account if you currently don't have a linked Spotify account.", - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("You cannot unlink your Spotify account if you haven't linked one.") + .status(Status::Error) + .build(), + true, + ) + .await; return; } @@ -73,28 +49,30 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu error!("Error deleting user account: {:?}", why); - check_msg( - respond_message( + respond_message( &ctx, &command, - "An unexpected error has occured while trying to unlink your account. Please try again later.", + EmbedBuilder::new() + .description("An unexpected error has occured while trying to unlink your account. Please try again later.") + .status(Status::Error) + .build(), true, ) - .await, - ); + .await; return; } - check_msg( - respond_message( - &ctx, - &command, - "Successfully unlinked your Spotify account from Spoticord", - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("Successfully unlinked your Spotify account from Spoticord") + .status(Status::Success) + .build(), + true, + ) + .await; }) } diff --git a/src/bot/commands/core/version.rs b/src/bot/commands/core/version.rs new file mode 100644 index 0000000..d7f5aa4 --- /dev/null +++ b/src/bot/commands/core/version.rs @@ -0,0 +1,49 @@ +use log::error; +use serenity::{ + builder::CreateApplicationCommand, + model::prelude::interaction::{ + application_command::ApplicationCommandInteraction, InteractionResponseType, + }, + prelude::Context, +}; + +use crate::{ + bot::commands::CommandOutput, + utils::{consts::VERSION, embed::Status}, +}; + +pub const NAME: &str = "version"; + +pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { + Box::pin(async move { + if let Err(why) = command + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.embed(|embed| { + embed + .title("Spoticord Version") + .author(|author| { + author + .name("Maintained by: RoDaBaFilms") + .url("https://rodabafilms.com/") + .icon_url("https://rodabafilms.com/logo_2021_nobg.png") + }) + .description(format!("Current version: {}\n\nSpoticord is open source, check out [our GitHub](https://github.com/SpoticordMusic)", VERSION)) + .color(Status::Info as u64) + }) + }) + }) + .await + { + error!("Error sending message: {:?}", why); + } + }) +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name(NAME) + .description("Shows the current running version of Spoticord") +} diff --git a/src/bot/commands/mod.rs b/src/bot/commands/mod.rs index 3a00d57..4a64166 100644 --- a/src/bot/commands/mod.rs +++ b/src/bot/commands/mod.rs @@ -11,12 +11,39 @@ use serenity::{ prelude::{Context, TypeMapKey}, }; +use crate::utils::embed::{make_embed_message, EmbedMessageOptions}; + mod core; mod music; +#[cfg(debug_assertions)] mod ping; + +#[cfg(debug_assertions)] mod token; +pub async fn respond_message( + ctx: &Context, + command: &ApplicationCommandInteraction, + options: EmbedMessageOptions, + ephemeral: bool, +) { + if let Err(why) = command + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message + .embed(|embed| make_embed_message(embed, options)) + .ephemeral(ephemeral) + }) + }) + .await + { + error!("Error sending message: {:?}", why); + } +} + pub type CommandOutput = Pin + Send>>; pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput; @@ -44,12 +71,23 @@ impl CommandManager { } // Core commands + instance.insert_command(core::help::NAME, core::help::register, core::help::run); + instance.insert_command( + core::version::NAME, + core::version::register, + core::version::run, + ); instance.insert_command(core::link::NAME, core::link::register, core::link::run); instance.insert_command( core::unlink::NAME, core::unlink::register, core::unlink::run, ); + instance.insert_command( + core::rename::NAME, + core::rename::register, + core::rename::run, + ); // Music commands instance.insert_command(music::join::NAME, music::join::register, music::join::run); @@ -58,6 +96,11 @@ impl CommandManager { music::leave::register, music::leave::run, ); + instance.insert_command( + music::playing::NAME, + music::playing::register, + music::playing::run, + ); instance } @@ -93,8 +136,8 @@ impl CommandManager { cmds: &HashMap, mut commands: &'a mut CreateApplicationCommands, ) -> &'a mut CreateApplicationCommands { - for cmd in cmds { - commands = commands.create_application_command(|command| (cmd.1.register)(command)); + for (_, command_info) in cmds { + commands = commands.create_application_command(|command| (command_info.register)(command)); } commands diff --git a/src/bot/commands/music/join.rs b/src/bot/commands/music/join.rs index 1009eb1..730ff9c 100644 --- a/src/bot/commands/music/join.rs +++ b/src/bot/commands/music/join.rs @@ -1,41 +1,17 @@ -use log::error; use serenity::{ builder::CreateApplicationCommand, - model::prelude::interaction::{ - application_command::ApplicationCommandInteraction, InteractionResponseType, - }, + model::prelude::interaction::application_command::ApplicationCommandInteraction, prelude::Context, - Result as SerenityResult, }; use crate::{ - bot::commands::CommandOutput, + bot::commands::{respond_message, CommandOutput}, session::manager::{SessionCreateError, SessionManager}, + utils::embed::{EmbedBuilder, Status}, }; pub const NAME: &str = "join"; -async fn respond_message( - ctx: &Context, - command: &ApplicationCommandInteraction, - msg: impl Into, - ephemeral: bool, -) -> SerenityResult<()> { - command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| message.content(msg.into()).ephemeral(ephemeral)) - }) - .await -} - -fn check_msg(result: SerenityResult<()>) { - if let Err(why) = result { - error!("Error sending message: {:?}", why); - } -} - pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let guild = ctx.cache.guild(command.guild_id.unwrap()).unwrap(); @@ -48,15 +24,18 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu { Some(channel_id) => channel_id, None => { - check_msg( - respond_message( - &ctx, - &command, - "You need to connect to a voice channel", - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Cannot join voice channel") + .icon_url("https://spoticord.com/static/image/prohibited.png") + .description("You need to connect to a voice channel") + .status(Status::Error) + .build(), + true, + ) + .await; return; } @@ -66,71 +45,143 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu let mut session_manager = data.get::().unwrap().clone(); // Check if another session is already active in this server - if let Some(session) = session_manager.get_session(guild.id).await { - let msg = if session.get_owner() == command.user.id { - "You are already playing music in this server" - } else { - "Someone else is already playing music in this server" - }; + let session_opt = session_manager.get_session(guild.id).await; + if let Some(session) = &session_opt { + if let Some(owner) = session.get_owner().await { + let msg = if owner == command.user.id { + "You are already controlling the bot" + } else { + "The bot is currently being controlled by someone else" + }; - check_msg(respond_message(&ctx, &command, msg, true).await); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Cannot join voice channel") + .icon_url("https://spoticord.com/static/image/prohibited.png") + .description(msg) + .status(Status::Error) + .build(), + true, + ) + .await; - return; + return; + } }; // Prevent duplicate Spotify sessions if let Some(session) = session_manager.find(command.user.id).await { - check_msg( - respond_message( + respond_message( &ctx, &command, + EmbedBuilder::new() + .title("Cannot join voice channel") + .icon_url("https://spoticord.com/static/image/prohibited.png") + .description( format!( "You are already playing music in another server ({}).\nStop playing in that server first before joining this one.", ctx.cache.guild(session.get_guild_id()).unwrap().name - ), + )).status(Status::Error).build(), true, ) - .await, - ); + .await; return; } - // Create the session, and handle potential errors - if let Err(why) = session_manager - .create_session(&ctx, guild.id, channel_id, command.user.id) - .await - { - // Need to link first - if let SessionCreateError::NoSpotifyError = why { - check_msg( + if let Some(session) = &session_opt { + if let Err(why) = session.update_owner(&ctx, command.user.id).await { + // Need to link first + if let SessionCreateError::NoSpotifyError = why { respond_message( &ctx, &command, - "You need to link your Spotify account. Use `/link` or go to https://account.spoticord.com/ to get started.", + EmbedBuilder::new() + .title("Cannot join voice channel") + .icon_url("https://spoticord.com/static/image/prohibited.png") + .description("You need to link your Spotify account. Use or go to https://account.spoticord.com/ to get started.") + .status(Status::Error) + .build(), true, ) - .await, - ); + .await; - return; - } + return; + } - // Any other error - check_msg( + // Any other error respond_message( &ctx, &command, - "An error occurred while joining the channel. Please try again later.", + EmbedBuilder::new() + .title("Cannot join voice channel") + .icon_url("https://spoticord.com/static/image/prohibited.png") + .description("An error occured while joining the channel. Please try again later.") + .status(Status::Error) + .build(), true, ) - .await, - ); + .await; - return; - }; + return; + } + } else { + // Create the session, and handle potential errors + if let Err(why) = session_manager + .create_session(&ctx, guild.id, channel_id, command.user.id) + .await + { + // Need to link first + if let SessionCreateError::NoSpotifyError = why { + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Cannot join voice channel") + .icon_url("https://spoticord.com/static/image/prohibited.png") + .description("You need to link your Spotify account. Use or go to https://account.spoticord.com/ to get started.") + .status(Status::Error) + .build(), + true, + ) + .await; - check_msg(respond_message(&ctx, &command, "Joined the voice channel.", false).await); + return; + } + + // Any other error + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Cannot join voice channel") + .icon_url("https://spoticord.com/static/image/prohibited.png") + .description("An error occured while joining the channel. Please try again later.") + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + }; + } + + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Connected to voice channel") + .icon_url("https://spoticord.com/static/image/speaker.png") + .description(format!("Come listen along in <#{}>", channel_id)) + .footer("Spotify will automatically start playing on Spoticord") + .status(Status::Success) + .build(), + false, + ) + .await; }) } diff --git a/src/bot/commands/music/leave.rs b/src/bot/commands/music/leave.rs index de11b65..354bd80 100644 --- a/src/bot/commands/music/leave.rs +++ b/src/bot/commands/music/leave.rs @@ -1,38 +1,17 @@ -use log::error; use serenity::{ builder::CreateApplicationCommand, - model::prelude::interaction::{ - application_command::ApplicationCommandInteraction, InteractionResponseType, - }, + model::prelude::interaction::application_command::ApplicationCommandInteraction, prelude::Context, - Result as SerenityResult, }; -use crate::{bot::commands::CommandOutput, session::manager::SessionManager}; +use crate::{ + bot::commands::{respond_message, CommandOutput}, + session::manager::SessionManager, + utils::embed::{EmbedBuilder, Status}, +}; pub const NAME: &str = "leave"; -async fn respond_message( - ctx: &Context, - command: &ApplicationCommandInteraction, - msg: &str, - ephemeral: bool, -) -> SerenityResult<()> { - command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| message.content(msg).ephemeral(ephemeral)) - }) - .await -} - -fn check_msg(result: SerenityResult<()>) { - if let Err(why) = result { - error!("Error sending message: {:?}", why); - } -} - pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; @@ -41,41 +20,53 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu let session = match session_manager.get_session(command.guild_id.unwrap()).await { Some(session) => session, None => { - check_msg( - respond_message( - &ctx, - &command, - "I'm currently not connected to any voice channel", - true, - ) - .await, - ); + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Cannot disconnect bot") + .icon_url("https://tabler-icons.io/static/tabler-icons/icons/ban.svg") + .description("I'm currently not connected to any voice channel") + .status(Status::Error) + .build(), + true, + ) + .await; + return; } }; - if session.get_owner() != command.user.id { - // This message was generated by AI, and I love it. - check_msg(respond_message(&ctx, &command, "You are not the one who summoned me", true).await); - return; - }; - - if let Err(why) = session.disconnect().await { - error!("Error disconnecting from voice channel: {:?}", why); - - check_msg( + if let Some(owner) = session.get_owner().await { + if owner != command.user.id { + // This message was generated by AI, and I love it. respond_message( &ctx, &command, - "An error occurred while disconnecting from the voice channel", + EmbedBuilder::new() + .description("You are not the one who summoned me") + .status(Status::Error) + .build(), true, ) - .await, - ); - return; + .await; + + return; + }; } - check_msg(respond_message(&ctx, &command, "Successfully left the voice channel", false).await); + session.disconnect().await; + + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .description("I have left the voice channel, goodbye for now") + .status(Status::Info) + .build(), + false, + ) + .await; }) } diff --git a/src/bot/commands/music/mod.rs b/src/bot/commands/music/mod.rs index 2c0fcca..88ecafd 100644 --- a/src/bot/commands/music/mod.rs +++ b/src/bot/commands/music/mod.rs @@ -1,2 +1,3 @@ pub mod join; pub mod leave; +pub mod playing; diff --git a/src/bot/commands/music/playing.rs b/src/bot/commands/music/playing.rs new file mode 100644 index 0000000..c1ccf6c --- /dev/null +++ b/src/bot/commands/music/playing.rs @@ -0,0 +1,170 @@ +use librespot::core::spotify_id::SpotifyAudioType; +use log::error; +use serenity::{ + builder::CreateApplicationCommand, + model::prelude::interaction::{ + application_command::ApplicationCommandInteraction, InteractionResponseType, + }, + prelude::Context, +}; + +use crate::{ + bot::commands::{respond_message, CommandOutput}, + session::manager::SessionManager, + utils::{embed::{EmbedBuilder, Status}, self}, +}; + +pub const NAME: &str = "playing"; + +pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { + Box::pin(async move { + let not_playing = async { + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("Cannot get track info") + .icon_url("https://tabler-icons.io/static/tabler-icons/icons/ban.svg") + .description("I'm currently not playing any music in this server") + .status(Status::Error) + .build(), + true, + ) + .await; + }; + + let data = ctx.data.read().await; + let session_manager = data.get::().unwrap().clone(); + + let session = match session_manager.get_session(command.guild_id.unwrap()).await { + Some(session) => session, + None => { + not_playing.await; + + return; + } + }; + + let owner = match session.get_owner().await { + Some(owner) => owner, + None => { + not_playing.await; + + return; + } + }; + + // Get Playback Info from session + let pbi = match session.get_playback_info().await { + Some(pbi) => pbi, + None => { + not_playing.await; + + return; + } + }; + + let spotify_id = match pbi.spotify_id { + Some(spotify_id) => spotify_id, + None => { + not_playing.await; + + return; + } + }; + + // Get audio type + let audio_type = if spotify_id.audio_type == SpotifyAudioType::Track { + "track" + } else { + "episode" + }; + + // Create title + let title = format!("{} - {}", pbi.get_artists().unwrap(), pbi.get_name().unwrap()); + + // Create description + let mut description = String::new(); + + let position = pbi.get_position(); + let spot = position * 20 / pbi.duration_ms; + + description.push_str(if pbi.is_playing { "▶️ " } else { "⏸️ " }); + + for i in 0..20 { + if i == spot { + description.push_str("🔵"); + } else { + description.push_str("▬"); + } + } + + description.push_str("\n:alarm_clock: "); + description.push_str(&format!("{} / {}", utils::time_to_str(position / 1000), utils::time_to_str(pbi.duration_ms / 1000))); + + // Get owner of session + let owner = match ctx.cache.user(owner) { + Some(user) => user, + None => { + // This shouldn't happen + // TODO: This can happen, idk when + + error!("Could not find user with id {}", owner); + + respond_message( + &ctx, + &command, + EmbedBuilder::new() + .title("[INTERNAL ERROR] Cannot get track info") + .description(format!("Could not find user with id {}\nThis is an issue with the bot!", owner)) + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + }; + + // Get the thumbnail image + let thumbnail = pbi.get_thumbnail_url().unwrap(); + + if let Err(why) = command + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message + .embed(|embed| + embed + .author(|author| + author + .name("Currently Playing") + .icon_url("https://www.freepnglogos.com/uploads/spotify-logo-png/file-spotify-logo-png-4.png") + ) + .title(title) + .url(format!("https://open.spotify.com/{}/{}", audio_type, spotify_id.to_base62().unwrap())) + .description(description) + .footer(|footer| + footer + .text(&owner.name) + .icon_url(owner.face()) + ) + .thumbnail(&thumbnail) + .color(Status::Success as u64) + ) + }) + }) + .await + { + error!("Error sending message: {:?}", why); + } + }) +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name(NAME) + .description("Display which song is currently being played") +} diff --git a/src/bot/events.rs b/src/bot/events.rs index 6292061..7241dab 100644 --- a/src/bot/events.rs +++ b/src/bot/events.rs @@ -3,10 +3,12 @@ use log::*; use serenity::{ async_trait, - model::prelude::{interaction::Interaction, Ready}, + model::prelude::{interaction::Interaction, Activity, Ready}, prelude::{Context, EventHandler}, }; +use crate::utils::consts::MOTD; + use super::commands::CommandManager; // Handler struct with a command parameter, an array of dictionary which takes a string and function @@ -23,6 +25,8 @@ impl EventHandler for Handler { command_manager.register_commands(&ctx).await; + ctx.set_activity(Activity::listening(MOTD)).await; + info!("{} has come online", ready.user.name); } @@ -36,7 +40,7 @@ impl EventHandler for Handler { response .kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { - message.content("This command can only be used in a server") + message.content("You can only execute commands inside of a server") }) }) .await diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 2bddc4f..ae78b1f 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,2 +1,3 @@ +// TODO: Check all image urls in embed responses pub mod commands; pub mod events; diff --git a/src/database.rs b/src/database.rs index 5b50588..b3683f3 100644 --- a/src/database.rs +++ b/src/database.rs @@ -18,6 +18,9 @@ pub enum DatabaseError { #[error("An invalid status code was returned from a request: {0}")] InvalidStatusCode(StatusCode), + + #[error("An invalid input body was provided: {0}")] + InvalidInputBody(String), } #[derive(Serialize, Deserialize)] @@ -78,6 +81,7 @@ enum Method { Post, Put, Delete, + Patch, } impl Database { @@ -112,6 +116,7 @@ impl Database { Method::Post => client.post(url), Method::Put => client.put(url), Method::Delete => client.delete(url), + Method::Patch => client.patch(url), }; request = if let Some(body) = options.body { @@ -157,9 +162,43 @@ impl Database { Ok(body) } + + async fn json_post( + &self, + value: impl Serialize, + path: impl Into, + ) -> Result { + let body = json!(value); + + let response = match self + .request(RequestOptions { + method: Method::Post, + path: path.into(), + body: Some(Body::Json(body)), + headers: None, + }) + .await + { + Ok(response) => response, + Err(error) => return Err(DatabaseError::IOError(error.to_string())), + }; + + match response.status() { + StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => {} + status => return Err(DatabaseError::InvalidStatusCode(status)), + }; + + let body = match response.json::().await { + Ok(body) => body, + Err(error) => return Err(DatabaseError::ParseError(error.to_string())), + }; + + Ok(body) + } } impl Database { + // Get Spoticord user pub async fn get_user(&self, user_id: impl Into) -> Result { let path = format!("/user/{}", user_id.into()); @@ -202,6 +241,17 @@ impl Database { Ok(body) } + // Create a Spoticord user + pub async fn create_user(&self, user_id: impl Into) -> Result { + let body = json!({ + "id": user_id.into(), + }); + + let user: User = self.json_post(body, "/user/new").await?; + + Ok(user) + } + // Create the link Request for a user pub async fn create_user_request( &self, @@ -262,6 +312,42 @@ impl Database { Ok(()) } + + pub async fn update_user_device_name( + &self, + user_id: impl Into, + name: impl Into, + ) -> Result<(), DatabaseError> { + let device_name: String = name.into(); + + if device_name.len() > 16 || device_name.len() < 1 { + return Err(DatabaseError::InvalidInputBody( + "Invalid device name length".into(), + )); + } + + let body = json!({ "device_name": device_name }); + + let response = match self + .request(RequestOptions { + method: Method::Patch, + path: format!("/user/{}", user_id.into()), + body: Some(Body::Json(body)), + headers: None, + }) + .await + { + Ok(response) => response, + Err(err) => return Err(DatabaseError::IOError(err.to_string())), + }; + + match response.status() { + StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => { + Ok(()) + } + status => return Err(DatabaseError::InvalidStatusCode(status)), + } + } } impl TypeMapKey for Database { diff --git a/src/ipc/packet.rs b/src/ipc/packet.rs index 84421b6..a8b9283 100644 --- a/src/ipc/packet.rs +++ b/src/ipc/packet.rs @@ -9,4 +9,16 @@ pub enum IpcPacket { StartPlayback, StopPlayback, + + /// The current Spotify track was changed + TrackChange(String), + + /// Spotify playback was started/resumed + Playing(String, u32, u32), + + /// Spotify playback was paused + Paused(String, u32, u32), + + /// Sent when the user has switched their Spotify device away from Spoticord + Stopped, } diff --git a/src/main.rs b/src/main.rs index cc16721..cb301d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,13 @@ use dotenv::dotenv; use log::*; use serenity::{framework::StandardFramework, prelude::GatewayIntents, Client}; use songbird::SerenityInit; -use std::env; +use std::{env, process::exit}; +use tokio::signal::unix::SignalKind; -use crate::{bot::commands::CommandManager, database::Database, session::manager::SessionManager}; +use crate::{ + bot::commands::CommandManager, database::Database, session::manager::SessionManager, + stats::StatsManager, +}; mod audio; mod bot; @@ -15,10 +19,23 @@ mod ipc; mod librespot_ext; mod player; mod session; +mod stats; mod utils; -#[tokio::main] +#[tokio::main(flavor = "multi_thread")] async fn main() { + if std::env::var("RUST_LOG").is_err() { + #[cfg(debug_assertions)] + { + std::env::set_var("RUST_LOG", "spoticord"); + } + + #[cfg(not(debug_assertions))] + { + std::env::set_var("RUST_LOG", "spoticord=info"); + } + } + env_logger::init(); let args: Vec = env::args().collect(); @@ -31,6 +48,8 @@ async fn main() { player::main().await; + debug!("Player exited, shutting down"); + return; } } @@ -48,6 +67,10 @@ async fn main() { let token = env::var("TOKEN").expect("a token in the environment"); let db_url = env::var("DATABASE_URL").expect("a database URL in the environment"); + let kv_url = env::var("KV_URL").expect("a redis URL in the environment"); + + let stats_manager = StatsManager::new(kv_url).expect("Failed to connect to redis"); + let session_manager = SessionManager::new(); // Create client let mut client = Client::builder( @@ -65,23 +88,56 @@ async fn main() { data.insert::(Database::new(db_url, None)); data.insert::(CommandManager::new()); - data.insert::(SessionManager::new()); + data.insert::(session_manager.clone()); } let shard_manager = client.shard_manager.clone(); + let cache = client.cache_and_http.cache.clone(); - // Spawn a task to shutdown the bot when a SIGINT is received + let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate()).unwrap(); + + // Background tasks tokio::spawn(async move { - tokio::signal::ctrl_c() - .await - .expect("Could not register CTRL+C handler"); + loop { + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { + let guild_count = cache.guilds().len(); + let active_count = session_manager.get_active_session_count().await; - info!("SIGINT Received, shutting down..."); + if let Err(why) = stats_manager.set_server_count(guild_count) { + error!("Failed to update server count: {}", why); + } - shard_manager.lock().await.shutdown_all().await; + if let Err(why) = stats_manager.set_active_count(active_count) { + error!("Failed to update active count: {}", why); + } + + // Yes, I like to handle my s's when I'm working with amounts + debug!("Updated stats: {} guild{}, {} active session{}", guild_count, if guild_count == 1 { "" } else { "s" }, active_count, if active_count == 1 { "" } else { "s" }); + } + + _ = tokio::signal::ctrl_c() => { + info!("Received interrupt signal, shutting down..."); + + shard_manager.lock().await.shutdown_all().await; + + break; + } + + _ = sigterm.recv() => { + info!("Received terminate signal, shutting down..."); + + shard_manager.lock().await.shutdown_all().await; + + break; + } + } + } }); + // Start the bot if let Err(why) = client.start_autosharded().await { - println!("Error in bot: {:?}", why); + error!("FATAL Error in bot: {:?}", why); + exit(1); } } diff --git a/src/player/mod.rs b/src/player/mod.rs index 6a1398b..42fff9d 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -8,10 +8,10 @@ use librespot::{ playback::{ config::{Bitrate, PlayerConfig}, mixer::{self, MixerConfig}, - player::Player, + player::{Player, PlayerEvent}, }, }; -use log::{debug, error, info, trace, warn}; +use log::{debug, error, warn}; use serde_json::json; use crate::{ @@ -24,6 +24,7 @@ use crate::{ pub struct SpoticordPlayer { client: ipc::Client, session: Option, + spirc: Option, } impl SpoticordPlayer { @@ -31,6 +32,7 @@ impl SpoticordPlayer { Self { client, session: None, + spirc: None, } } @@ -49,6 +51,11 @@ impl SpoticordPlayer { // Log in using the token let credentials = Credentials::with_token(username, &token); + // Shutdown old session (cannot be done in the stop function) + if let Some(session) = self.session.take() { + session.shutdown(); + } + // Connect the session let (session, _) = match Session::connect(session_config, credentials, None, false).await { Ok((session, credentials)) => (session, credentials), @@ -64,19 +71,18 @@ impl SpoticordPlayer { let client = self.client.clone(); // Create the player - let (player, _) = Player::new( + let (player, mut receiver) = Player::new( player_config, session.clone(), mixer.get_soft_volume(), move || Box::new(StdoutSink::new(client)), ); - let mut receiver = player.get_player_event_channel(); - - let (_, spirc_run) = Spirc::new( + let (spirc, spirc_task) = Spirc::new( ConnectConfig { name: device_name.into(), - initial_volume: Some(65535), + // 75% + initial_volume: Some((65535 / 4) * 3), ..ConnectConfig::default() }, session.clone(), @@ -85,6 +91,7 @@ impl SpoticordPlayer { ); let device_id = session.device_id().to_owned(); + let ipc = self.client.clone(); // IPC Handler tokio::spawn(async move { @@ -103,12 +110,12 @@ impl SpoticordPlayer { { Ok(resp) => { if resp.status() == 202 { - info!("Successfully switched to device"); + debug!("Successfully switched to device"); break; } } Err(why) => { - debug!("Failed to set device: {}", why); + error!("Failed to set device: {}", why); break; } } @@ -116,25 +123,76 @@ impl SpoticordPlayer { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } - // TODO: Do IPC stuff with these events + // Do IPC stuff with these events loop { let event = match receiver.recv().await { Some(event) => event, None => break, }; - trace!("Player event: {:?}", event); + match event { + PlayerEvent::Playing { + play_request_id: _, + track_id, + position_ms, + duration_ms, + } => { + if let Err(why) = ipc.send(IpcPacket::Playing( + track_id.to_uri().unwrap(), + position_ms, + duration_ms, + )) { + error!("Failed to send playing packet: {}", why); + } + } + + PlayerEvent::Paused { + play_request_id: _, + track_id, + position_ms, + duration_ms, + } => { + if let Err(why) = ipc.send(IpcPacket::Paused( + track_id.to_uri().unwrap(), + position_ms, + duration_ms, + )) { + error!("Failed to send paused packet: {}", why); + } + } + + PlayerEvent::Changed { + old_track_id: _, + new_track_id, + } => { + if let Err(why) = ipc.send(IpcPacket::TrackChange(new_track_id.to_uri().unwrap())) { + error!("Failed to send track change packet: {}", why); + } + } + + PlayerEvent::Stopped { + play_request_id: _, + track_id: _, + } => { + if let Err(why) = ipc.send(IpcPacket::Stopped) { + error!("Failed to send player stopped packet: {}", why); + } + } + + _ => {} + }; } - info!("Player stopped"); + debug!("Player stopped"); }); - tokio::spawn(spirc_run); + self.spirc = Some(spirc); + session.spawn(spirc_task); } pub fn stop(&mut self) { - if let Some(session) = self.session.take() { - session.shutdown(); + if let Some(spirc) = self.spirc.take() { + spirc.shutdown(); } } } @@ -162,13 +220,13 @@ pub async fn main() { match message { IpcPacket::Connect(token, device_name) => { - info!("Connecting to Spotify with device name {}", device_name); + debug!("Connecting to Spotify with device name {}", device_name); player.start(token, device_name).await; } IpcPacket::Disconnect => { - info!("Disconnecting from Spotify"); + debug!("Disconnecting from Spotify"); player.stop(); } @@ -185,6 +243,4 @@ pub async fn main() { } } } - - info!("We're done here, shutting down..."); } diff --git a/src/session/manager.rs b/src/session/manager.rs index b8d071c..365145e 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -60,12 +60,34 @@ impl SessionManager { Ok(()) } - /// Remove (and destroy) a session + /// Remove a session pub async fn remove_session(&mut self, guild_id: GuildId) { let mut sessions = self.sessions.write().await; + + if let Some(session) = sessions.get(&guild_id) { + if let Some(owner) = session.get_owner().await { + let mut owner_map = self.owner_map.write().await; + owner_map.remove(&owner); + } + } + sessions.remove(&guild_id); } + /// Remove owner from owner map. + /// Used whenever a user stops playing music without leaving the bot. + pub async fn remove_owner(&mut self, owner_id: UserId) { + let mut owner_map = self.owner_map.write().await; + owner_map.remove(&owner_id); + } + + /// Set the owner of a session + /// Used when a user joins a session that is already active + pub async fn set_owner(&mut self, owner_id: UserId, guild_id: GuildId) { + let mut owner_map = self.owner_map.write().await; + owner_map.insert(owner_id, guild_id); + } + /// Get a session by its guild ID pub async fn get_session(&self, guild_id: GuildId) -> Option> { let sessions = self.sessions.read().await; @@ -82,4 +104,19 @@ impl SessionManager { sessions.get(&guild_id).cloned() } + + /// Get the amount of sessions with an owner + pub async fn get_active_session_count(&self) -> usize { + let sessions = self.sessions.read().await; + + let mut count: usize = 0; + + for session in sessions.values() { + if session.owner.read().await.is_some() { + count += 1; + } + } + + count + } } diff --git a/src/session/mod.rs b/src/session/mod.rs index bb0d9cc..8c8bc9b 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,18 +1,19 @@ use self::manager::{SessionCreateError, SessionManager}; use crate::{ database::{Database, DatabaseError}, - ipc::{self, packet::IpcPacket}, + ipc::{self, packet::IpcPacket, Client}, + utils::{self, spotify}, }; use ipc_channel::ipc::IpcError; +use librespot::core::spotify_id::{SpotifyAudioType, SpotifyId}; use log::*; use serenity::{ async_trait, model::prelude::{ChannelId, GuildId, UserId}, - prelude::Context, + prelude::{Context, RwLock}, }; use songbird::{ create_player, - error::JoinResult, input::{children_to_reader, Input}, tracks::TrackHandle, Call, Event, EventContext, EventHandler, @@ -25,9 +26,111 @@ use tokio::sync::Mutex; pub mod manager; +#[derive(Clone)] +pub struct PlaybackInfo { + last_updated: u128, + position_ms: u32, + + pub track: Option, + pub episode: Option, + pub spotify_id: Option, + + pub duration_ms: u32, + pub is_playing: bool, +} + +impl PlaybackInfo { + fn new(duration_ms: u32, position_ms: u32, is_playing: bool) -> Self { + Self { + last_updated: utils::get_time_ms(), + track: None, + episode: None, + spotify_id: None, + duration_ms, + position_ms, + is_playing, + } + } + + // Update position, duration and playback state + async fn update_pos_dur(&mut self, position_ms: u32, duration_ms: u32, is_playing: bool) { + self.position_ms = position_ms; + self.duration_ms = duration_ms; + self.is_playing = is_playing; + + self.last_updated = utils::get_time_ms(); + } + + // Update spotify id, track and episode + fn update_track_episode( + &mut self, + spotify_id: SpotifyId, + track: Option, + episode: Option, + ) { + self.spotify_id = Some(spotify_id); + self.track = track; + self.episode = episode; + } + + pub fn get_position(&self) -> u32 { + if self.is_playing { + let now = utils::get_time_ms(); + let diff = now - self.last_updated; + + self.position_ms + diff as u32 + } else { + self.position_ms + } + } + + pub fn get_name(&self) -> Option { + if let Some(track) = &self.track { + Some(track.name.clone()) + } else if let Some(episode) = &self.episode { + Some(episode.name.clone()) + } else { + None + } + } + + pub fn get_artists(&self) -> Option { + if let Some(track) = &self.track { + Some( + track + .artists + .iter() + .map(|a| a.name.clone()) + .collect::>() + .join(", "), + ) + } else if let Some(episode) = &self.episode { + Some(episode.show.name.clone()) + } else { + None + } + } + + pub fn get_thumbnail_url(&self) -> Option { + if let Some(track) = &self.track { + let mut images = track.album.images.clone(); + images.sort_by(|a, b| b.width.cmp(&a.width)); + + Some(images.get(0).unwrap().url.clone()) + } else if let Some(episode) = &self.episode { + let mut images = episode.show.images.clone(); + images.sort_by(|a, b| b.width.cmp(&a.width)); + + Some(images.get(0).unwrap().url.clone()) + } else { + None + } + } +} + #[derive(Clone)] pub struct SpoticordSession { - owner: UserId, + owner: Arc>>, guild_id: GuildId, channel_id: ChannelId, @@ -35,6 +138,10 @@ pub struct SpoticordSession { call: Arc>, track: TrackHandle, + + playback_info: Arc>>, + + client: Client, } impl SpoticordSession { @@ -92,8 +199,7 @@ impl SpoticordSession { let mut call_mut = call.lock().await; // Spawn player process - let args: Vec = std::env::args().collect(); - let child = match Command::new(&args[0]) + let child = match Command::new(std::env::current_exe().unwrap()) .args(["--player", &tx_name, &rx_name]) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -126,9 +232,22 @@ impl SpoticordSession { // Set call audio to track call_mut.play_only(track); + let instance = Self { + owner: Arc::new(RwLock::new(Some(owner_id.clone()))), + guild_id, + channel_id, + session_manager: session_manager.clone(), + call: call.clone(), + track: track_handle.clone(), + playback_info: Arc::new(RwLock::new(None)), + client: client.clone(), + }; + // Clone variables for use in the IPC handler let ipc_track = track_handle.clone(); let ipc_client = client.clone(); + let ipc_context = ctx.clone(); + let mut ipc_instance = instance.clone(); // Handle IPC packets // This will automatically quit once the IPC connection is closed @@ -140,6 +259,9 @@ impl SpoticordSession { }; loop { + // Required for IpcPacket::TrackChange to work + tokio::task::yield_now().await; + let msg = match ipc_client.recv() { Ok(msg) => msg, Err(why) => { @@ -153,29 +275,89 @@ impl SpoticordSession { }; match msg { + // Sink requests playback to start/resume IpcPacket::StartPlayback => { check_result(ipc_track.play()); } + // Sink requests playback to pause IpcPacket::StopPlayback => { check_result(ipc_track.pause()); } + // A new track has been set by the player + IpcPacket::TrackChange(track) => { + // Convert to SpotifyId + let track_id = SpotifyId::from_uri(&track).unwrap(); + + let mut instance = ipc_instance.clone(); + let context = ipc_context.clone(); + + tokio::spawn(async move { + if let Err(why) = instance.update_track(&context, &owner_id, track_id).await { + error!("Failed to update track: {:?}", why); + + instance.player_stopped().await; + } + }); + } + + // The player has started playing a track + IpcPacket::Playing(track, position_ms, duration_ms) => { + // Convert to SpotifyId + let track_id = SpotifyId::from_uri(&track).unwrap(); + + let was_none = ipc_instance + .update_playback(duration_ms, position_ms, true) + .await; + + if was_none { + // Stop player if update track fails + if let Err(why) = ipc_instance + .update_track(&ipc_context, &owner_id, track_id) + .await + { + error!("Failed to update track: {:?}", why); + + ipc_instance.player_stopped().await; + return; + } + } + } + + IpcPacket::Paused(track, position_ms, duration_ms) => { + // Convert to SpotifyId + let track_id = SpotifyId::from_uri(&track).unwrap(); + + let was_none = ipc_instance + .update_playback(duration_ms, position_ms, true) + .await; + + if was_none { + // Stop player if update track fails + if let Err(why) = ipc_instance + .update_track(&ipc_context, &owner_id, track_id) + .await + { + error!("Failed to update track: {:?}", why); + + ipc_instance.player_stopped().await; + return; + } + } + } + + IpcPacket::Stopped => { + ipc_instance.player_stopped().await; + } + + // Ignore other packets _ => {} } } }); // Set up events - let instance = Self { - owner: owner_id, - guild_id, - channel_id, - session_manager, - call: call.clone(), - track: track_handle, - }; - call_mut.add_global_event( songbird::Event::Core(songbird::CoreEvent::DriverDisconnect), instance.clone(), @@ -186,6 +368,7 @@ impl SpoticordSession { instance.clone(), ); + // Inform the player process to connect to Spotify if let Err(why) = client.send(IpcPacket::Connect(token, user.device_name)) { error!("Failed to send IpcPacket::Connect packet: {:?}", why); } @@ -193,7 +376,147 @@ impl SpoticordSession { Ok(instance) } - pub async fn disconnect(&self) -> JoinResult<()> { + pub async fn update_owner( + &self, + ctx: &Context, + owner_id: UserId, + ) -> Result<(), SessionCreateError> { + // Get the Spotify token of the owner + let data = ctx.data.read().await; + let database = data.get::().unwrap(); + let mut session_manager = data.get::().unwrap().clone(); + + let token = match database.get_access_token(owner_id.to_string()).await { + Ok(token) => token, + Err(why) => { + if let DatabaseError::InvalidStatusCode(code) = why { + if code == 404 { + return Err(SessionCreateError::NoSpotifyError); + } + } + + return Err(SessionCreateError::DatabaseError); + } + }; + + let user = match database.get_user(owner_id.to_string()).await { + Ok(user) => user, + Err(why) => { + error!("Failed to get user: {:?}", why); + return Err(SessionCreateError::DatabaseError); + } + }; + + let mut owner = self.owner.write().await; + *owner = Some(owner_id); + + session_manager.set_owner(owner_id, self.guild_id).await; + + // Inform the player process to connect to Spotify + if let Err(why) = self + .client + .send(IpcPacket::Connect(token, user.device_name)) + { + error!("Failed to send IpcPacket::Connect packet: {:?}", why); + } + + Ok(()) + } + + // Update current track + async fn update_track( + &self, + ctx: &Context, + owner_id: &UserId, + spotify_id: SpotifyId, + ) -> Result<(), String> { + let should_update = { + let pbi = self.playback_info.read().await; + + if let Some(pbi) = &*pbi { + pbi.spotify_id.is_none() || pbi.spotify_id.unwrap() != spotify_id + } else { + false + } + }; + + if !should_update { + return Ok(()); + } + + let data = ctx.data.read().await; + let database = data.get::().unwrap(); + + let token = match database.get_access_token(&owner_id.to_string()).await { + Ok(token) => token, + Err(why) => { + error!("Failed to get access token: {:?}", why); + return Err("Failed to get access token".to_string()); + } + }; + + let mut track: Option = None; + let mut episode: Option = None; + + if spotify_id.audio_type == SpotifyAudioType::Track { + let track_info = match spotify::get_track_info(&token, spotify_id).await { + Ok(track) => track, + Err(why) => { + error!("Failed to get track info: {:?}", why); + return Err("Failed to get track info".to_string()); + } + }; + + trace!("Received track info: {:?}", track_info); + + track = Some(track_info); + } else if spotify_id.audio_type == SpotifyAudioType::Podcast { + let episode_info = match spotify::get_episode_info(&token, spotify_id).await { + Ok(episode) => episode, + Err(why) => { + error!("Failed to get episode info: {:?}", why); + return Err("Failed to get episode info".to_string()); + } + }; + + trace!("Received episode info: {:?}", episode_info); + + episode = Some(episode_info); + } + + let mut pbi = self.playback_info.write().await; + + if let Some(pbi) = &mut *pbi { + pbi.update_track_episode(spotify_id, track, episode); + } + + Ok(()) + } + + /// Called when the player must stop, but not leave the call + async fn player_stopped(&mut self) { + if let Err(why) = self.track.pause() { + error!("Failed to pause track: {:?}", why); + } + + // Disconnect from Spotify + if let Err(why) = self.client.send(IpcPacket::Disconnect) { + error!("Failed to send disconnect packet: {:?}", why); + } + + // Clear owner + let mut owner = self.owner.write().await; + if let Some(owner_id) = owner.take() { + self.session_manager.remove_owner(owner_id).await; + } + + // Clear playback info + let mut playback_info = self.playback_info.write().await; + *playback_info = None; + } + + // Disconnect from voice channel and remove session from manager + pub async fn disconnect(&self) { info!("Disconnecting from voice channel {}", self.channel_id); self @@ -206,17 +529,55 @@ impl SpoticordSession { self.track.stop().unwrap_or(()); call.remove_all_global_events(); - call.leave().await + + if let Err(why) = call.leave().await { + error!("Failed to leave voice channel: {:?}", why); + } } - pub fn get_owner(&self) -> UserId { - self.owner + // Update playback info (duration, position, playing state) + async fn update_playback(&self, duration_ms: u32, position_ms: u32, playing: bool) -> bool { + let is_none = { + let pbi = self.playback_info.read().await; + + pbi.is_none() + }; + + if is_none { + let mut pbi = self.playback_info.write().await; + *pbi = Some(PlaybackInfo::new(duration_ms, position_ms, playing)); + } else { + let mut pbi = self.playback_info.write().await; + + // Update position, duration and playback state + pbi + .as_mut() + .unwrap() + .update_pos_dur(position_ms, duration_ms, playing) + .await; + }; + + is_none } + // Get the playback info for the current track + pub async fn get_playback_info(&self) -> Option { + self.playback_info.read().await.clone() + } + + // Get the current owner of this session + pub async fn get_owner(&self) -> Option { + let owner = self.owner.read().await; + + *owner + } + + // Get the server id this session is playing in pub fn get_guild_id(&self) -> GuildId { self.guild_id } + // Get the channel id this session is playing in pub fn get_channel_id(&self) -> ChannelId { self.channel_id } @@ -228,10 +589,18 @@ impl EventHandler for SpoticordSession { match ctx { EventContext::DriverDisconnect(_) => { debug!("Driver disconnected, leaving voice channel"); - self.disconnect().await.ok(); + self.disconnect().await; } EventContext::ClientDisconnect(who) => { - debug!("Client disconnected, {}", who.user_id.to_string()); + trace!("Client disconnected, {}", who.user_id.to_string()); + + if let Some(session) = self.session_manager.find(UserId(who.user_id.0)).await { + if session.get_guild_id() == self.guild_id && session.get_channel_id() == self.channel_id + { + // Clone because haha immutable references + self.clone().player_stopped().await; + } + } } _ => {} } diff --git a/src/stats/mod.rs b/src/stats/mod.rs new file mode 100644 index 0000000..93d8a59 --- /dev/null +++ b/src/stats/mod.rs @@ -0,0 +1,26 @@ +use redis::{Commands, RedisResult}; + +#[derive(Clone)] +pub struct StatsManager { + redis: redis::Client, +} + +impl StatsManager { + pub fn new(url: impl Into) -> RedisResult { + let redis = redis::Client::open(url.into())?; + + Ok(StatsManager { redis }) + } + + pub fn set_server_count(&self, count: usize) -> RedisResult<()> { + let mut con = self.redis.get_connection()?; + + con.set("sc-bot-total-servers", count.to_string()) + } + + pub fn set_active_count(&self, count: usize) -> RedisResult<()> { + let mut con = self.redis.get_connection()?; + + con.set("sc-bot-active-servers", count.to_string()) + } +} diff --git a/src/utils/consts.rs b/src/utils/consts.rs new file mode 100644 index 0000000..920f012 --- /dev/null +++ b/src/utils/consts.rs @@ -0,0 +1,3 @@ +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const MOTD: &str = "OPEN BETA (v2)"; +// pub const MOTD: &str = "some good 'ol music"; diff --git a/src/utils/discord.rs b/src/utils/discord.rs new file mode 100644 index 0000000..2e56418 --- /dev/null +++ b/src/utils/discord.rs @@ -0,0 +1,10 @@ +pub fn escape(text: impl Into) -> String { + let text: String = text.into(); + + text + .replace("\\", "\\\\") + .replace("*", "\\*") + .replace("_", "\\_") + .replace("~", "\\~") + .replace("`", "\\`") +} diff --git a/src/utils/embed.rs b/src/utils/embed.rs new file mode 100644 index 0000000..83f8351 --- /dev/null +++ b/src/utils/embed.rs @@ -0,0 +1,99 @@ +use serenity::builder::CreateEmbed; + +#[allow(dead_code)] +pub enum Status { + Info = 0x0773D6, + Success = 0x3BD65D, + Warning = 0xF0D932, + Error = 0xFC1F28, + None = 0, +} + +#[derive(Default)] +pub struct EmbedMessageOptions { + pub title: Option, + pub title_url: Option, + pub icon_url: Option, + pub description: String, + pub status: Option, + pub footer: Option, +} + +pub struct EmbedBuilder { + embed: EmbedMessageOptions, +} + +impl EmbedBuilder { + pub fn new() -> Self { + Self { + embed: EmbedMessageOptions::default(), + } + } + + pub fn title(mut self, title: impl Into) -> Self { + self.embed.title = Some(title.into()); + self + } + + pub fn title_url(mut self, title_url: impl Into) -> Self { + self.embed.title_url = Some(title_url.into()); + self + } + + pub fn icon_url(mut self, icon_url: impl Into) -> Self { + self.embed.icon_url = Some(icon_url.into()); + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.embed.description = description.into(); + self + } + + pub fn status(mut self, status: Status) -> Self { + self.embed.status = Some(status); + self + } + + pub fn footer(mut self, footer: impl Into) -> Self { + self.embed.footer = Some(footer.into()); + self + } + + /// Build the embed + pub fn build(self) -> EmbedMessageOptions { + self.embed + } +} + +pub fn make_embed_message<'a>( + embed: &'a mut CreateEmbed, + options: EmbedMessageOptions, +) -> &'a mut CreateEmbed { + let status = options.status.unwrap_or(Status::None); + + embed.author(|author| { + if let Some(title) = options.title { + author.name(title); + } + + if let Some(title_url) = options.title_url { + author.url(title_url); + } + + if let Some(icon_url) = options.icon_url { + author.icon_url(icon_url); + } + + author + }); + + if let Some(text) = options.footer { + embed.footer(|footer| footer.text(text)); + } + + embed.description(options.description); + embed.color(status as u32); + + embed +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 71ac53d..d457b50 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; +pub mod consts; +pub mod discord; +pub mod embed; pub mod spotify; pub fn get_time() -> u64 { @@ -8,3 +11,28 @@ pub fn get_time() -> u64 { since_the_epoch.as_secs() } + +pub fn get_time_ms() -> u128 { + let now = SystemTime::now(); + let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + + since_the_epoch.as_millis() +} + +pub fn time_to_str(time: u32) -> String { + let hour = 3600; + let min = 60; + + if time / hour >= 1 { + return format!( + "{}h{}m{}s", + time / hour, + (time % hour) / min, + (time % hour) % min + ); + } else if time / min >= 1 { + return format!("{}m{}s", time / min, time % min); + } else { + return format!("{}s", time); + } +} diff --git a/src/utils/spotify.rs b/src/utils/spotify.rs index e3b98b3..7364b66 100644 --- a/src/utils/spotify.rs +++ b/src/utils/spotify.rs @@ -1,4 +1,8 @@ +use std::error::Error; + +use librespot::core::spotify_id::SpotifyId; use log::{error, trace}; +use serde::Deserialize; use serde_json::Value; pub async fn get_username(token: impl Into) -> Result { @@ -34,3 +38,98 @@ pub async fn get_username(token: impl Into) -> Result { error!("Missing 'id' field in body"); Err("Failed to parse body: Invalid body received".to_string()) } + +#[derive(Debug, Clone, Deserialize)] +pub struct Artist { + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Image { + pub url: String, + pub height: u32, + pub width: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Album { + pub name: String, + pub images: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Track { + pub name: String, + pub artists: Vec, + pub album: Album, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Show { + pub name: String, + pub images: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Episode { + pub name: String, + pub show: Show, +} + +pub async fn get_track_info( + token: impl Into, + track: SpotifyId, +) -> Result> { + let token = token.into(); + let client = reqwest::Client::new(); + + let response = client + .get(format!( + "https://api.spotify.com/v1/tracks/{}", + track.to_base62()? + )) + .bearer_auth(token) + .send() + .await?; + + if response.status() != 200 { + return Err( + format!( + "Failed to get track info: Invalid status code: {}", + response.status() + ) + .into(), + ); + } + + Ok(response.json().await?) +} + +pub async fn get_episode_info( + token: impl Into, + episode: SpotifyId, +) -> Result> { + let token = token.into(); + let client = reqwest::Client::new(); + + let response = client + .get(format!( + "https://api.spotify.com/v1/episodes/{}", + episode.to_base62()? + )) + .bearer_auth(token) + .send() + .await?; + + if response.status() != 200 { + return Err( + format!( + "Failed to get episode info: Invalid status code: {}", + response.status() + ) + .into(), + ); + } + + Ok(response.json().await?) +}