Uhm so yeah here's some c o d e
This commit is contained in:
		
							parent
							
								
									6a77189343
								
							
						
					
					
						commit
						d530b182b3
					
				
							
								
								
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | target/ | ||||||
|  | .env | ||||||
|  | .gitignore | ||||||
|  | .dockerignore | ||||||
|  | Dockerfile | ||||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -6,4 +6,9 @@ | |||||||
| *.sqlite | *.sqlite | ||||||
| 
 | 
 | ||||||
| # Secrets | # Secrets | ||||||
| .env | .env | ||||||
|  | 
 | ||||||
|  | # Editors | ||||||
|  | .vscode | ||||||
|  | .vs | ||||||
|  | .fleet | ||||||
							
								
								
									
										34
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										34
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -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"] } | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | # Builder | ||||||
|  | FROM rust:1.62-buster as builder | ||||||
|  | 
 | ||||||
|  | WORKDIR /app | ||||||
|  | 
 | ||||||
|  | # Add extra build dependencies here | ||||||
|  | RUN apt-get update && apt-get install -y cmake | ||||||
|  | 
 | ||||||
|  | COPY . . | ||||||
|  | RUN cargo install --path . | ||||||
|  | 
 | ||||||
|  | # Runtime | ||||||
|  | FROM debian:buster-slim | ||||||
|  | 
 | ||||||
|  | WORKDIR /app | ||||||
|  | 
 | ||||||
|  | # Add extra runtime dependencies here | ||||||
|  | RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* | ||||||
|  | 
 | ||||||
|  | # Copy spoticord binary from builder | ||||||
|  | COPY --from=builder /usr/local/cargo/bin/spoticord ./spoticord | ||||||
|  | 
 | ||||||
|  | CMD ["./spoticord"] | ||||||
							
								
								
									
										201
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,201 @@ | |||||||
|  |                                  Apache License | ||||||
|  |                            Version 2.0, January 2004 | ||||||
|  |                         http://www.apache.org/licenses/ | ||||||
|  | 
 | ||||||
|  |    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | ||||||
|  | 
 | ||||||
|  |    1. Definitions. | ||||||
|  | 
 | ||||||
|  |       "License" shall mean the terms and conditions for use, reproduction, | ||||||
|  |       and distribution as defined by Sections 1 through 9 of this document. | ||||||
|  | 
 | ||||||
|  |       "Licensor" shall mean the copyright owner or entity authorized by | ||||||
|  |       the copyright owner that is granting the License. | ||||||
|  | 
 | ||||||
|  |       "Legal Entity" shall mean the union of the acting entity and all | ||||||
|  |       other entities that control, are controlled by, or are under common | ||||||
|  |       control with that entity. For the purposes of this definition, | ||||||
|  |       "control" means (i) the power, direct or indirect, to cause the | ||||||
|  |       direction or management of such entity, whether by contract or | ||||||
|  |       otherwise, or (ii) ownership of fifty percent (50%) or more of the | ||||||
|  |       outstanding shares, or (iii) beneficial ownership of such entity. | ||||||
|  | 
 | ||||||
|  |       "You" (or "Your") shall mean an individual or Legal Entity | ||||||
|  |       exercising permissions granted by this License. | ||||||
|  | 
 | ||||||
|  |       "Source" form shall mean the preferred form for making modifications, | ||||||
|  |       including but not limited to software source code, documentation | ||||||
|  |       source, and configuration files. | ||||||
|  | 
 | ||||||
|  |       "Object" form shall mean any form resulting from mechanical | ||||||
|  |       transformation or translation of a Source form, including but | ||||||
|  |       not limited to compiled object code, generated documentation, | ||||||
|  |       and conversions to other media types. | ||||||
|  | 
 | ||||||
|  |       "Work" shall mean the work of authorship, whether in Source or | ||||||
|  |       Object form, made available under the License, as indicated by a | ||||||
|  |       copyright notice that is included in or attached to the work | ||||||
|  |       (an example is provided in the Appendix below). | ||||||
|  | 
 | ||||||
|  |       "Derivative Works" shall mean any work, whether in Source or Object | ||||||
|  |       form, that is based on (or derived from) the Work and for which the | ||||||
|  |       editorial revisions, annotations, elaborations, or other modifications | ||||||
|  |       represent, as a whole, an original work of authorship. For the purposes | ||||||
|  |       of this License, Derivative Works shall not include works that remain | ||||||
|  |       separable from, or merely link (or bind by name) to the interfaces of, | ||||||
|  |       the Work and Derivative Works thereof. | ||||||
|  | 
 | ||||||
