Uhm so yeah here's some c o d e
parent
6a77189343
commit
d530b182b3
|
@ -0,0 +1,5 @@
|
||||||
|
target/
|
||||||
|
.env
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
|
@ -6,4 +6,9 @@
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode
|
||||||
|
.vs
|
||||||
|
.fleet
|
|
@ -306,6 +306,16 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combine"
|
||||||
|
version = "4.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "command_attr"
|
name = "command_attr"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
@ -1897,6 +1907,20 @@ dependencies = [
|
||||||
"rand_core 0.5.1",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
|
@ -2287,6 +2311,12 @@ dependencies = [
|
||||||
"digest 0.10.3",
|
"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]]
|
[[package]]
|
||||||
name = "shannon"
|
name = "shannon"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
@ -2395,7 +2425,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spoticord"
|
name = "spoticord"
|
||||||
version = "2.0.0-indev"
|
version = "2.0.0-beta"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
@ -2403,12 +2433,12 @@ dependencies = [
|
||||||
"ipc-channel",
|
"ipc-channel",
|
||||||
"librespot",
|
"librespot",
|
||||||
"log",
|
"log",
|
||||||
|
"redis",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"samplerate",
|
"samplerate",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serenity",
|
"serenity",
|
||||||
"shell-words",
|
|
||||||
"songbird",
|
"songbird",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
[package]
|
[package]
|
||||||
name = "spoticord"
|
name = "spoticord"
|
||||||
version = "2.0.0-indev"
|
version = "2.0.0-beta"
|
||||||
edition = "2021"
|
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]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
@ -18,12 +20,12 @@ env_logger = "0.9.0"
|
||||||
ipc-channel = { version = "0.16.0", features = ["async"] }
|
ipc-channel = { version = "0.16.0", features = ["async"] }
|
||||||
librespot = { version = "0.4.2", default-features = false }
|
librespot = { version = "0.4.2", default-features = false }
|
||||||
log = "0.4.17"
|
log = "0.4.17"
|
||||||
|
redis = "0.22.1"
|
||||||
reqwest = "0.11.11"
|
reqwest = "0.11.11"
|
||||||
samplerate = "0.2.4"
|
samplerate = "0.2.4"
|
||||||
serde = "1.0.144"
|
serde = "1.0.144"
|
||||||
serde_json = "1.0.85"
|
serde_json = "1.0.85"
|
||||||
serenity = { version = "0.11.5", features = ["voice"] }
|
serenity = { version = "0.11.5", features = ["voice"] }
|
||||||
shell-words = "1.1.0"
|
|
||||||
songbird = "0.3.0"
|
songbird = "0.3.0"
|
||||||
thiserror = "1.0.33"
|
thiserror = "1.0.33"
|
||||||
tokio = { version = "1.20.1", features = ["rt", "full"] }
|
tokio = { version = "1.20.1", features = ["rt", "full"] }
|
||||||
|
|
|
@ -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"]
|
|
@ -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.
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Spoticord
|
||||||
|
|
||||||
|
Spoticord is a Discord music bot that allows you to control your music using the Spotify app.
|
|
@ -154,7 +154,7 @@ impl SinkAsBytes for StdoutSink {
|
||||||
buffer.len()
|
buffer.len()
|
||||||
};
|
};
|
||||||
|
|
||||||
while get_buffer_len() > BUFFER_SIZE * 5 {
|
while get_buffer_len() > BUFFER_SIZE * 2 {
|
||||||
std::thread::sleep(Duration::from_millis(15));
|
std::thread::sleep(Duration::from_millis(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -1,53 +1,34 @@
|
||||||
use log::error;
|
use log::error;
|
||||||
use serenity::{
|
use serenity::{
|
||||||
builder::CreateApplicationCommand,
|
builder::CreateApplicationCommand,
|
||||||
model::prelude::interaction::{
|
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
||||||
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
|
||||||
},
|
|
||||||
prelude::Context,
|
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";
|
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 {
|
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let data = ctx.data.read().await;
|
let data = ctx.data.read().await;
|
||||||
let database = data.get::<Database>().unwrap();
|
let database = data.get::<Database>().unwrap();
|
||||||
|
|
||||||
if let Ok(_) = database.get_user_account(command.user.id.to_string()).await {
|
if let Ok(_) = database.get_user_account(command.user.id.to_string()).await {
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
"You have already linked your Spotify account.",
|
.description("You have already linked your Spotify account.")
|
||||||
true,
|
.status(Status::Error)
|
||||||
)
|
.build(),
|
||||||
.await,
|
true,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -56,15 +37,22 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
|
||||||
let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap();
|
let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap();
|
||||||
let link = format!("{}/spotify/{}", base, request.token);
|
let link = format!("{}/spotify/{}", base, request.token);
|
||||||
|
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
format!("Go to the following URL to link your account:\n{}", link),
|
.title("Link your Spotify account")
|
||||||
true,
|
.title_url(&link)
|
||||||
)
|
.icon_url("https://spoticord.com/img/spotify-logo.png")
|
||||||
.await,
|
.description(format!(
|
||||||
);
|
"Go to [this link]({}) to connect your Spotify account.",
|
||||||
|
link
|
||||||
|
))
|
||||||
|
.status(Status::Info)
|
||||||
|
.build(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -77,30 +65,38 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
|
||||||
let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap();
|
let base = std::env::var("SPOTICORD_ACCOUNTS_URL").unwrap();
|
||||||
let link = format!("{}/spotify/{}", base, request.token);
|
let link = format!("{}/spotify/{}", base, request.token);
|
||||||
|
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
format!("Go to the following URL to link your account:\n{}", link),
|
.title("Link your Spotify account")
|
||||||
true,
|
.title_url(&link)
|
||||||
)
|
.icon_url("https://spoticord.com/img/spotify-logo.png")
|
||||||
.await,
|
.description(format!(
|
||||||
);
|
"Go to [this link]({}) to connect your Spotify account.",
|
||||||
|
link
|
||||||
|
))
|
||||||
|
.status(Status::Info)
|
||||||
|
.build(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(why) => {
|
Err(why) => {
|
||||||
error!("Error creating user request: {:?}", why);
|
error!("Error creating user request: {:?}", why);
|
||||||
|
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
"An error occurred while serving your request. Please try again later.",
|
.description("An error occurred while serving your request. Please try again later.")
|
||||||
true,
|
.status(Status::Error)
|
||||||
)
|
.build(),
|
||||||
.await,
|
true,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,2 +1,5 @@
|
||||||
|
pub mod help;
|
||||||
pub mod link;
|
pub mod link;
|
||||||
|
pub mod rename;
|
||||||
pub mod unlink;
|
pub mod unlink;
|
||||||
|
pub mod version;
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,42 +1,19 @@
|
||||||
use log::error;
|
use log::error;
|
||||||
use serenity::{
|
use serenity::{
|
||||||
builder::CreateApplicationCommand,
|
builder::CreateApplicationCommand,
|
||||||
model::prelude::interaction::{
|
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
||||||
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
|
||||||
},
|
|
||||||
prelude::Context,
|
prelude::Context,
|
||||||
Result as SerenityResult,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::commands::CommandOutput,
|
bot::commands::{respond_message, CommandOutput},
|
||||||
database::{Database, DatabaseError},
|
database::{Database, DatabaseError},
|
||||||
session::manager::SessionManager,
|
session::manager::SessionManager,
|
||||||
|
utils::embed::{EmbedBuilder, Status},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const NAME: &str = "unlink";
|
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 {
|
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let data = ctx.data.read().await;
|
let data = ctx.data.read().await;
|
||||||
|
@ -45,9 +22,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
|
||||||
|
|
||||||
// Disconnect session if user has any
|
// Disconnect session if user has any
|
||||||
if let Some(session) = session_manager.find(command.user.id).await {
|
if let Some(session) = session_manager.find(command.user.id).await {
|
||||||
if let Err(why) = session.disconnect().await {
|
session.disconnect().await;
|
||||||
error!("Error disconnecting session: {:?}", why);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists in the first place
|
// 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 let DatabaseError::InvalidStatusCode(status) = why {
|
||||||
if status == 404 {
|
if status == 404 {
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
"You cannot unlink your Spotify account if you currently don't have a linked Spotify account.",
|
.description("You cannot unlink your Spotify account if you haven't linked one.")
|
||||||
true,
|
.status(Status::Error)
|
||||||
)
|
.build(),
|
||||||
.await,
|
true,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -73,28 +49,30 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
|
||||||
|
|
||||||
error!("Error deleting user account: {:?}", why);
|
error!("Error deleting user account: {:?}", why);
|
||||||
|
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
|
||||||
&ctx,
|
&ctx,
|
||||||
&command,
|
&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,
|
true,
|
||||||
)
|
)
|
||||||
.await,
|
.await;
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
"Successfully unlinked your Spotify account from Spoticord",
|
.description("Successfully unlinked your Spotify account from Spoticord")
|
||||||
true,
|
.status(Status::Success)
|
||||||
)
|
.build(),
|
||||||
.await,
|
true,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -11,12 +11,39 @@ use serenity::{
|
||||||
prelude::{Context, TypeMapKey},
|
prelude::{Context, TypeMapKey},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::utils::embed::{make_embed_message, EmbedMessageOptions};
|
||||||
|
|
||||||
mod core;
|
mod core;
|
||||||
mod music;
|
mod music;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
mod ping;
|
mod ping;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
mod token;
|
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 CommandOutput = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||||
pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput;
|
pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput;
|
||||||
|
|
||||||
|
@ -44,12 +71,23 @@ impl CommandManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core commands
|
// 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::link::NAME, core::link::register, core::link::run);
|
||||||
instance.insert_command(
|
instance.insert_command(
|
||||||
core::unlink::NAME,
|
core::unlink::NAME,
|
||||||
core::unlink::register,
|
core::unlink::register,
|
||||||
core::unlink::run,
|
core::unlink::run,
|
||||||
);
|
);
|
||||||
|
instance.insert_command(
|
||||||
|
core::rename::NAME,
|
||||||
|
core::rename::register,
|
||||||
|
core::rename::run,
|
||||||
|
);
|
||||||
|
|
||||||
// Music commands
|
// Music commands
|
||||||
instance.insert_command(music::join::NAME, music::join::register, music::join::run);
|
instance.insert_command(music::join::NAME, music::join::register, music::join::run);
|
||||||
|
@ -58,6 +96,11 @@ impl CommandManager {
|
||||||
music::leave::register,
|
music::leave::register,
|
||||||
music::leave::run,
|
music::leave::run,
|
||||||
);
|
);
|
||||||
|
instance.insert_command(
|
||||||
|
music::playing::NAME,
|
||||||
|
music::playing::register,
|
||||||
|
music::playing::run,
|
||||||
|
);
|
||||||
|
|
||||||
instance
|
instance
|
||||||
}
|
}
|
||||||
|
@ -93,8 +136,8 @@ impl CommandManager {
|
||||||
cmds: &HashMap<String, CommandInfo>,
|
cmds: &HashMap<String, CommandInfo>,
|
||||||
mut commands: &'a mut CreateApplicationCommands,
|
mut commands: &'a mut CreateApplicationCommands,
|
||||||
) -> &'a mut CreateApplicationCommands {
|
) -> &'a mut CreateApplicationCommands {
|
||||||
for cmd in cmds {
|
for (_, command_info) in cmds {
|
||||||
commands = commands.create_application_command(|command| (cmd.1.register)(command));
|
commands = commands.create_application_command(|command| (command_info.register)(command));
|
||||||
}
|
}
|
||||||
|
|
||||||
commands
|
commands
|
||||||
|
|
|
@ -1,41 +1,17 @@
|
||||||
use log::error;
|
|
||||||
use serenity::{
|
use serenity::{
|
||||||
builder::CreateApplicationCommand,
|
builder::CreateApplicationCommand,
|
||||||
model::prelude::interaction::{
|
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
||||||
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
|
||||||
},
|
|
||||||
prelude::Context,
|
prelude::Context,
|
||||||
Result as SerenityResult,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::commands::CommandOutput,
|
bot::commands::{respond_message, CommandOutput},
|
||||||
session::manager::{SessionCreateError, SessionManager},
|
session::manager::{SessionCreateError, SessionManager},
|
||||||
|
utils::embed::{EmbedBuilder, Status},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const NAME: &str = "join";
|
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 {
|
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let guild = ctx.cache.guild(command.guild_id.unwrap()).unwrap();
|
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,
|
Some(channel_id) => channel_id,
|
||||||
None => {
|
None => {
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
"You need to connect to a voice channel",
|
.title("Cannot join voice channel")
|
||||||
true,
|
.icon_url("https://spoticord.com/static/image/prohibited.png")
|
||||||
)
|
.description("You need to connect to a voice channel")
|
||||||
.await,
|
.status(Status::Error)
|
||||||
);
|
.build(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -66,71 +45,143 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
|
||||||
let mut session_manager = data.get::<SessionManager>().unwrap().clone();
|
let mut session_manager = data.get::<SessionManager>().unwrap().clone();
|
||||||
|
|
||||||
// Check if another session is already active in this server
|
// Check if another session is already active in this server
|
||||||
if let Some(session) = session_manager.get_session(guild.id).await {
|
let session_opt = session_manager.get_session(guild.id).await;
|
||||||
let msg = if session.get_owner() == command.user.id {
|
if let Some(session) = &session_opt {
|
||||||
"You are already playing music in this server"
|
if let Some(owner) = session.get_owner().await {
|
||||||
} else {
|
let msg = if owner == command.user.id {
|
||||||
"Someone else is already playing music in this server"
|
"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
|
// Prevent duplicate Spotify sessions
|
||||||
if let Some(session) = session_manager.find(command.user.id).await {
|
if let Some(session) = session_manager.find(command.user.id).await {
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
|
||||||
&ctx,
|
&ctx,
|
||||||
&command,
|
&command,
|
||||||
|
EmbedBuilder::new()
|
||||||
|
.title("Cannot join voice channel")
|
||||||
|
.icon_url("https://spoticord.com/static/image/prohibited.png")
|
||||||
|
.description(
|
||||||
format!(
|
format!(
|
||||||
"You are already playing music in another server ({}).\nStop playing in that server first before joining this one.",
|
"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
|
ctx.cache.guild(session.get_guild_id()).unwrap().name
|
||||||
),
|
)).status(Status::Error).build(),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.await,
|
.await;
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the session, and handle potential errors
|
if let Some(session) = &session_opt {
|
||||||
if let Err(why) = session_manager
|
if let Err(why) = session.update_owner(&ctx, command.user.id).await {
|
||||||
.create_session(&ctx, guild.id, channel_id, command.user.id)
|
// Need to link first
|
||||||
.await
|
if let SessionCreateError::NoSpotifyError = why {
|
||||||
{
|
|
||||||
// Need to link first
|
|
||||||
if let SessionCreateError::NoSpotifyError = why {
|
|
||||||
check_msg(
|
|
||||||
respond_message(
|
respond_message(
|
||||||
&ctx,
|
&ctx,
|
||||||
&command,
|
&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,
|
true,
|
||||||
)
|
)
|
||||||
.await,
|
.await;
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any other error
|
// Any other error
|
||||||
check_msg(
|
|
||||||
respond_message(
|
respond_message(
|
||||||
&ctx,
|
&ctx,
|
||||||
&command,
|
&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,
|
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 </link:1036714850367320136> 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;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,38 +1,17 @@
|
||||||
use log::error;
|
|
||||||
use serenity::{
|
use serenity::{
|
||||||
builder::CreateApplicationCommand,
|
builder::CreateApplicationCommand,
|
||||||
model::prelude::interaction::{
|
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
||||||
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
|
||||||
},
|
|
||||||
prelude::Context,
|
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";
|
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 {
|
pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let data = ctx.data.read().await;
|
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 {
|
let session = match session_manager.get_session(command.guild_id.unwrap()).await {
|
||||||
Some(session) => session,
|
Some(session) => session,
|
||||||
None => {
|
None => {
|
||||||
check_msg(
|
respond_message(
|
||||||
respond_message(
|
&ctx,
|
||||||
&ctx,
|
&command,
|
||||||
&command,
|
EmbedBuilder::new()
|
||||||
"I'm currently not connected to any voice channel",
|
.title("Cannot disconnect bot")
|
||||||
true,
|
.icon_url("https://tabler-icons.io/static/tabler-icons/icons/ban.svg")
|
||||||
)
|
.description("I'm currently not connected to any voice channel")
|
||||||
.await,
|
.status(Status::Error)
|
||||||
);
|
.build(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if session.get_owner() != command.user.id {
|
if let Some(owner) = session.get_owner().await {
|
||||||
// This message was generated by AI, and I love it.
|
if owner != command.user.id {
|
||||||
check_msg(respond_message(&ctx, &command, "You are not the one who summoned me", true).await);
|
// This message was generated by AI, and I love it.
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(why) = session.disconnect().await {
|
|
||||||
error!("Error disconnecting from voice channel: {:?}", why);
|
|
||||||
|
|
||||||
check_msg(
|
|
||||||
respond_message(
|
respond_message(
|
||||||
&ctx,
|
&ctx,
|
||||||
&command,
|
&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,
|
true,
|
||||||
)
|
)
|
||||||
.await,
|
.await;
|
||||||
);
|
|
||||||
return;
|
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;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod join;
|
pub mod join;
|
||||||
pub mod leave;
|
pub mod leave;
|
||||||
|
pub mod playing;
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -3,10 +3,12 @@
|
||||||
use log::*;
|
use log::*;
|
||||||
use serenity::{
|
use serenity::{
|
||||||
async_trait,
|
async_trait,
|
||||||
model::prelude::{interaction::Interaction, Ready},
|
model::prelude::{interaction::Interaction, Activity, Ready},
|
||||||
prelude::{Context, EventHandler},
|
prelude::{Context, EventHandler},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::utils::consts::MOTD;
|
||||||
|
|
||||||
use super::commands::CommandManager;
|
use super::commands::CommandManager;
|
||||||
|
|
||||||
// Handler struct with a command parameter, an array of dictionary which takes a string and function
|
// 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;
|
command_manager.register_commands(&ctx).await;
|
||||||
|
|
||||||
|
ctx.set_activity(Activity::listening(MOTD)).await;
|
||||||
|
|
||||||
info!("{} has come online", ready.user.name);
|
info!("{} has come online", ready.user.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +40,7 @@ impl EventHandler for Handler {
|
||||||
response
|
response
|
||||||
.kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource)
|
.kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource)
|
||||||
.interaction_response_data(|message| {
|
.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
|
.await
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
|
// TODO: Check all image urls in embed responses
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
|
|
@ -18,6 +18,9 @@ pub enum DatabaseError {
|
||||||
|
|
||||||
#[error("An invalid status code was returned from a request: {0}")]
|
#[error("An invalid status code was returned from a request: {0}")]
|
||||||
InvalidStatusCode(StatusCode),
|
InvalidStatusCode(StatusCode),
|
||||||
|
|
||||||
|
#[error("An invalid input body was provided: {0}")]
|
||||||
|
InvalidInputBody(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
|
@ -78,6 +81,7 @@ enum Method {
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
|
Patch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
|
@ -112,6 +116,7 @@ impl Database {
|
||||||
Method::Post => client.post(url),
|
Method::Post => client.post(url),
|
||||||
Method::Put => client.put(url),
|
Method::Put => client.put(url),
|
||||||
Method::Delete => client.delete(url),
|
Method::Delete => client.delete(url),
|
||||||
|
Method::Patch => client.patch(url),
|
||||||
};
|
};
|
||||||
|
|
||||||
request = if let Some(body) = options.body {
|
request = if let Some(body) = options.body {
|
||||||
|
@ -157,9 +162,43 @@ impl Database {
|
||||||
|
|
||||||
Ok(body)
|
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 {
|
impl Database {
|
||||||
|
// Get Spoticord user
|
||||||
pub async fn get_user(&self, user_id: impl Into<String>) -> Result<User, DatabaseError> {
|
pub async fn get_user(&self, user_id: impl Into<String>) -> Result<User, DatabaseError> {
|
||||||
let path = format!("/user/{}", user_id.into());
|
let path = format!("/user/{}", user_id.into());
|
||||||
|
|
||||||
|
@ -202,6 +241,17 @@ impl Database {
|
||||||
Ok(body)
|
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
|
// Create the link Request for a user
|
||||||
pub async fn create_user_request(
|
pub async fn create_user_request(
|
||||||
&self,
|
&self,
|
||||||
|
@ -262,6 +312,42 @@ impl Database {
|
||||||
|
|
||||||
Ok(())
|
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 {
|
impl TypeMapKey for Database {
|
||||||
|
|
|
@ -9,4 +9,16 @@ pub enum IpcPacket {
|
||||||
|
|
||||||
StartPlayback,
|
StartPlayback,
|
||||||
StopPlayback,
|
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,
|
||||||
}
|
}
|
||||||
|
|
78
src/main.rs
78
src/main.rs
|
@ -4,9 +4,13 @@ use dotenv::dotenv;
|
||||||
use log::*;
|
use log::*;
|
||||||
use serenity::{framework::StandardFramework, prelude::GatewayIntents, Client};
|
use serenity::{framework::StandardFramework, prelude::GatewayIntents, Client};
|
||||||
use songbird::SerenityInit;
|
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 audio;
|
||||||
mod bot;
|
mod bot;
|
||||||
|
@ -15,10 +19,23 @@ mod ipc;
|
||||||
mod librespot_ext;
|
mod librespot_ext;
|
||||||
mod player;
|
mod player;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod stats;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() {
|
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();
|
env_logger::init();
|
||||||
|
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
|
@ -31,6 +48,8 @@ async fn main() {
|
||||||
|
|
||||||
player::main().await;
|
player::main().await;
|
||||||
|
|
||||||
|
debug!("Player exited, shutting down");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,6 +67,10 @@ async fn main() {
|
||||||
|
|
||||||
let token = env::var("TOKEN").expect("a token in the environment");
|
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 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
|
// Create client
|
||||||
let mut client = Client::builder(
|
let mut client = Client::builder(
|
||||||
|
@ -65,23 +88,56 @@ async fn main() {
|
||||||
|
|
||||||
data.insert::<Database>(Database::new(db_url, None));
|
data.insert::<Database>(Database::new(db_url, None));
|
||||||
data.insert::<CommandManager>(CommandManager::new());
|
data.insert::<CommandManager>(CommandManager::new());
|
||||||
data.insert::<SessionManager>(SessionManager::new());
|
data.insert::<SessionManager>(session_manager.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let shard_manager = client.shard_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::spawn(async move {
|
||||||
tokio::signal::ctrl_c()
|
loop {
|
||||||
.await
|
tokio::select! {
|
||||||
.expect("Could not register CTRL+C handler");
|
_ = 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 {
|
if let Err(why) = client.start_autosharded().await {
|
||||||
println!("Error in bot: {:?}", why);
|
error!("FATAL Error in bot: {:?}", why);
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,10 @@ use librespot::{
|
||||||
playback::{
|
playback::{
|
||||||
config::{Bitrate, PlayerConfig},
|
config::{Bitrate, PlayerConfig},
|
||||||
mixer::{self, MixerConfig},
|
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 serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
@ -24,6 +24,7 @@ use crate::{
|
||||||
pub struct SpoticordPlayer {
|
pub struct SpoticordPlayer {
|
||||||
client: ipc::Client,
|
client: ipc::Client,
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
|
spirc: Option<Spirc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpoticordPlayer {
|
impl SpoticordPlayer {
|
||||||
|
@ -31,6 +32,7 @@ impl SpoticordPlayer {
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
session: None,
|
session: None,
|
||||||
|
spirc: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,6 +51,11 @@ impl SpoticordPlayer {
|
||||||
// Log in using the token
|
// Log in using the token
|
||||||
let credentials = Credentials::with_token(username, &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
|
// Connect the session
|
||||||
let (session, _) = match Session::connect(session_config, credentials, None, false).await {
|
let (session, _) = match Session::connect(session_config, credentials, None, false).await {
|
||||||
Ok((session, credentials)) => (session, credentials),
|
Ok((session, credentials)) => (session, credentials),
|
||||||
|
@ -64,19 +71,18 @@ impl SpoticordPlayer {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
// Create the player
|
// Create the player
|
||||||
let (player, _) = Player::new(
|
let (player, mut receiver) = Player::new(
|
||||||
player_config,
|
player_config,
|
||||||
session.clone(),
|
session.clone(),
|
||||||
mixer.get_soft_volume(),
|
mixer.get_soft_volume(),
|
||||||
move || Box::new(StdoutSink::new(client)),
|
move || Box::new(StdoutSink::new(client)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut receiver = player.get_player_event_channel();
|
let (spirc, spirc_task) = Spirc::new(
|
||||||
|
|
||||||
let (_, spirc_run) = Spirc::new(
|
|
||||||
ConnectConfig {
|
ConnectConfig {
|
||||||
name: device_name.into(),
|
name: device_name.into(),
|
||||||
initial_volume: Some(65535),
|
// 75%
|
||||||
|
initial_volume: Some((65535 / 4) * 3),
|
||||||
..ConnectConfig::default()
|
..ConnectConfig::default()
|
||||||
},
|
},
|
||||||
session.clone(),
|
session.clone(),
|
||||||
|
@ -85,6 +91,7 @@ impl SpoticordPlayer {
|
||||||
);
|
);
|
||||||
|
|
||||||
let device_id = session.device_id().to_owned();
|
let device_id = session.device_id().to_owned();
|
||||||
|
let ipc = self.client.clone();
|
||||||
|
|
||||||
// IPC Handler
|
// IPC Handler
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
@ -103,12 +110,12 @@ impl SpoticordPlayer {
|
||||||
{
|
{
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if resp.status() == 202 {
|
if resp.status() == 202 {
|
||||||
info!("Successfully switched to device");
|
debug!("Successfully switched to device");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(why) => {
|
Err(why) => {
|
||||||
debug!("Failed to set device: {}", why);
|
error!("Failed to set device: {}", why);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -116,25 +123,76 @@ impl SpoticordPlayer {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Do IPC stuff with these events
|
// Do IPC stuff with these events
|
||||||
loop {
|
loop {
|
||||||
let event = match receiver.recv().await {
|
let event = match receiver.recv().await {
|
||||||
Some(event) => event,
|
Some(event) => event,
|
||||||
None => break,
|
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) {
|
pub fn stop(&mut self) {
|
||||||
if let Some(session) = self.session.take() {
|
if let Some(spirc) = self.spirc.take() {
|
||||||
session.shutdown();
|
spirc.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -162,13 +220,13 @@ pub async fn main() {
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
IpcPacket::Connect(token, device_name) => {
|
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;
|
player.start(token, device_name).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
IpcPacket::Disconnect => {
|
IpcPacket::Disconnect => {
|
||||||
info!("Disconnecting from Spotify");
|
debug!("Disconnecting from Spotify");
|
||||||
|
|
||||||
player.stop();
|
player.stop();
|
||||||
}
|
}
|
||||||
|
@ -185,6 +243,4 @@ pub async fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("We're done here, shutting down...");
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,12 +60,34 @@ impl SessionManager {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove (and destroy) a session
|
/// Remove a session
|
||||||
pub async fn remove_session(&mut self, guild_id: GuildId) {
|
pub async fn remove_session(&mut self, guild_id: GuildId) {
|
||||||
let mut sessions = self.sessions.write().await;
|
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);
|
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
|
/// Get a session by its guild ID
|
||||||
pub async fn get_session(&self, guild_id: GuildId) -> Option<Arc<SpoticordSession>> {
|
pub async fn get_session(&self, guild_id: GuildId) -> Option<Arc<SpoticordSession>> {
|
||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
|
@ -82,4 +104,19 @@ impl SessionManager {
|
||||||
|
|
||||||
sessions.get(&guild_id).cloned()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
use self::manager::{SessionCreateError, SessionManager};
|
use self::manager::{SessionCreateError, SessionManager};
|
||||||
use crate::{
|
use crate::{
|
||||||
database::{Database, DatabaseError},
|
database::{Database, DatabaseError},
|
||||||
ipc::{self, packet::IpcPacket},
|
ipc::{self, packet::IpcPacket, Client},
|
||||||
|
utils::{self, spotify},
|
||||||
};
|
};
|
||||||
use ipc_channel::ipc::IpcError;
|
use ipc_channel::ipc::IpcError;
|
||||||
|
use librespot::core::spotify_id::{SpotifyAudioType, SpotifyId};
|
||||||
use log::*;
|
use log::*;
|
||||||
use serenity::{
|
use serenity::{
|
||||||
async_trait,
|
async_trait,
|
||||||
model::prelude::{ChannelId, GuildId, UserId},
|
model::prelude::{ChannelId, GuildId, UserId},
|
||||||
prelude::Context,
|
prelude::{Context, RwLock},
|
||||||
};
|
};
|
||||||
use songbird::{
|
use songbird::{
|
||||||
create_player,
|
create_player,
|
||||||
error::JoinResult,
|
|
||||||
input::{children_to_reader, Input},
|
input::{children_to_reader, Input},
|
||||||
tracks::TrackHandle,
|
tracks::TrackHandle,
|
||||||
Call, Event, EventContext, EventHandler,
|
Call, Event, EventContext, EventHandler,
|
||||||
|
@ -25,9 +26,111 @@ use tokio::sync::Mutex;
|
||||||
|
|
||||||
pub mod manager;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct SpoticordSession {
|
pub struct SpoticordSession {
|
||||||
owner: UserId,
|
owner: Arc<RwLock<Option<UserId>>>,
|
||||||
guild_id: GuildId,
|
guild_id: GuildId,
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
|
|
||||||
|
@ -35,6 +138,10 @@ pub struct SpoticordSession {
|
||||||
|
|
||||||
call: Arc<Mutex<Call>>,
|
call: Arc<Mutex<Call>>,
|
||||||
track: TrackHandle,
|
track: TrackHandle,
|
||||||
|
|
||||||
|
playback_info: Arc<RwLock<Option<PlaybackInfo>>>,
|
||||||
|
|
||||||
|
client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpoticordSession {
|
impl SpoticordSession {
|
||||||
|
@ -92,8 +199,7 @@ impl SpoticordSession {
|
||||||
let mut call_mut = call.lock().await;
|
let mut call_mut = call.lock().await;
|
||||||
|
|
||||||
// Spawn player process
|
// Spawn player process
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let child = match Command::new(std::env::current_exe().unwrap())
|
||||||
let child = match Command::new(&args[0])
|
|
||||||
.args(["--player", &tx_name, &rx_name])
|
.args(["--player", &tx_name, &rx_name])
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::inherit())
|
.stderr(Stdio::inherit())
|
||||||
|
@ -126,9 +232,22 @@ impl SpoticordSession {
|
||||||
// Set call audio to track
|
// Set call audio to track
|
||||||
call_mut.play_only(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
|
// Clone variables for use in the IPC handler
|
||||||
let ipc_track = track_handle.clone();
|
let ipc_track = track_handle.clone();
|
||||||
let ipc_client = client.clone();
|
let ipc_client = client.clone();
|
||||||
|
let ipc_context = ctx.clone();
|
||||||
|
let mut ipc_instance = instance.clone();
|
||||||
|
|
||||||
// Handle IPC packets
|
// Handle IPC packets
|
||||||
// This will automatically quit once the IPC connection is closed
|
// This will automatically quit once the IPC connection is closed
|
||||||
|
@ -140,6 +259,9 @@ impl SpoticordSession {
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Required for IpcPacket::TrackChange to work
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
|
||||||
let msg = match ipc_client.recv() {
|
let msg = match ipc_client.recv() {
|
||||||
Ok(msg) => msg,
|
Ok(msg) => msg,
|
||||||
Err(why) => {
|
Err(why) => {
|
||||||
|
@ -153,29 +275,89 @@ impl SpoticordSession {
|
||||||
};
|
};
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
|
// Sink requests playback to start/resume
|
||||||
IpcPacket::StartPlayback => {
|
IpcPacket::StartPlayback => {
|
||||||
check_result(ipc_track.play());
|
check_result(ipc_track.play());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sink requests playback to pause
|
||||||
IpcPacket::StopPlayback => {
|
IpcPacket::StopPlayback => {
|
||||||
check_result(ipc_track.pause());
|
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
|
// 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(
|
call_mut.add_global_event(
|
||||||
songbird::Event::Core(songbird::CoreEvent::DriverDisconnect),
|
songbird::Event::Core(songbird::CoreEvent::DriverDisconnect),
|
||||||
instance.clone(),
|
instance.clone(),
|
||||||
|
@ -186,6 +368,7 @@ impl SpoticordSession {
|
||||||
instance.clone(),
|
instance.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Inform the player process to connect to Spotify
|
||||||
if let Err(why) = client.send(IpcPacket::Connect(token, user.device_name)) {
|
if let Err(why) = client.send(IpcPacket::Connect(token, user.device_name)) {
|
||||||
error!("Failed to send IpcPacket::Connect packet: {:?}", why);
|
error!("Failed to send IpcPacket::Connect packet: {:?}", why);
|
||||||
}
|
}
|
||||||
|
@ -193,7 +376,147 @@ impl SpoticordSession {
|
||||||
Ok(instance)
|
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);
|
info!("Disconnecting from voice channel {}", self.channel_id);
|
||||||
|
|
||||||
self
|
self
|
||||||
|
@ -206,17 +529,55 @@ impl SpoticordSession {
|
||||||
|
|
||||||
self.track.stop().unwrap_or(());
|
self.track.stop().unwrap_or(());
|
||||||
call.remove_all_global_events();
|
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 {
|
// Update playback info (duration, position, playing state)
|
||||||
self.owner
|
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 {
|
pub fn get_guild_id(&self) -> GuildId {
|
||||||
self.guild_id
|
self.guild_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the channel id this session is playing in
|
||||||
pub fn get_channel_id(&self) -> ChannelId {
|
pub fn get_channel_id(&self) -> ChannelId {
|
||||||
self.channel_id
|
self.channel_id
|
||||||
}
|
}
|
||||||
|
@ -228,10 +589,18 @@ impl EventHandler for SpoticordSession {
|
||||||
match ctx {
|
match ctx {
|
||||||
EventContext::DriverDisconnect(_) => {
|
EventContext::DriverDisconnect(_) => {
|
||||||
debug!("Driver disconnected, leaving voice channel");
|
debug!("Driver disconnected, leaving voice channel");
|
||||||
self.disconnect().await.ok();
|
self.disconnect().await;
|
||||||
}
|
}
|
||||||
EventContext::ClientDisconnect(who) => {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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";
|
|
@ -0,0 +1,10 @@
|
||||||
|
pub fn escape(text: impl Into<String>) -> String {
|
||||||
|
let text: String = text.into();
|
||||||
|
|
||||||
|
text
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("*", "\\*")
|
||||||
|
.replace("_", "\\_")
|
||||||
|
.replace("~", "\\~")
|
||||||
|
.replace("`", "\\`")
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -1,5 +1,8 @@
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub mod consts;
|
||||||
|
pub mod discord;
|
||||||
|
pub mod embed;
|
||||||
pub mod spotify;
|
pub mod spotify;
|
||||||
|
|
||||||
pub fn get_time() -> u64 {
|
pub fn get_time() -> u64 {
|
||||||
|
@ -8,3 +11,28 @@ pub fn get_time() -> u64 {
|
||||||
|
|
||||||
since_the_epoch.as_secs()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use librespot::core::spotify_id::SpotifyId;
|
||||||
use log::{error, trace};
|
use log::{error, trace};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
pub async fn get_username(token: impl Into<String>) -> Result<String, String> {
|
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");
|
error!("Missing 'id' field in body");
|
||||||
Err("Failed to parse body: Invalid body received".to_string())
|
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?)
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue