Uhm so yeah here's some c o d e

main
DaXcess 2022-10-31 22:46:34 +01:00
parent 6a77189343
commit d530b182b3
33 changed files with 1934 additions and 298 deletions

5
.dockerignore 100644
View File

@ -0,0 +1,5 @@
target/
.env
.gitignore
.dockerignore
Dockerfile

5
.gitignore vendored
View File

@ -7,3 +7,8 @@
# Secrets
.env
# Editors
.vscode
.vs
.fleet

34
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

23
Dockerfile 100644
View File

@ -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"]

201
LICENSE 100644
View File

@ -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.

3
README.md 100644
View File

@ -0,0 +1,3 @@
# Spoticord
Spoticord is a Discord music bot that allows you to control your music using the Spotify app.

View File

@ -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));
}

View File

@ -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")
}

View File

@ -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<String>,
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::<Database>().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.",
EmbedBuilder::new()
.description("You have already linked your Spotify account.")
.status(Status::Error)
.build(),
true,
)
.await,
);
.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),
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,
);
.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),
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,
);
.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.",
EmbedBuilder::new()
.description("An error occurred while serving your request. Please try again later.")
.status(Status::Error)
.build(),
true,
)
.await,
);
.await;
return;
}

View File

@ -1,2 +1,5 @@
pub mod help;
pub mod link;
pub mod rename;
pub mod unlink;
pub mod version;

View File

@ -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::<Database>().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)
})
}

View File

@ -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<String>,
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.",
EmbedBuilder::new()
.description("You cannot unlink your Spotify account if you haven't linked one.")
.status(Status::Error)
.build(),
true,
)
.await,
);
.await;
return;
}
@ -73,28 +49,30 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
error!("Error deleting user account: {:?}", why);
check_msg(
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",
EmbedBuilder::new()
.description("Successfully unlinked your Spotify account from Spoticord")
.status(Status::Success)
.build(),
true,
)
.await,
);
.await;
})
}

View File

@ -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")
}

View File

@ -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<Box<dyn Future<Output = ()> + 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<String, CommandInfo>,
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

View File

@ -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<String>,
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",
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,
);
.await;
return;
}
@ -66,36 +45,89 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
let mut session_manager = data.get::<SessionManager>().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"
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 {
"Someone else is already playing music in this server"
"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;
}
};
// Prevent duplicate Spotify sessions
if let Some(session) = session_manager.find(command.user.id).await {
check_msg(
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;
}
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,
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 </link:1036714850367320136> or go to https://account.spoticord.com/ to get started.")
.status(Status::Error)
.build(),
true,
)
.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;
}
} else {
// Create the session, and handle potential errors
if let Err(why) = session_manager
.create_session(&ctx, guild.id, channel_id, command.user.id)
@ -103,34 +135,53 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
{
// Need to link first
if let SessionCreateError::NoSpotifyError = why {
check_msg(
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 </link:1036714850367320136> or go to https://account.spoticord.com/ to get started.")
.status(Status::Error)
.build(),
true,
)
.await,
);
.await;
return;
}
// Any other error
check_msg(
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;
};
}
check_msg(respond_message(&ctx, &command, "Joined the voice channel.", false).await);
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;
})
}

View File

@ -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",
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,
);
.await;
return;
}
};
if session.get_owner() != command.user.id {
if let Some(owner) = session.get_owner().await {
if 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(
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,
);
.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;
})
}

View File

@ -1,2 +1,3 @@
pub mod join;
pub mod leave;
pub mod playing;

View File

@ -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::<SessionManager>().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")
}

View File

@ -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

View File

@ -1,2 +1,3 @@
// TODO: Check all image urls in embed responses
pub mod commands;
pub mod events;

View File

@ -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<T: DeserializeOwned>(
&self,
value: impl Serialize,
path: impl Into<String>,
) -> Result<T, DatabaseError> {
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::<T>().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<String>) -> Result<User, DatabaseError> {
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<String>) -> Result<User, DatabaseError> {
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<String>,
name: impl Into<String>,
) -> 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 {

View File

@ -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,
}