|  |       "Contribution" shall mean any work of authorship, including | ||||||
|  |       the original version of the Work and any modifications or additions | ||||||
|  |       to that Work or Derivative Works thereof, that is intentionally | ||||||
|  |       submitted to Licensor for inclusion in the Work by the copyright owner | ||||||
|  |       or by an individual or Legal Entity authorized to submit on behalf of | ||||||
|  |       the copyright owner. For the purposes of this definition, "submitted" | ||||||
|  |       means any form of electronic, verbal, or written communication sent | ||||||
|  |       to the Licensor or its representatives, including but not limited to | ||||||
|  |       communication on electronic mailing lists, source code control systems, | ||||||
|  |       and issue tracking systems that are managed by, or on behalf of, the | ||||||
|  |       Licensor for the purpose of discussing and improving the Work, but | ||||||
|  |       excluding communication that is conspicuously marked or otherwise | ||||||
|  |       designated in writing by the copyright owner as "Not a Contribution." | ||||||
|  | 
 | ||||||
|  |       "Contributor" shall mean Licensor and any individual or Legal Entity | ||||||
|  |       on behalf of whom a Contribution has been received by Licensor and | ||||||
|  |       subsequently incorporated within the Work. | ||||||
|  | 
 | ||||||
|  |    2. Grant of Copyright License. Subject to the terms and conditions of | ||||||
|  |       this License, each Contributor hereby grants to You a perpetual, | ||||||
|  |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||||
|  |       copyright license to reproduce, prepare Derivative Works of, | ||||||
|  |       publicly display, publicly perform, sublicense, and distribute the | ||||||
|  |       Work and such Derivative Works in Source or Object form. | ||||||
|  | 
 | ||||||
|  |    3. Grant of Patent License. Subject to the terms and conditions of | ||||||
|  |       this License, each Contributor hereby grants to You a perpetual, | ||||||
|  |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||||
|  |       (except as stated in this section) patent license to make, have made, | ||||||
|  |       use, offer to sell, sell, import, and otherwise transfer the Work, | ||||||
|  |       where such license applies only to those patent claims licensable | ||||||
|  |       by such Contributor that are necessarily infringed by their | ||||||
|  |       Contribution(s) alone or by combination of their Contribution(s) | ||||||
|  |       with the Work to which such Contribution(s) was submitted. If You | ||||||
|  |       institute patent litigation against any entity (including a | ||||||
|  |       cross-claim or counterclaim in a lawsuit) alleging that the Work | ||||||
|  |       or a Contribution incorporated within the Work constitutes direct | ||||||
|  |       or contributory patent infringement, then any patent licenses | ||||||
|  |       granted to You under this License for that Work shall terminate | ||||||
|  |       as of the date such litigation is filed. | ||||||
|  | 
 | ||||||
|  |    4. Redistribution. You may reproduce and distribute copies of the | ||||||
|  |       Work or Derivative Works thereof in any medium, with or without | ||||||
|  |       modifications, and in Source or Object form, provided that You | ||||||
|  |       meet the following conditions: | ||||||
|  | 
 | ||||||
|  |       (a) You must give any other recipients of the Work or | ||||||
|  |           Derivative Works a copy of this License; and | ||||||
|  | 
 | ||||||
|  |       (b) You must cause any modified files to carry prominent notices | ||||||
|  |           stating that You changed the files; and | ||||||
|  | 
 | ||||||
|  |       (c) You must retain, in the Source form of any Derivative Works | ||||||
|  |           that You distribute, all copyright, patent, trademark, and | ||||||
|  |           attribution notices from the Source form of the Work, | ||||||
|  |           excluding those notices that do not pertain to any part of | ||||||
|  |           the Derivative Works; and | ||||||
|  | 
 | ||||||
|  |       (d) If the Work includes a "NOTICE" text file as part of its | ||||||
|  |           distribution, then any Derivative Works that You distribute must | ||||||
|  |           include a readable copy of the attribution notices contained | ||||||
|  |           within such NOTICE file, excluding those notices that do not | ||||||
|  |           pertain to any part of the Derivative Works, in at least one | ||||||
|  |           of the following places: within a NOTICE text file distributed | ||||||
|  |           as part of the Derivative Works; within the Source form or | ||||||
|  |           documentation, if provided along with the Derivative Works; or, | ||||||
|  |           within a display generated by the Derivative Works, if and | ||||||
|  |           wherever such third-party notices normally appear. The contents | ||||||
|  |           of the NOTICE file are for informational purposes only and | ||||||
|  |           do not modify the License. You may add Your own attribution | ||||||
|  |           notices within Derivative Works that You distribute, alongside | ||||||
|  |           or as an addendum to the NOTICE text from the Work, provided | ||||||
|  |           that such additional attribution notices cannot be construed | ||||||
|  |           as modifying the License. | ||||||
|  | 
 | ||||||
|  |       You may add Your own copyright statement to Your modifications and | ||||||
|  |       may provide additional or different license terms and conditions | ||||||
|  |       for use, reproduction, or distribution of Your modifications, or | ||||||
|  |       for any such Derivative Works as a whole, provided Your use, | ||||||
|  |       reproduction, and distribution of the Work otherwise complies with | ||||||
|  |       the conditions stated in this License. | ||||||
|  | 
 | ||||||
|  |    5. Submission of Contributions. Unless You explicitly state otherwise, | ||||||
|  |       any Contribution intentionally submitted for inclusion in the Work | ||||||
|  |       by You to the Licensor shall be under the terms and conditions of | ||||||
|  |       this License, without any additional terms or conditions. | ||||||
|  |       Notwithstanding the above, nothing herein shall supersede or modify | ||||||
|  |       the terms of any separate license agreement you may have executed | ||||||
|  |       with Licensor regarding such Contributions. | ||||||
|  | 
 | ||||||
|  |    6. Trademarks. This License does not grant permission to use the trade | ||||||
|  |       names, trademarks, service marks, or product names of the Licensor, | ||||||
|  |       except as required for reasonable and customary use in describing the | ||||||
|  |       origin of the Work and reproducing the content of the NOTICE file. | ||||||
|  | 
 | ||||||
|  |    7. Disclaimer of Warranty. Unless required by applicable law or | ||||||
|  |       agreed to in writing, Licensor provides the Work (and each | ||||||
|  |       Contributor provides its Contributions) on an "AS IS" BASIS, | ||||||
|  |       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | ||||||
|  |       implied, including, without limitation, any warranties or conditions | ||||||
|  |       of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | ||||||
|  |       PARTICULAR PURPOSE. You are solely responsible for determining the | ||||||
|  |       appropriateness of using or redistributing the Work and assume any | ||||||
|  |       risks associated with Your exercise of permissions under this License. | ||||||
|  | 
 | ||||||
|  |    8. Limitation of Liability. In no event and under no legal theory, | ||||||
|  |       whether in tort (including negligence), contract, or otherwise, | ||||||
|  |       unless required by applicable law (such as deliberate and grossly | ||||||
|  |       negligent acts) or agreed to in writing, shall any Contributor be | ||||||
|  |       liable to You for damages, including any direct, indirect, special, | ||||||
|  |       incidental, or consequential damages of any character arising as a | ||||||
|  |       result of this License or out of the use or inability to use the | ||||||
|  |       Work (including but not limited to damages for loss of goodwill, | ||||||
|  |       work stoppage, computer failure or malfunction, or any and all | ||||||
|  |       other commercial damages or losses), even if such Contributor | ||||||
|  |       has been advised of the possibility of such damages. | ||||||
|  | 
 | ||||||
|  |    9. Accepting Warranty or Additional Liability. While redistributing | ||||||
|  |       the Work or Derivative Works thereof, You may choose to offer, | ||||||
|  |       and charge a fee for, acceptance of support, warranty, indemnity, | ||||||
|  |       or other liability obligations and/or rights consistent with this | ||||||
|  |       License. However, in accepting such obligations, You may act only | ||||||
|  |       on Your own behalf and on Your sole responsibility, not on behalf | ||||||
|  |       of any other Contributor, and only if You agree to indemnify, | ||||||
|  |       defend, and hold each Contributor harmless for any liability | ||||||
|  |       incurred by, or claims asserted against, such Contributor by reason | ||||||
|  |       of your accepting any such warranty or additional liability. | ||||||
|  | 
 | ||||||
|  |    END OF TERMS AND CONDITIONS | ||||||
|  | 
 | ||||||
|  |    APPENDIX: How to apply the Apache License to your work. | ||||||
|  | 
 | ||||||