View File

@ -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<String> = 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>(Database::new(db_url, None));
data.insert::<CommandManager>(CommandManager::new());
data.insert::<SessionManager>(SessionManager::new());
data.insert::<SessionManager>(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);
}
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);
}
}

View File

@ -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<Session>,
spirc: Option<Spirc>,
}
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);
}
}
info!("Player stopped");
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);
}
}
_ => {}
};
}
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...");
}

View File

@ -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<Arc<SpoticordSession>> {
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
}
}

View File

@ -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<spotify::Track>,
pub episode: Option<spotify::Episode>,
pub spotify_id: Option<SpotifyId>,
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<spotify::Track>,
episode: Option<spotify::Episode>,
) {
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<String> {
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<String> {
if let Some(track) = &self.track {
Some(
track
.artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<String>>()
.join(", "),
)
} else if let Some(episode) = &self.episode {
Some(episode.show.name.clone())
} else {
None
}
}
pub fn get_thumbnail_url(&self) -> Option<String> {
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<RwLock<Option<UserId>>>,
guild_id: GuildId,
channel_id: ChannelId,
@ -35,6 +138,10 @@ pub struct SpoticordSession {
call: Arc<Mutex<Call>>,
track: TrackHandle,
playback_info: Arc<RwLock<Option<PlaybackInfo>>>,
client: Client,
}
impl SpoticordSession {
@ -92,8 +199,7 @@ impl SpoticordSession {
let mut call_mut = call.lock().await;
// Spawn player process
let args: Vec<String> = 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::<Database>().unwrap();
let mut session_manager = data.get::<SessionManager>().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::<Database>().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<spotify::Track> = None;
let mut episode: Option<spotify::Episode> = 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<PlaybackInfo> {
self.playback_info.read().await.clone()
}
// Get the current owner of this session
pub async fn get_owner(&self) -> Option<UserId> {
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;
}
}
}
_ => {}
}

26
src/stats/mod.rs 100644
View File

@ -0,0 +1,26 @@
use redis::{Commands, RedisResult};
#[derive(Clone)]
pub struct StatsManager {
redis: redis::Client,
}
impl StatsManager {
pub fn new(url: impl Into<String>) -> RedisResult<StatsManager> {
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())
}
}

View File

@ -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";

View File

@ -0,0 +1,10 @@
pub fn escape(text: impl Into<String>) -> String {
let text: String = text.into();
text
.replace("\\", "\\\\")
.replace("*", "\\*")
.replace("_", "\\_")
.replace("~", "\\~")
.replace("`", "\\`")
}

99
src/utils/embed.rs 100644
View File

@ -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<String>,
pub title_url: Option<String>,
pub icon_url: Option<String>,
pub description: String,
pub status: Option<Status>,
pub footer: Option<String>,
}
pub struct EmbedBuilder {
embed: EmbedMessageOptions,
}
impl EmbedBuilder {
pub fn new() -> Self {
Self {
embed: EmbedMessageOptions::default(),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.embed.title = Some(title.into());
self
}
pub fn title_url(mut self, title_url: impl Into<String>) -> Self {
self.embed.title_url = Some(title_url.into());
self
}
pub fn icon_url(mut self, icon_url: impl Into<String>) -> Self {
self.embed.icon_url = Some(icon_url.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> 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<String>) -> 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
}

View File

@ -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);
}
}

View File

@ -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<String>) -> Result<String, String> {
@ -34,3 +38,98 @@ pub async fn get_username(token: impl Into<String>) -> Result<String, String> {
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<Image>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Track {
pub name: String,
pub artists: Vec<Artist>,
pub album: Album,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Show {
pub name: String,
pub images: Vec<Image>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Episode {
pub name: String,
pub show: Show,
}
pub async fn get_track_info(
token: impl Into<String>,
track: SpotifyId,
) -> Result<Track, Box<dyn Error>> {
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<String>,
episode: SpotifyId,
) -> Result<Episode, Box<dyn Error>> {
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?)
}