|  |       To apply the Apache License to your work, attach the following | ||||||
|  |       boilerplate notice, with the fields enclosed by brackets "[]" | ||||||
|  |       replaced with your own identifying information. (Don't include | ||||||
|  |       the brackets!)  The text should be enclosed in the appropriate | ||||||
|  |       comment syntax for the file format. We also recommend that a | ||||||
|  |       file or class name and description of purpose be included on the | ||||||
|  |       same "printed page" as the copyright notice for easier | ||||||
|  |       identification within third-party archives. | ||||||
|  | 
 | ||||||
|  |    Copyright [yyyy] [name of copyright owner] | ||||||
|  | 
 | ||||||
|  |    Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |    you may not use this file except in compliance with the License. | ||||||
|  |    You may obtain a copy of the License at | ||||||
|  | 
 | ||||||
|  |        http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | 
 | ||||||
|  |    Unless required by applicable law or agreed to in writing, software | ||||||
|  |    distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |    See the License for the specific language governing permissions and | ||||||
|  |    limitations under the License. | ||||||
							
								
								
									
										3
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @ -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)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										34
									
								
								src/bot/commands/core/help.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/bot/commands/core/help.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | |||||||
|  | use serenity::{ | ||||||
|  |   builder::CreateApplicationCommand, | ||||||
|  |   model::prelude::interaction::application_command::ApplicationCommandInteraction, | ||||||
|  |   prelude::Context, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | use crate::{ | ||||||
|  |   bot::commands::{respond_message, CommandOutput}, | ||||||
|  |   utils::embed::{EmbedBuilder, Status}, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | pub const NAME: &str = "help"; | ||||||
|  | 
 | ||||||
|  | pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||||
|  |   Box::pin(async move { | ||||||
|  |     respond_message( | ||||||
|  |       &ctx, | ||||||
|  |       &command, | ||||||
|  |       EmbedBuilder::new() | ||||||
|  |         .title("Spoticord Help") | ||||||
|  |         .icon_url("https://spoticord.com/img/logo-standard.webp") | ||||||
|  |         .description(format!("Click **[here](https://spoticord.com/commands)** for a list of commands.\n{}", | ||||||
|  |         "If you need help setting Spoticord up you can check out the **[Documentation](https://spoticord.com/documentation)** page on the Spoticord website.\n\n")) | ||||||
|  |         .status(Status::Info) | ||||||
|  |         .build(), | ||||||
|  |       false, | ||||||
|  |     ) | ||||||
|  |     .await; | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||||
|  |   command.name(NAME).description("Shows the help message") | ||||||
|  | } | ||||||
| @ -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; | ||||||
|  | |||||||
							
								
								
									
										165
									
								
								src/bot/commands/core/rename.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								src/bot/commands/core/rename.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,165 @@ | |||||||
|  | use log::error; | ||||||
|  | use reqwest::StatusCode; | ||||||
|  | use serenity::{ | ||||||
|  |   builder::CreateApplicationCommand, | ||||||
|  |   model::prelude::{ | ||||||
|  |     command::CommandOptionType, interaction::application_command::ApplicationCommandInteraction, | ||||||
|  |   }, | ||||||
|  |   prelude::Context, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | use crate::{ | ||||||
|  |   bot::commands::{respond_message, CommandOutput}, | ||||||
|  |   database::{Database, DatabaseError}, | ||||||
|  |   utils::{ | ||||||
|  |     self, | ||||||
|  |     embed::{EmbedBuilder, Status}, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | pub const NAME: &str = "rename"; | ||||||
|  | 
 | ||||||
|  | pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||||
|  |   Box::pin(async move { | ||||||
|  |     let data = ctx.data.read().await; | ||||||
|  |     let database = data.get::<Database>().unwrap(); | ||||||
|  | 
 | ||||||
|  |     // Check if user exists, if not, create them
 | ||||||
|  |     if let Err(why) = database.get_user(command.user.id.to_string()).await { | ||||||
|  |       match why { | ||||||
|  |         DatabaseError::InvalidStatusCode(StatusCode::NOT_FOUND) => { | ||||||
|  |           if let Err(why) = database.create_user(command.user.id.to_string()).await { | ||||||
|  |             error!("Error creating user: {:?}", why); | ||||||
|  | 
 | ||||||
|  |             respond_message( | ||||||
|  |               &ctx, | ||||||
|  |               &command, | ||||||
|  |               EmbedBuilder::new() | ||||||
|  |                 .description("Something went wrong while trying to rename your Spoticord device.") | ||||||
|  |                 .status(Status::Error) | ||||||
|  |                 .build(), | ||||||
|  |               true, | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  | 
 | ||||||
|  |             return; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         _ => { | ||||||
|  |           respond_message( | ||||||
|  |             &ctx, | ||||||
|  |             &command, | ||||||
|  |             EmbedBuilder::new() | ||||||
|  |               .description("Something went wrong while trying to rename your Spoticord device.") | ||||||
|  |               .status(Status::Error) | ||||||
|  |               .build(), | ||||||
|  |             true, | ||||||
|  |           ) | ||||||
|  |           .await; | ||||||
|  | 
 | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let device_name = match command.data.options.get(0) { | ||||||
|  |       Some(option) => match option.value { | ||||||
|  |         Some(ref value) => value.as_str().unwrap().to_string(), | ||||||
|  |         None => { | ||||||
|  |           respond_message( | ||||||
|  |             &ctx, | ||||||
|  |             &command, | ||||||
|  |             EmbedBuilder::new() | ||||||
|  |               .description("You need to provide a name for your Spoticord device.") | ||||||
|  |               .status(Status::Error) | ||||||
|  |               .build(), | ||||||
|  |             true, | ||||||
|  |           ) | ||||||
|  |           .await; | ||||||
|  | 
 | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       None => { | ||||||
|  |         respond_message( | ||||||
|  |           &ctx, | ||||||
|  |           &command, | ||||||
|  |           EmbedBuilder::new() | ||||||
|  |             .description("You need to provide a name for your Spoticord device.") | ||||||
|  |             .status(Status::Error) | ||||||
|  |             .build(), | ||||||
|  |           true, | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     if let Err(why) = database | ||||||
|  |       .update_user_device_name(command.user.id.to_string(), &device_name) | ||||||
|  |       .await | ||||||
|  |     { | ||||||
|  |       if let DatabaseError::InvalidInputBody(_) = why { | ||||||
|  |         respond_message( | ||||||
|  |           &ctx, | ||||||
|  |           &command, | ||||||
|  |           EmbedBuilder::new() | ||||||
|  |             .description( | ||||||
|  |               "Your device name must not exceed 16 characters and be at least 1 character long.", | ||||||
|  |             ) | ||||||
|  |             .status(Status::Error) | ||||||
|  |             .build(), | ||||||
|  |           true, | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       error!("Error updating user device name: {:?}", why); | ||||||
|  | 
 | ||||||
|  |       respond_message( | ||||||
|  |         &ctx, | ||||||
|  |         &command, | ||||||
|  |         EmbedBuilder::new() | ||||||
|  |           .description("Something went wrong while trying to rename your Spoticord device.") | ||||||
|  |           .status(Status::Error) | ||||||
|  |           .build(), | ||||||
|  |         true, | ||||||
|  |       ) | ||||||
|  |       .await; | ||||||
|  | 
 | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     respond_message( | ||||||
|  |       &ctx, | ||||||
|  |       &command, | ||||||
|  |       EmbedBuilder::new() | ||||||
|  |         .description(format!( | ||||||
|  |           "Successfully changed the Spotify device name to **{}**", | ||||||
|  |           utils::discord::escape(device_name) | ||||||
|  |         )) | ||||||
|  |         .status(Status::Success) | ||||||
|  |         .build(), | ||||||
|  |       true, | ||||||
|  |     ) | ||||||
|  |     .await; | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||||
|  |   command | ||||||
|  |     .name(NAME) | ||||||
|  |     .description("Set a new device name that is displayed in Spotify") | ||||||
|  |     .create_option(|option| { | ||||||
|  |       option | ||||||
|  |         .name("name") | ||||||
|  |         .description("The new device name") | ||||||
|  |         .kind(CommandOptionType::String) | ||||||
|  |         .max_length(16) | ||||||
|  |         .required(true) | ||||||
|  |     }) | ||||||
|  | } | ||||||
| @ -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; | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										49
									
								
								src/bot/commands/core/version.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/bot/commands/core/version.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | |||||||
|  | use log::error; | ||||||
|  | use serenity::{ | ||||||
|  |   builder::CreateApplicationCommand, | ||||||
|  |   model::prelude::interaction::{ | ||||||
|  |     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||||
|  |   }, | ||||||
|  |   prelude::Context, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | use crate::{ | ||||||
|  |   bot::commands::CommandOutput, | ||||||
|  |   utils::{consts::VERSION, embed::Status}, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | pub const NAME: &str = "version"; | ||||||
|  | 
 | ||||||
|  | pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||||
|  |   Box::pin(async move { | ||||||
|  |     if let Err(why) = command | ||||||
|  |       .create_interaction_response(&ctx.http, |response| { | ||||||
|  |         response | ||||||
|  |           .kind(InteractionResponseType::ChannelMessageWithSource) | ||||||
|  |           .interaction_response_data(|message| { | ||||||
|  |             message.embed(|embed| { | ||||||
|  |               embed | ||||||
|  |                 .title("Spoticord Version") | ||||||
|  |                 .author(|author| { | ||||||
|  |                   author | ||||||
|  |                     .name("Maintained by: RoDaBaFilms") | ||||||
|  |                     .url("https://rodabafilms.com/") | ||||||
|  |                     .icon_url("https://rodabafilms.com/logo_2021_nobg.png") | ||||||
|  |                 }) | ||||||
|  |                 .description(format!("Current version: {}\n\nSpoticord is open source, check out [our GitHub](https://github.com/SpoticordMusic)", VERSION)) | ||||||
|  |                 .color(Status::Info as u64) | ||||||
|  |             }) | ||||||
|  |           }) | ||||||
|  |       }) | ||||||
|  |       .await | ||||||
|  |     { | ||||||
|  |       error!("Error sending message: {:?}", why); | ||||||
|  |     } | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||||
|  |   command | ||||||
|  |     .name(NAME) | ||||||
|  |     .description("Shows the current running version of Spoticord") | ||||||
|  | } | ||||||
| @ -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; | ||||||
|  | |||||||
							
								
								
									
										170
									
								
								src/bot/commands/music/playing.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/bot/commands/music/playing.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,170 @@ | |||||||
|  | use librespot::core::spotify_id::SpotifyAudioType; | ||||||
|  | use log::error; | ||||||
|  | use serenity::{ | ||||||
|  |   builder::CreateApplicationCommand, | ||||||
|  |   model::prelude::interaction::{ | ||||||
|  |     application_command::ApplicationCommandInteraction, InteractionResponseType, | ||||||
|  |   }, | ||||||
|  |   prelude::Context, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | use crate::{ | ||||||
|  |   bot::commands::{respond_message, CommandOutput}, | ||||||
|  |   session::manager::SessionManager, | ||||||
|  |   utils::{embed::{EmbedBuilder, Status}, self}, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | pub const NAME: &str = "playing"; | ||||||
|  | 
 | ||||||
|  | pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { | ||||||
|  |   Box::pin(async move { | ||||||
|  |     let not_playing = async { | ||||||
|  |       respond_message( | ||||||
|  |         &ctx, | ||||||
|  |         &command, | ||||||
|  |         EmbedBuilder::new() | ||||||
|  |           .title("Cannot get track info") | ||||||
|  |           .icon_url("https://tabler-icons.io/static/tabler-icons/icons/ban.svg") | ||||||
|  |           .description("I'm currently not playing any music in this server") | ||||||
|  |           .status(Status::Error) | ||||||
|  |           .build(), | ||||||
|  |         true, | ||||||
|  |       ) | ||||||
|  |       .await; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     let data = ctx.data.read().await; | ||||||
|  |     let session_manager = data.get::<SessionManager>().unwrap().clone(); | ||||||
|  | 
 | ||||||
|  |     let session = match session_manager.get_session(command.guild_id.unwrap()).await { | ||||||
|  |       Some(session) => session, | ||||||
|  |       None => { | ||||||
|  |         not_playing.await; | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     let owner = match session.get_owner().await { | ||||||
|  |       Some(owner) => owner, | ||||||
|  |       None => { | ||||||
|  |         not_playing.await; | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // Get Playback Info from session
 | ||||||
|  |     let pbi = match session.get_playback_info().await { | ||||||
|  |       Some(pbi) => pbi, | ||||||
|  |       None => { | ||||||
|  |         not_playing.await; | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     let spotify_id = match pbi.spotify_id { | ||||||
|  |       Some(spotify_id) => spotify_id, | ||||||
|  |       None => { | ||||||
|  |         not_playing.await; | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // Get audio type
 | ||||||
|  |     let audio_type = if spotify_id.audio_type == SpotifyAudioType::Track { | ||||||
|  |       "track" | ||||||
|  |     } else { | ||||||
|  |       "episode" | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // Create title
 | ||||||
|  |     let title = format!("{} - {}", pbi.get_artists().unwrap(), pbi.get_name().unwrap()); | ||||||
|  | 
 | ||||||
|  |     // Create description
 | ||||||
|  |     let mut description = String::new(); | ||||||
|  | 
 | ||||||
|  |     let position = pbi.get_position(); | ||||||
|  |     let spot = position * 20 / pbi.duration_ms; | ||||||
|  | 
 | ||||||
|  |     description.push_str(if pbi.is_playing { "▶️ " } else { "⏸️ " }); | ||||||
|  | 
 | ||||||
|  |     for i in 0..20 { | ||||||
|  |       if i == spot { | ||||||
|  |         description.push_str("🔵"); | ||||||
|  |       } else { | ||||||
|  |         description.push_str("▬"); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     description.push_str("\n:alarm_clock: "); | ||||||
|  |     description.push_str(&format!("{} / {}", utils::time_to_str(position / 1000), utils::time_to_str(pbi.duration_ms / 1000))); | ||||||
|  | 
 | ||||||
|  |     // Get owner of session
 | ||||||
|  |     let owner = match ctx.cache.user(owner) { | ||||||
|  |       Some(user) => user, | ||||||
|  |       None => { | ||||||
|  |         // This shouldn't happen
 | ||||||
|  |         // TODO: This can happen, idk when
 | ||||||
|  | 
 | ||||||
|  |         error!("Could not find user with id {}", owner); | ||||||
|  | 
 | ||||||
|  |         respond_message( | ||||||
|  |           &ctx, | ||||||
|  |           &command, | ||||||
|  |           EmbedBuilder::new() | ||||||
|  |             .title("[INTERNAL ERROR] Cannot get track info") | ||||||
|  |             .description(format!("Could not find user with id {}\nThis is an issue with the bot!", owner)) | ||||||
|  |             .status(Status::Error) | ||||||
|  |             .build(), | ||||||
|  |           true, | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // Get the thumbnail image
 | ||||||
|  |     let thumbnail = pbi.get_thumbnail_url().unwrap(); | ||||||
|  | 
 | ||||||
|  |     if let Err(why) = command | ||||||
|  |       .create_interaction_response(&ctx.http, |response| { | ||||||
|  |         response | ||||||
|  |           .kind(InteractionResponseType::ChannelMessageWithSource) | ||||||
|  |           .interaction_response_data(|message| { | ||||||
|  |             message | ||||||
|  |               .embed(|embed| 
 | ||||||
|  |                 embed | ||||||
|  |                   .author(|author| 
 | ||||||
|  |                     author | ||||||
|  |                       .name("Currently Playing") | ||||||
|  |                       .icon_url("https://www.freepnglogos.com/uploads/spotify-logo-png/file-spotify-logo-png-4.png") | ||||||
|  |                     ) | ||||||
|  |                   .title(title) | ||||||
|  |                   .url(format!("https://open.spotify.com/{}/{}", audio_type, spotify_id.to_base62().unwrap())) | ||||||
|  |                   .description(description) | ||||||
|  |                   .footer(|footer| 
 | ||||||
|  |                     footer | ||||||
|  |                       .text(&owner.name) | ||||||
|  |                       .icon_url(owner.face()) | ||||||
|  |                   ) | ||||||
|  |                   .thumbnail(&thumbnail) | ||||||
|  |                   .color(Status::Success as u64) | ||||||
|  |               ) | ||||||
|  |           }) | ||||||
|  |       }) | ||||||
|  |       .await | ||||||
|  |     { | ||||||
|  |       error!("Error sending message: {:?}", why); | ||||||
|  |     } | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { | ||||||
|  |   command | ||||||
|  |     .name(NAME) | ||||||
|  |     .description("Display which song is currently being played") | ||||||
|  | } | ||||||
| @ -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; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|       _ => {} |       _ => {} | ||||||
|     } |     } | ||||||
|  | |||||||
							
								
								
									
										26
									
								
								src/stats/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/stats/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | |||||||
|  | use redis::{Commands, RedisResult}; | ||||||
|  | 
 | ||||||
|  | #[derive(Clone)] | ||||||
|  | pub struct StatsManager { | ||||||
|  |   redis: redis::Client, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl StatsManager { | ||||||
|  |   pub fn new(url: impl Into<String>) -> RedisResult<StatsManager> { | ||||||
|  |     let redis = redis::Client::open(url.into())?; | ||||||
|  | 
 | ||||||
|  |     Ok(StatsManager { redis }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn set_server_count(&self, count: usize) -> RedisResult<()> { | ||||||
|  |     let mut con = self.redis.get_connection()?; | ||||||
|  | 
 | ||||||
|  |     con.set("sc-bot-total-servers", count.to_string()) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn set_active_count(&self, count: usize) -> RedisResult<()> { | ||||||
|  |     let mut con = self.redis.get_connection()?; | ||||||
|  | 
 | ||||||
|  |     con.set("sc-bot-active-servers", count.to_string()) | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								src/utils/consts.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/utils/consts.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); | ||||||
|  | pub const MOTD: &str = "OPEN BETA (v2)"; | ||||||
|  | // pub const MOTD: &str = "some good 'ol music";
 | ||||||
							
								
								
									
										10
									
								
								src/utils/discord.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/utils/discord.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | pub fn escape(text: impl Into<String>) -> String { | ||||||
|  |   let text: String = text.into(); | ||||||
|  | 
 | ||||||
|  |   text | ||||||
|  |     .replace("\\", "\\\\") | ||||||
|  |     .replace("*", "\\*") | ||||||
|  |     .replace("_", "\\_") | ||||||
|  |     .replace("~", "\\~") | ||||||
|  |     .replace("`", "\\`") | ||||||
|  | } | ||||||
							
								
								
									
										99
									
								
								src/utils/embed.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/utils/embed.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,99 @@ | |||||||
|  | use serenity::builder::CreateEmbed; | ||||||
|  | 
 | ||||||
|  | #[allow(dead_code)] | ||||||
|  | pub enum Status { | ||||||
|  |   Info = 0x0773D6, | ||||||
|  |   Success = 0x3BD65D, | ||||||
|  |   Warning = 0xF0D932, | ||||||
|  |   Error = 0xFC1F28, | ||||||
|  |   None = 0, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Default)] | ||||||
|  | pub struct EmbedMessageOptions { | ||||||
|  |   pub title: Option<String>, | ||||||
|  |   pub title_url: Option<String>, | ||||||
|  |   pub icon_url: Option<String>, | ||||||
|  |   pub description: String, | ||||||
|  |   pub status: Option<Status>, | ||||||
|  |   pub footer: Option<String>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub struct EmbedBuilder { | ||||||
|  |   embed: EmbedMessageOptions, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl EmbedBuilder { | ||||||
|  |   pub fn new() -> Self { | ||||||
|  |     Self { | ||||||
|  |       embed: EmbedMessageOptions::default(), | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn title(mut self, title: impl Into<String>) -> Self { | ||||||
|  |     self.embed.title = Some(title.into()); | ||||||
|  |     self | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn title_url(mut self, title_url: impl Into<String>) -> Self { | ||||||
|  |     self.embed.title_url = Some(title_url.into()); | ||||||
|  |     self | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn icon_url(mut self, icon_url: impl Into<String>) -> Self { | ||||||
|  |     self.embed.icon_url = Some(icon_url.into()); | ||||||
|  |     self | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn description(mut self, description: impl Into<String>) -> Self { | ||||||
|  |     self.embed.description = description.into(); | ||||||
|  |     self | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn status(mut self, status: Status) -> Self { | ||||||
|  |     self.embed.status = Some(status); | ||||||
|  |     self | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   pub fn footer(mut self, footer: impl Into<String>) -> Self { | ||||||
|  |     self.embed.footer = Some(footer.into()); | ||||||
|  |     self | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Build the embed
 | ||||||
|  |   pub fn build(self) -> EmbedMessageOptions { | ||||||
|  |     self.embed | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub fn make_embed_message<'a>( | ||||||
|  |   embed: &'a mut CreateEmbed, | ||||||
|  |   options: EmbedMessageOptions, | ||||||
|  | ) -> &'a mut CreateEmbed { | ||||||
|  |   let status = options.status.unwrap_or(Status::None); | ||||||
|  | 
 | ||||||
|  |   embed.author(|author| { | ||||||
|  |     if let Some(title) = options.title { | ||||||
|  |       author.name(title); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if let Some(title_url) = options.title_url { | ||||||
|  |       author.url(title_url); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if let Some(icon_url) = options.icon_url { | ||||||
|  |       author.icon_url(icon_url); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     author | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   if let Some(text) = options.footer { | ||||||
|  |     embed.footer(|footer| footer.text(text)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   embed.description(options.description); | ||||||
|  |   embed.color(status as u32); | ||||||
|  | 
 | ||||||
|  |   embed | ||||||
|  | } | ||||||
| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 DaXcess
						DaXcess