Update Spoticord to v2.2.0

PR: Update Spoticord to v2.2.0
main
Daniel 2024-08-13 15:26:59 +02:00 committed by GitHub
commit a93c2ff0ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 7277 additions and 5211 deletions

View File

@ -1,8 +1,2 @@
[registries.crates-io]
protocol = "sparse"
[target.x86_64-pc-windows-gnu]
rustflags = "-C link-args=-lssp" # Does not compile without this line
[target.aarch64-unknown-linux-gnu]
rustflags = "-C linker=aarch64-linux-gnu-gcc"
rustflags = "-C linker=aarch64-linux-gnu-gcc"

View File

@ -1,5 +1,8 @@
target/
.env
.gitignore
.dockerignore
Dockerfile
# .dockerignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# build and deps
/target
# env files
.env*

View File

@ -2,16 +2,16 @@ name: Build and push to registry
on:
push:
branches: [ "main", "dev" ]
tags: [ "v*.*.*" ]
branches: ["main", "dev"]
tags: ["v*.*.*"]
pull_request:
branches: [ "main", "dev" ]
branches: ["main", "dev"]
workflow_dispatch:
permissions:
packages: write
contents: read
jobs:
build-and-push:
name: Build Docker image and push to registry
@ -53,6 +53,15 @@ jobs:
type=semver,pattern={{major}}
type=sha
- name: Inject Docker cache
uses: reproducible-containers/buildkit-cache-dance@v3.1.0
with:
cache-map: |
{
"usr-local-cargo-registry": "/usr/local/cargo/registry",
"app-target": "/app/target"
}
- name: Build image and push to registry
uses: docker/build-push-action@v5
with:
@ -62,5 +71,6 @@ jobs:
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
# Some basic caching of the layers...
cache-from: ${{ steps.repo-uri-string.outputs.lowercase }}:latest-cache
cache-to: ${{ steps.repo-uri-string.outputs.lowercase }}:latest-cache
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false

View File

@ -10,15 +10,15 @@ jobs:
clippy:
name: Run Clippy
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Run Cargo Clippy
run: cargo clippy --all-targets --all-features -- -D warnings

4
.gitignore vendored
View File

@ -1,10 +1,6 @@
# Rust
/target
# SQLite database
*.db
*.sqlite
# Secrets
.env

View File

@ -1 +0,0 @@
tab_spaces = 2

View File

@ -1,26 +1,44 @@
# Changelog
## 2.2.0 | TBD
### Changes
- Rewrote the entire bot (again)
- Updated librespot from v0.4.2 to v0.5.0-dev
- Added `/lyrics`, which provides the user with an auto-updating lyrics embed
- Added `/stop`, which disconnects the bot from Spotify without leaving the call (will still leave after 5 minutes)
- Changed `/playing` to automatically update the embed accordingly
- Renamed `/leave` to `/disconnect`
- Removed the Database API, replaced with direct connection to a Postgres database
**Full Changelog** (good luck): https://github.com/SpoticordMusic/spoticord/compare/v2.1.2..v2.2.0
## 2.1.2 | September 28th 2023
### Changes
* Removed OpenSSL dependency
* Added aarch64 support
* Added cross compilation to Github Actions
* Added `dev` branch to Github Actions
* Removed hardcoded URL in the /join command
* Fixed an issue in /playing where the bot showed it was playing even though it was paused
- Removed OpenSSL dependency
- Added aarch64 support
- Added cross compilation to Github Actions
- Added `dev` branch to Github Actions
- Removed hardcoded URL in the /join command
- Fixed an issue in /playing where the bot showed it was playing even though it was paused
**Full Changelog**: https://github.com/SpoticordMusic/spoticord/compare/v2.1.1...v2.1.2
## 2.1.1 | September 23rd 2023
Reduced the amount of CPU that the bot uses from ~15%-25% per user to 1%-2% per user (percentage per core, benched on an AMD Ryzen 9 5950X).
### Changes
* Fixed issue #20
- Fixed issue #20
**Full Changelog**: https://github.com/SpoticordMusic/spoticord/compare/v2.1.0...v2.1.1
## 2.1.0 | September 20th 2023
So, it's been a while since I worked on this project, and some bugs have since been discovered.
The main focus for this version is to stop using multiple processes for every player, and instead do everything in threads.
@ -37,11 +55,12 @@ The main focus for this version is to stop using multiple processes for every pl
- Enable autoplay
- After skipping a song, you will no longer hear a tiny bit of the previous song after the silence
**Full Changelog**: https://github.com/SpoticordMusic/spoticord/compare/v2.0.0...v2.1.0
### Issues
- Currently, the CPU usage is much higher than it used to be. I really wanted to push this update out before taking the time to do some optimizations, as the bot and server are still easily able to hold up the limited amount of Spoticord users (and v2.0.0 was just falling apart). Issue is being tracked in #20
## 2.0.0 | June 8th 2023
- Initial Release
- Initial Release

View File

@ -1,8 +1,11 @@
# Compiling from source
## Initial setup
Spoticord is built using [rust](https://www.rust-lang.org/), so you'll need to install that first. It is cross-platform, so it should work on Windows, Linux and MacOS. You can find more info about how to install rust [here](https://www.rust-lang.org/tools/install).
### Rust formatter
Spoticord uses [rustfmt](https://github.com/rust-lang/rustfmt) to format the code, and we ask everyone that contributes to Spoticord to use it as well. You can install it by running the following command in your terminal:
```sh
@ -12,6 +15,7 @@ rustup component add rustfmt
If you are using VSCode, you can install the [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer) extension, which will automatically format your code when you save it (if you have `format on save` enabled). Although rust-analyzer is recommended anyway, as it provides a lot of useful features.
## Build dependencies
On Windows you'll need to install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2019) to be able to compile executables in rust (this will also be explained during the rust installation).
If you are on Linux, you can use your package manager to install the following dependencies:
@ -41,6 +45,7 @@ sudo dnf install cmake
```
## Compiling
Now that you have all the dependencies installed, you can compile Spoticord. To do this, you'll first need to clone the repository:
```sh
@ -76,9 +81,9 @@ cargo run
As of now, Spoticord has one optional feature: `stats`. This feature enables collecting a few statistics, total and active servers. These statistics will be sent to a redis server, where they then can be read for whatever purpose. If you want to enable this feature, you can do so by running the following command:
```sh
cargo build [--release] --features metrics
cargo build [--release] --features stats
```
# MSRV
The current minimum supported rust version is `1.67.0`.
The current minimum supported rust version is `1.75.0` _(Checked with `cargo-msrv`)_.

View File

@ -40,8 +40,8 @@ We make use of `rustfmt` to format our code. You can install it by running `rust
We make use of the pre-commit git hook to run `clippy` before you commit your code. To set up the git hooks you can run the following command:
```bash
git config core.hooksPath .githooks
```
```bash
git config core.hooksPath .githooks
```
If you want to skip this check, you can use the `--no-verify` flag in your git commit command. Do note however that code that does not pass these checks will not be merged.
If you want to skip this check, you can use the `--no-verify` flag in your git commit command. Do note however that code that does not pass these checks will not be merged.

3897
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +1,46 @@
[package]
name = "spoticord"
version = "2.1.2"
version = "2.2.0"
edition = "2021"
rust-version = "1.65.0"
rust-version = "1.75.0"
[[bin]]
name = "spoticord"
path = "src/main.rs"
[workspace]
members = [
"spoticord_audio",
"spoticord_config",
"spoticord_database",
"spoticord_player",
"spoticord_session",
"spoticord_utils",
"spoticord_stats",
]
[features]
stats = ["redis"]
default = ["stats"]
stats = ["spoticord_stats"]
[dependencies]
anyhow = "1.0.75"
dotenv = "0.15.0"
env_logger = "0.10.0"
hex = "0.4.3"
lazy_static = "1.4.0"
librespot = { version = "0.4.2", default-features = false }
log = "0.4.20"
protobuf = "2.28.0"
redis = { version = "0.23.3", optional = true, default-features = false }
reqwest = { version = "0.11.20", default-features = false }
samplerate = "0.2.4"
serde = "1.0.188"
serde_json = "1.0.107"
serenity = { version = "0.11.6", features = ["framework", "cache", "standard_framework", "rustls_backend", "gateway"], default-features = false }
songbird = { version = "0.3.2", features = ["driver", "serenity-rustls"], default-features = false }
thiserror = "1.0.48"
tokio = { version = "1.32.0", features = ["rt", "full"] }
zerocopy = "0.7.5"
spoticord_config = { path = "./spoticord_config" }
spoticord_database = { path = "./spoticord_database" }
spoticord_player = { path = "./spoticord_player" }
spoticord_session = { path = "./spoticord_session" }
spoticord_utils = { path = "./spoticord_utils" }
spoticord_stats = { path = "./spoticord_stats", optional = true }
anyhow = "1.0.86"
dotenvy = "0.15.7"
env_logger = "0.11.5"
log = "0.4.22"
poise = "0.6.1"
serenity = "0.12.2"
songbird = { version = "0.4.3", features = ["simd-json"] }
tokio = { version = "1.39.2", features = ["full"] }
[profile.release]
opt-level = 3
lto = true
strip = true

View File

@ -1,44 +1,47 @@
# Builder
FROM --platform=linux/amd64 rust:1.72.1-buster as builder
FROM --platform=linux/amd64 rust:1.79.0-buster AS builder
WORKDIR /app
# Add extra build dependencies here
RUN apt-get update && apt-get install -yqq \
cmake gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
cmake gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
COPY . .
RUN rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
RUN rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
# Remove `--features=stats` if you want to deploy without stats collection
RUN cargo build --features=stats --release \
--target=x86_64-unknown-linux-gnu --target=aarch64-unknown-linux-gnu
# Add `--no-default-features` if you don't want stats collection
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --target=x86_64-unknown-linux-gnu --target=aarch64-unknown-linux-gnu && \
# Copy the executables outside of /target as it'll get unmounted after this RUN command
cp /app/target/x86_64-unknown-linux-gnu/release/spoticord /app/x86_64 && \
cp /app/target/aarch64-unknown-linux-gnu/release/spoticord /app/aarch64
# Runtime
FROM debian:buster-slim
ARG TARGETPLATFORM
ENV TARGETPLATFORM=$TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM}
# Add extra runtime dependencies here
# RUN apt-get update && apt-get install -yqq --no-install-recommends \
# openssl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt update && apt install -y ca-certificates
# Copy spoticord binaries from builder to /tmp
# Copy spoticord binaries from builder to /tmp so we can dynamically use them
COPY --from=builder \
/app/target/x86_64-unknown-linux-gnu/release/spoticord /tmp/x86_64
/app/x86_64 /tmp/x86_64
COPY --from=builder \
/app/target/aarch64-unknown-linux-gnu/release/spoticord /tmp/aarch64
/app/aarch64 /tmp/aarch64
# Copy appropiate binary for target arch from /tmp
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
cp /tmp/x86_64 /usr/local/bin/spoticord; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
cp /tmp/aarch64 /usr/local/bin/spoticord; \
# Copy appropriate binary for target arch from /tmp
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
cp /tmp/x86_64 /usr/local/bin/spoticord; \
elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
cp /tmp/aarch64 /usr/local/bin/spoticord; \
fi
# Delete unused binaries
RUN rm -rvf /tmp/x86_64 /tmp/aarch64
ENTRYPOINT [ "/usr/local/bin/spoticord" ]
ENTRYPOINT [ "/usr/local/bin/spoticord" ]

143
LICENSE
View File

@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -5,34 +5,46 @@ Spoticord is built on top of [librespot](https://github.com/librespot-org/libres
Being built on top of rust, Spoticord is relatively lightweight and can run on low-spec hardware.
## How to use
### Official bot
Spoticord is being hosted as an official bot. You can find more info about how to use this bot over at [the Spoticord website](https://spoticord.com/).
### Environment variables
Spoticord uses environment variables to configure itself. The following variables are required:
- `DISCORD_TOKEN`: The Discord bot token used for authenticating with Discord.
- `DATABASE_URL`: The base URL of the database API used for storing user data. This base URL must point to an instance of [the Spoticord Database API](https://github.com/SpoticordMusic/spoticord-database).
- `SPOTICORD_ACCOUNTS_URL`: The base URL of the accounts frontend used for authenticating with Spotify. This base URL must point to an instance of [the Spoticord Accounts frontend](https://github.com/SpoticordMusic/spoticord-accounts).
- `DATABASE_URL`: The URL of the postgres database where spoticord will store user data. Currently only postgresql databases are supported.
- `LINK_URL`: The base URL of the account-linking frontend used for authenticating users with Spotify. This base URL must point to an instance of [the Spoticord Link frontend](https://github.com/SpoticordMusic/spoticord-link).
- `SPOTIFY_CLIENT_ID`: The Spotify Client ID for the Spotify application that is used for Spoticord. This will be used for refreshing tokens.
- `SPOTIFY_CLIENT_SECRET`: The Spotify Client Secret for the Spotify application that is used for Spoticord. This will be used for refreshing tokens.
Additionally you can configure the following variables:
- `GUILD_ID`: The ID of the Discord server where this bot will create commands for. This is used during testing to prevent the bot from creating slash commands in other servers, as well as getting the commands quicker. This variable is optional, and if not set, the bot will create commands in all servers it is in (this may take up to 15 minutes).
- `GUILD_ID`: The ID of the Discord server where this bot will create commands for. This is used during testing to prevent the bot from creating slash commands in other servers, as well as generally being faster than global command propagation. This variable is required when running a debug build, and ignored when running a release build.
- `KV_URL`: The connection URL of a redis-server instance used for storing realtime data. This variable is required when compiling with the `stats` feature.
#### Providing environment variables
You can provide environment variables in a `.env` file at the root of the working directory of Spoticord.
You can also provide environment variables the normal way, e.g. the command line, using `export` (or `set` for Windows) or using docker.
Environment variables set this way take precedence over those in the `.env` file (if one exists).
# Compiling
For information about how to compile Spoticord from source, check out [COMPILING.md](COMPILING.md).
# Contributing
For information about how to contribute to Spoticord, check out [CONTRIBUTING.md](CONTRIBUTING.md).
# Contact
![Discord Shield](https://discordapp.com/api/guilds/779292533053456404/widget.png?style=shield)
If you have any questions, feel free to join the [Spoticord Discord server](https://discord.gg/wRCyhVqBZ5)!
# License
Spoticord is licensed under the [GNU General Public License v3.0](LICENSE).
Spoticord is licensed under the [GNU Affero General Public License v3.0](LICENSE).

View File

@ -0,0 +1,12 @@
[package]
name = "spoticord_audio"
version = "2.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
librespot = { git = "https://github.com/SpoticordMusic/librespot.git", version = "0.5.0-dev", default-features = false }
songbird = { version = "0.4.3", features = ["simd-json"] }
tokio = { version = "1.39.2", features = ["sync"], default-features = false }
zerocopy = "0.7.35"

View File

@ -0,0 +1,2 @@
pub mod sink;
pub mod stream;

View File

@ -0,0 +1,68 @@
use crate::stream::Stream;
use librespot::playback::audio_backend::{Sink, SinkAsBytes, SinkError, SinkResult};
use librespot::playback::convert::Converter;
use librespot::playback::decoder::AudioPacket;
use std::io::Write;
use tokio::sync::mpsc::UnboundedSender;
pub enum SinkEvent {
Start,
Stop,
}
pub struct StreamSink {
stream: Stream,
sender: UnboundedSender<SinkEvent>,
}
impl StreamSink {
pub fn new(stream: Stream, sender: UnboundedSender<SinkEvent>) -> Self {
Self { stream, sender }
}
}
impl Sink for StreamSink {
fn start(&mut self) -> SinkResult<()> {
if let Err(_why) = self.sender.send(SinkEvent::Start) {
// WARNING: Returning an error causes librespot-playback to exit the process with status 1
// return Err(SinkError::ConnectionRefused(_why.to_string()));
}
Ok(())
}
fn stop(&mut self) -> SinkResult<()> {
if let Err(_why) = self.sender.send(SinkEvent::Stop) {
// WARNING: Returning an error causes librespot-playback to exit the process with status 1
// return Err(SinkError::ConnectionRefused(_why.to_string()));
}
self.stream.flush().ok();
Ok(())
}
fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {
use zerocopy::AsBytes;
let AudioPacket::Samples(samples) = packet else {
return Ok(());
};
self.write_bytes(converter.f64_to_f32(&samples).as_bytes())?;
Ok(())
}
}
impl SinkAsBytes for StreamSink {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
self.stream
.write_all(data)
.map_err(|why| SinkError::OnWrite(why.to_string()))?;
Ok(())
}
}

View File

@ -0,0 +1,88 @@
use std::{
io::{Read, Seek, Write},
sync::{Arc, Condvar, Mutex},
};
use songbird::input::core::io::MediaSource;
/// The lower the value, the less latency
///
/// Too low of a value results in jittery audio
const BUFFER_SIZE: usize = 64 * 1024;
#[derive(Clone, Default)]
pub struct Stream {
inner: Arc<(Mutex<Vec<u8>>, Condvar)>,
}
impl Stream {
pub fn new() -> Self {
Self::default()
}
}
impl Read for Stream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let (mutex, condvar) = &*self.inner;
let mut buffer = mutex.lock().expect("Mutex was poisoned");
// Prevent Discord jitter by filling buffer with zeroes if we don't have any audio
// (i.e. when you skip too far ahead in a song which hasn't been downloaded yet)
if buffer.is_empty() {
buf.fill(0);
condvar.notify_all();
return Ok(buf.len());
}
let max_read = usize::min(buf.len(), buffer.len());
buf[0..max_read].copy_from_slice(&buffer[0..max_read]);
buffer.drain(0..max_read);
condvar.notify_all();
Ok(max_read)
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let (mutex, condvar) = &*self.inner;
let mut buffer = mutex.lock().expect("Mutex was poisoned");
while buffer.len() + buf.len() > BUFFER_SIZE {
buffer = condvar.wait(buffer).expect("Mutex was poisoned");
}
buffer.extend_from_slice(buf);
condvar.notify_all();
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
let (mutex, condvar) = &*self.inner;
let mut buffer = mutex.lock().expect("Mutex was poisoned");
buffer.clear();
condvar.notify_all();
Ok(())
}
}
impl Seek for Stream {
fn seek(&mut self, _: std::io::SeekFrom) -> std::io::Result<u64> {
Ok(0)
}
}
impl MediaSource for Stream {
fn byte_len(&self) -> Option<u64> {
None
}
fn is_seekable(&self) -> bool {
false
}
}

View File

@ -0,0 +1,12 @@
[package]
name = "spoticord_config"
version = "2.2.0"
edition = "2021"
[dependencies]
lazy_static = "1.5.0"
rspotify = { version = "0.13.2", default-features = false, features = [
"client-reqwest",
"reqwest-rustls-tls",
] }
serenity = "0.12.2"

View File

@ -0,0 +1,3 @@
# Spoticord: Configuration
This module contains configuration items for spoticord, like environment variables.

View File

@ -0,0 +1,14 @@
use lazy_static::lazy_static;
lazy_static! {
pub static ref DISCORD_TOKEN: String =
std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN environment variable");
pub static ref DATABASE_URL: String =
std::env::var("DATABASE_URL").expect("missing DATABASE_URL environment variable");
pub static ref LINK_URL: String =
std::env::var("LINK_URL").expect("missing LINK_URL environment variable");
pub static ref SPOTIFY_CLIENT_ID: String =
std::env::var("SPOTIFY_CLIENT_ID").expect("missing SPOTIFY_CLIENT_ID environment variable");
pub static ref SPOTIFY_CLIENT_SECRET: String = std::env::var("SPOTIFY_CLIENT_SECRET")
.expect("missing SPOTIFY_CLIENT_SECRET environment variable");
}

View File

@ -0,0 +1,44 @@
mod env;
use rspotify::{AuthCodeSpotify, Config, Credentials, OAuth, Token};
use serenity::all::GatewayIntents;
#[cfg(not(debug_assertions))]
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(debug_assertions)]
pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-dev");
/// The "listening to" message that shows up under the Spoticord bot user
pub const MOTD: &str = "some good 'ol music";
/// The time it takes (in seconds) for Spoticord to disconnect when no music is being played
pub const DISCONNECT_TIME: u64 = 5 * 60;
pub fn discord_token() -> &'static str {
&env::DISCORD_TOKEN
}
pub fn discord_intents() -> GatewayIntents {
GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES
}
pub fn database_url() -> &'static str {
&env::DATABASE_URL
}
pub fn link_url() -> &'static str {
&env::LINK_URL
}
pub fn get_spotify(token: Token) -> AuthCodeSpotify {
AuthCodeSpotify::from_token_with_config(
token,
Credentials {
id: env::SPOTIFY_CLIENT_ID.to_string(),
secret: Some(env::SPOTIFY_CLIENT_SECRET.to_string()),
},
OAuth::default(),
Config::default(),
)
}

View File

@ -0,0 +1,18 @@
[package]
name = "spoticord_database"
version = "2.2.0"
edition = "2021"
[dependencies]
spoticord_config = { path = "../spoticord_config" }
diesel = { version = "2.1.6", features = ["chrono"] }
diesel-async = { version = "0.4.1", features = ["deadpool", "postgres"] }
rspotify = { version = "0.13.2", default-features = false, features = [
"client-reqwest",
"reqwest-rustls-tls",
] }
chrono = "0.4.38"
thiserror = "1.0.63"
rand = "0.8.5"
diesel_async_migrations = "0.12.0"

View File

@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "migrations"

View File

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,10 @@
-- Tables
DROP TABLE "account";
DROP TABLE "link_request";
DROP TABLE "user";
-- Trigger functions
DROP FUNCTION IF EXISTS delete_inactive_accounts();
DROP FUNCTION IF EXISTS update_last_updated_column();

View File

@ -0,0 +1,50 @@
-- Functions
CREATE OR REPLACE FUNCTION update_last_updated_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.last_updated = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION delete_inactive_accounts() RETURNS void AS $$
BEGIN
DELETE FROM account
WHERE last_updated < NOW() - INTERVAL '2 months';
END;
$$ LANGUAGE plpgsql;
-- Tables
CREATE TABLE "user" (
id VARCHAR PRIMARY KEY,
device_name VARCHAR(32) NOT NULL DEFAULT 'Spoticord'
);
CREATE TABLE "link_request" (
token TEXT PRIMARY KEY,
user_id TEXT UNIQUE NOT NULL,
expires TIMESTAMP NOT NULL,
CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
);
CREATE TABLE "account" (
user_id VARCHAR PRIMARY KEY,
username VARCHAR(64) NOT NULL,
access_token VARCHAR(1024) NOT NULL,
refresh_token VARCHAR(1024) NOT NULL,
session_token VARCHAR(1024),
expires TIMESTAMP NOT NULL,
last_updated TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT fk_account_user_id FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
);
-- Triggers
CREATE TRIGGER update_last_updated_column
BEFORE UPDATE ON "account"
FOR EACH ROW
EXECUTE FUNCTION update_last_updated_column();

View File

@ -0,0 +1,43 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error(transparent)]
Diesel(diesel::result::Error),
#[error(transparent)]
PoolBuild(#[from] diesel_async::pooled_connection::deadpool::BuildError),
#[error(transparent)]
Pool(#[from] diesel_async::pooled_connection::deadpool::PoolError),
#[error("Failed to refresh token")]
RefreshTokenFailure,
#[error("The requested record was not found")]
NotFound,
}
impl From<diesel::result::Error> for DatabaseError {
fn from(value: diesel::result::Error) -> Self {
match value {
diesel::result::Error::NotFound => Self::NotFound,
other => Self::Diesel(other),
}
}
}
pub type Result<T> = ::core::result::Result<T, DatabaseError>;
pub trait DatabaseResultExt<T> {
fn optional(self) -> Result<Option<T>>;
}
impl<T> DatabaseResultExt<T> for Result<T> {
fn optional(self) -> Result<Option<T>> {
match self {
Err(DatabaseError::NotFound) => Ok(None),
other => other.map(Some),
}
}
}

View File

@ -0,0 +1,234 @@
pub mod error;
mod migrations;
mod models;
mod schema;
use std::sync::Arc;
use chrono::{Duration, Utc};
use diesel::prelude::*;
use diesel_async::{
pooled_connection::{deadpool::Pool, AsyncDieselConnectionManager},
AsyncPgConnection, RunQueryDsl,
};
use error::*;
use models::{Account, LinkRequest, User};
use rand::{distributions::Alphanumeric, Rng};
use rspotify::{clients::BaseClient, Token};
#[derive(Clone)]
pub struct Database(Arc<Pool<AsyncPgConnection>>);
impl Database {
pub async fn connect() -> Result<Self> {
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(
spoticord_config::database_url(),
);
let pool = Pool::builder(config).build()?;
let mut conn = pool.get().await?;
migrations::run_migrations(&mut conn).await?;
Ok(Self(Arc::new(pool)))
}
// User operations
pub async fn get_user(&self, user_id: impl AsRef<str>) -> Result<User> {
use schema::user::dsl::*;
let mut connection = self.0.get().await?;
let result = user
.filter(id.eq(user_id.as_ref()))
.select(User::as_select())
.first(&mut connection)
.await?;
Ok(result)
}
pub async fn create_user(&self, user_id: impl AsRef<str>) -> Result<User> {
use schema::user::dsl::*;
let mut connection = self.0.get().await?;
let result = diesel::insert_into(user)
.values(id.eq(user_id.as_ref()))
.returning(User::as_returning())
.get_result(&mut connection)
.await?;
Ok(result)
}
pub async fn delete_user(&self, user_id: impl AsRef<str>) -> Result<usize> {
use schema::user::dsl::*;
let mut connection = self.0.get().await?;
let affected = diesel::delete(user)
.filter(id.eq(user_id.as_ref()))
.execute(&mut connection)
.await?;
Ok(affected)
}
pub async fn get_or_create_user(&self, user_id: impl AsRef<str>) -> Result<User> {
match self.get_user(&user_id).await {
Err(DatabaseError::NotFound) => self.create_user(user_id).await,
result => result,
}
}
pub async fn update_device_name(
&self,
user_id: impl AsRef<str>,
_device_name: impl AsRef<str>,
) -> Result<()> {
use schema::user::dsl::*;
let mut connection = self.0.get().await?;
diesel::update(user)
.filter(id.eq(user_id.as_ref()))
.set(device_name.eq(_device_name.as_ref()))
.execute(&mut connection)
.await?;
Ok(())
}
// Account operations
pub async fn get_account(&self, _user_id: impl AsRef<str>) -> Result<Account> {
use schema::account::dsl::*;
let mut connection = self.0.get().await?;
let result = account
.select(Account::as_select())
.filter(user_id.eq(_user_id.as_ref()))
.first(&mut connection)
.await?;
Ok(result)
}
pub async fn delete_account(&self, _user_id: impl AsRef<str>) -> Result<usize> {
use schema::account::dsl::*;
let mut connection = self.0.get().await?;
let affected = diesel::delete(account)
.filter(user_id.eq(_user_id.as_ref()))
.execute(&mut connection)
.await?;
Ok(affected)
}
pub async fn update_session_token(
&self,
_user_id: impl AsRef<str>,
_session_token: impl AsRef<str>,
) -> Result<()> {
use schema::account::dsl::*;
let mut connection = self.0.get().await?;
diesel::update(account)
.filter(user_id.eq(_user_id.as_ref()))
.set(session_token.eq(_session_token.as_ref()))
.execute(&mut connection)
.await?;
Ok(())
}
// Request operations
pub async fn get_request(&self, _user_id: impl AsRef<str>) -> Result<LinkRequest> {
use schema::link_request::dsl::*;
let mut connection = self.0.get().await?;
let result = link_request
.select(LinkRequest::as_select())
.filter(user_id.eq(_user_id.as_ref()))
.first(&mut connection)
.await?;
Ok(result)
}
/// Create a new link request that expires after an hour
pub async fn create_request(&self, _user_id: impl AsRef<str>) -> Result<LinkRequest> {
use schema::link_request::dsl::*;
let mut connection = self.0.get().await?;
let _token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(64)
.map(char::from)
.collect();
let _expires = (Utc::now() + Duration::hours(1)).naive_utc();
let request = diesel::insert_into(link_request)
.values((
user_id.eq(_user_id.as_ref()),
token.eq(&_token),
expires.eq(_expires),
))
.on_conflict(user_id)
.do_update()
.set((token.eq(&_token), expires.eq(_expires)))
.returning(LinkRequest::as_returning())
.get_result(&mut connection)
.await?;
Ok(request)
}
// Special operations
/// Retrieve a user's Spotify access token. This token, if expired, will automatically be refreshed
/// using the refresh token stored in the database. If this succeeds, the access token will be updated.
pub async fn get_access_token(&self, _user_id: impl AsRef<str>) -> Result<String> {
use schema::account::dsl::*;
let mut connection = self.0.get().await?;
let mut result: Account = account
.filter(user_id.eq(_user_id.as_ref()))
.select(Account::as_select())
.first(&mut connection)
.await?;
// If the token has expired, refresh it automatically
if result.expired_offset(Duration::minutes(1)) {
let spotify = spoticord_config::get_spotify(Token {
refresh_token: Some(result.refresh_token),
..Default::default()
});
let token = match spotify.refetch_token().await {
Ok(Some(token)) => token,
_ => {
self.delete_account(_user_id.as_ref()).await.ok();
return Err(DatabaseError::RefreshTokenFailure);
}
};
result = diesel::update(account)
.filter(user_id.eq(_user_id.as_ref()))
.set((
access_token.eq(&token.access_token),
refresh_token.eq(token.refresh_token.as_deref().unwrap_or("")),
expires.eq(&token
.expires_at
.expect("token expires_at is none, we broke time")
.naive_utc()),
))
.returning(Account::as_returning())
.get_result(&mut connection)
.await?;
}
Ok(result.access_token)
}
}

View File

@ -0,0 +1,13 @@
use diesel_async::AsyncConnection;
use diesel_async_migrations::{embed_migrations, EmbeddedMigrations};
pub async fn run_migrations<C>(connection: &mut C) -> Result<(), diesel::result::Error>
where
C: AsyncConnection<Backend = diesel::pg::Pg> + 'static + Send,
{
let migrations: EmbeddedMigrations = embed_migrations!();
migrations.run_pending_migrations(connection).await?;
Ok(())
}

View File

@ -0,0 +1,51 @@
use chrono::Utc;
use diesel::prelude::*;
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = super::schema::user)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct User {
pub id: String,
pub device_name: String,
}
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = super::schema::account)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Account {
pub user_id: String,
pub username: String,
pub access_token: String,
pub refresh_token: String,
pub session_token: Option<String>,
pub expires: chrono::NaiveDateTime,
}
impl Account {
pub fn expired(&self) -> bool {
Utc::now().naive_utc() > self.expires
}
pub fn expired_offset(&self, offset: chrono::Duration) -> bool {
Utc::now().naive_utc() > self.expires - offset
}
}
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = super::schema::link_request)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct LinkRequest {
pub token: String,
pub user_id: String,
pub expires: chrono::NaiveDateTime,
}
impl LinkRequest {
pub fn expired(&self) -> bool {
Utc::now().naive_utc() > self.expires
}
pub fn expired_offset(&self, offset: chrono::Duration) -> bool {
Utc::now().naive_utc() > self.expires - offset
}
}

View File

@ -0,0 +1,42 @@
// @generated automatically by Diesel CLI.
diesel::table! {
account (user_id) {
user_id -> Varchar,
#[max_length = 64]
username -> Varchar,
#[max_length = 1024]
access_token -> Varchar,
#[max_length = 1024]
refresh_token -> Varchar,
#[max_length = 1024]
session_token -> Nullable<Varchar>,
expires -> Timestamp,
last_updated -> Timestamp,
}
}
diesel::table! {
link_request (token) {
token -> Text,
user_id -> Text,
expires -> Timestamp,
}
}
diesel::table! {
user (id) {
id -> Varchar,
#[max_length = 32]
device_name -> Varchar,
}
}
diesel::joinable!(account -> user (user_id));
diesel::joinable!(link_request -> user (user_id));
diesel::allow_tables_to_appear_in_same_query!(
account,
link_request,
user,
);

View File

@ -0,0 +1,18 @@
[package]
name = "spoticord_player"
version = "2.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
spoticord_audio = { path = "../spoticord_audio" }
spoticord_utils = { path = "../spoticord_utils" }
librespot = { git = "https://github.com/SpoticordMusic/librespot.git", version = "0.5.0-dev", default-features = false }
songbird = { version = "0.4.3", features = ["simd-json"] }
tokio = { version = "1.39.2", features = ["full"] }
anyhow = "1.0.86"
log = "0.4.22"
symphonia = { version = "0.5.4", default-features = false, features = ["pcm"] }
hex = "0.4.3"

View File

@ -0,0 +1,126 @@
use std::collections::HashSet;
use librespot::{
core::SpotifyId,
metadata::{
artist::ArtistsWithRole,
audio::{AudioItem, UniqueFields},
},
};
#[derive(Debug, Clone)]
pub struct PlaybackInfo {
audio_item: AudioItem,
updated_at: u128,
position: u32,
playing: bool,
}
impl PlaybackInfo {
pub fn new(audio_item: AudioItem, position: u32, playing: bool) -> Self {
Self {
audio_item,
updated_at: spoticord_utils::get_time(),
position,
playing,
}
}
pub fn track_id(&self) -> SpotifyId {
self.audio_item.track_id
}
pub fn track_id_string(&self) -> String {
self.audio_item
.track_id
.to_base62()
.expect("invalid spotify id")
}
pub fn name(&self) -> String {
self.audio_item.name.clone()
}
pub fn artists(&self) -> Option<ArtistsWithRole> {
let artists = match &self.audio_item.unique_fields {
UniqueFields::Track { artists, .. } => artists.clone().0,
UniqueFields::Episode { .. } => None?,
};
let mut seen = HashSet::new();
let artists = artists
.into_iter()
.filter(|item| seen.insert(item.id))
.collect();
Some(ArtistsWithRole(artists))
}
pub fn show_name(&self) -> Option<String> {
match &self.audio_item.unique_fields {
UniqueFields::Episode { show_name, .. } => Some(show_name.to_string()),
UniqueFields::Track { .. } => None,
}
}
pub fn thumbnail(&self) -> String {
self.audio_item
.covers
.first()
.expect("spotify track missing cover image")
.url
.to_string()
}
pub fn duration(&self) -> u32 {
self.audio_item.duration_ms
}
pub fn url(&self) -> String {
match &self.audio_item.unique_fields {
UniqueFields::Episode { .. } => format!(
"https://open.spotify.com/episode/{}",
self.track_id_string()
),
UniqueFields::Track { .. } => {
format!("https://open.spotify.com/track/{}", self.track_id_string())
}
}
}
/// Get the current playback position, which accounts for time that may have passed since this struct was last updated
pub fn current_position(&self) -> u32 {
if self.playing {
let now = spoticord_utils::get_time();
let diff = now - self.updated_at;
self.position + diff as u32
} else {
self.position
}
}
pub fn playing(&self) -> bool {
self.playing
}
pub fn update_playback(&mut self, position: u32, playing: bool) {
self.position = position;
self.playing = playing;
self.updated_at = spoticord_utils::get_time();
}
pub fn update_track(&mut self, audio_item: AudioItem) {
self.audio_item = audio_item;
}
pub fn is_episode(&self) -> bool {
matches!(self.audio_item.unique_fields, UniqueFields::Episode { .. })
}
pub fn is_track(&self) -> bool {
matches!(self.audio_item.unique_fields, UniqueFields::Track { .. })
}
}

View File

@ -0,0 +1,315 @@
pub mod info;
use anyhow::Result;
use info::PlaybackInfo;
use librespot::{
connect::{config::ConnectConfig, spirc::Spirc},
core::{http_client::HttpClientError, Session as SpotifySession, SessionConfig},
discovery::Credentials,
metadata::Lyrics,
playback::{
config::{Bitrate, PlayerConfig, VolumeCtrl},
mixer::{self, MixerConfig},
player::{Player as SpotifyPlayer, PlayerEvent as SpotifyPlayerEvent},
},
};
use log::error;
use songbird::{input::RawAdapter, tracks::TrackHandle, Call};
use spoticord_audio::{
sink::{SinkEvent, StreamSink},
stream::Stream,
};
use std::{io::Write, sync::Arc};
use tokio::sync::{mpsc, oneshot, Mutex};
#[derive(Debug)]
enum PlayerCommand {
NextTrack,
PreviousTrack,
Pause,
Play,
GetPlaybackInfo(oneshot::Sender<Option<PlaybackInfo>>),
GetLyrics(oneshot::Sender<Option<Lyrics>>),
Shutdown,
}
#[derive(Debug)]
pub enum PlayerEvent {
Pause,
Play,
Stopped,
TrackChanged(Box<PlaybackInfo>),
}
pub struct Player {
session: SpotifySession,
spirc: Spirc,
track: TrackHandle,
stream: Stream,
playback_info: Option<PlaybackInfo>,
// Communication
events: mpsc::Sender<PlayerEvent>,
commands: mpsc::Receiver<PlayerCommand>,
spotify_events: mpsc::UnboundedReceiver<SpotifyPlayerEvent>,
sink_events: mpsc::UnboundedReceiver<SinkEvent>,
}
impl Player {
pub async fn create(
credentials: Credentials,
call: Arc<Mutex<Call>>,
device_name: impl Into<String>,
) -> Result<(PlayerHandle, mpsc::Receiver<PlayerEvent>)> {
let (event_tx, event_rx) = mpsc::channel(16);
let mut call_lock = call.lock().await;
let stream = Stream::new();
// Create songbird audio track
let adapter = RawAdapter::new(stream.clone(), 44100, 2);
let track = call_lock.play_only_input(adapter.into());
track.pause()?;
// Free call lock before creating session
drop(call_lock);
// Create librespot audio streamer
let session = SpotifySession::new(SessionConfig::default(), None);
let mixer = (mixer::find(Some("softvol")).expect("missing softvol mixer"))(MixerConfig {
volume_ctrl: VolumeCtrl::Log(VolumeCtrl::DEFAULT_DB_RANGE),
..Default::default()
});
let (tx_sink, rx_sink) = mpsc::unbounded_channel();
let player = SpotifyPlayer::new(
PlayerConfig {
// 96kbps causes audio key errors, so enjoy the quality upgrade
bitrate: Bitrate::Bitrate160,
..Default::default()
},
session.clone(),
mixer.get_soft_volume(),
{
let stream = stream.clone();
move || Box::new(StreamSink::new(stream, tx_sink))
},
);
let rx_player = player.get_player_event_channel();
let (spirc, spirc_task) = Spirc::new(
ConnectConfig {
name: device_name.into(),
initial_volume: Some((0.75f32 * u16::MAX as f32) as u16),
..Default::default()
},
session.clone(),
credentials,
player,
mixer,
)
.await?;
let (tx, rx) = mpsc::channel(16);
let player = Self {
session,
spirc,
track,
stream,
playback_info: None,
events: event_tx,
commands: rx,
spotify_events: rx_player,
sink_events: rx_sink,
};
// Launch it all!
tokio::spawn(spirc_task);
tokio::spawn(player.run());
Ok((PlayerHandle { commands: tx }, event_rx))
}
async fn run(mut self) {
loop {
tokio::select! {
opt_command = self.commands.recv() => {
let command = match opt_command {
Some(command) => command,
None => break,
};
self.handle_command(command).await;
},
Some(event) = self.spotify_events.recv() => {
self.handle_spotify_event(event).await;
},
Some(event) = self.sink_events.recv() => {
self.handle_sink_event(event).await;
}
else => break,
}
}
}
async fn handle_command(&mut self, command: PlayerCommand) {
match command {
PlayerCommand::NextTrack => _ = self.spirc.next(),
PlayerCommand::PreviousTrack => _ = self.spirc.prev(),
PlayerCommand::Pause => _ = self.spirc.pause(),
PlayerCommand::Play => _ = self.spirc.play(),
PlayerCommand::GetPlaybackInfo(tx) => _ = tx.send(self.playback_info.clone()),
PlayerCommand::GetLyrics(tx) => self.get_lyrics(tx).await,
PlayerCommand::Shutdown => self.commands.close(),
};
}
async fn handle_spotify_event(&mut self, event: SpotifyPlayerEvent) {
match event {
SpotifyPlayerEvent::PositionCorrection { position_ms, .. }
| SpotifyPlayerEvent::Seeked { position_ms, .. } => {
if let Some(playback_info) = self.playback_info.as_mut() {
playback_info.update_playback(position_ms, true);
}
}
SpotifyPlayerEvent::Playing { position_ms, .. } => {
_ = self.events.send(PlayerEvent::Play).await;
if let Some(playback_info) = self.playback_info.as_mut() {
playback_info.update_playback(position_ms, true);
}
}
SpotifyPlayerEvent::Paused { position_ms, .. } => {
_ = self.events.send(PlayerEvent::Pause).await;
if let Some(playback_info) = self.playback_info.as_mut() {
playback_info.update_playback(position_ms, false);
}
}
SpotifyPlayerEvent::Stopped { .. } | SpotifyPlayerEvent::SessionDisconnected { .. } => {
if let Err(why) = self.track.pause() {
error!("Failed to pause songbird track: {why}");
}
_ = self.events.send(PlayerEvent::Pause).await;
self.playback_info = None;
}
SpotifyPlayerEvent::TrackChanged { audio_item } => {
if let Some(playback_info) = self.playback_info.as_mut() {
playback_info.update_track(*audio_item);
} else {
self.playback_info = Some(PlaybackInfo::new(*audio_item, 0, false));
}
_ = self
.events
.send(PlayerEvent::TrackChanged(Box::new(
self.playback_info.clone().expect("playback info is None"),
)))
.await;
}
_ => {}
}
}
async fn handle_sink_event(&self, event: SinkEvent) {
if let SinkEvent::Start = event {
if let Err(why) = self.track.play() {
error!("Failed to resume songbird track: {why}");
}
}
}
/// Grab the lyrics for the current active track from Spotify.
///
/// This might return None if nothing is being played, or the current song does not have any lyrics.
async fn get_lyrics(&self, tx: oneshot::Sender<Option<Lyrics>>) {
let Some(playback_info) = &self.playback_info else {
_ = tx.send(None);
return;
};
let lyrics = match Lyrics::get(&self.session, &playback_info.track_id()).await {
Ok(lyrics) => lyrics,
Err(why) => {
// Ignore 404 errors
match why.error.downcast_ref::<HttpClientError>() {
Some(HttpClientError::StatusCode(code)) if code.as_u16() == 404 => {}
_ => error!("Failed to get lyrics: {why}"),
}
_ = tx.send(None);
return;
}
};
_ = tx.send(Some(lyrics));
}
}
impl Drop for Player {
fn drop(&mut self) {
_ = self.spirc.shutdown();
_ = self.stream.flush();
}
}
#[derive(Clone, Debug)]
pub struct PlayerHandle {
commands: mpsc::Sender<PlayerCommand>,
}
impl PlayerHandle {
pub fn is_valid(&self) -> bool {
!self.commands.is_closed()
}
pub async fn next_track(&self) {
_ = self.commands.send(PlayerCommand::NextTrack).await;
}
pub async fn previous_track(&self) {
_ = self.commands.send(PlayerCommand::PreviousTrack).await;
}
pub async fn pause(&self) {
_ = self.commands.send(PlayerCommand::Pause).await;
}
pub async fn play(&self) {
_ = self.commands.send(PlayerCommand::Play).await;
}
pub async fn playback_info(&self) -> Result<Option<PlaybackInfo>> {
let (tx, rx) = oneshot::channel();
self.commands
.send(PlayerCommand::GetPlaybackInfo(tx))
.await?;
Ok(rx.await?)
}
pub async fn get_lyrics(&self) -> Result<Option<Lyrics>> {
let (tx, rx) = oneshot::channel();
self.commands.send(PlayerCommand::GetLyrics(tx)).await?;
Ok(rx.await?)
}
pub async fn shutdown(&self) {
_ = self.commands.send(PlayerCommand::Shutdown).await;
}
}

View File

@ -0,0 +1,21 @@
[package]
name = "spoticord_session"
version = "2.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
spoticord_config = { path = "../spoticord_config" }
spoticord_database = { path = "../spoticord_database" }
spoticord_player = { path = "../spoticord_player" }
spoticord_utils = { path = "../spoticord_utils" }
tokio = { version = "1.39.2", features = ["full"] }
librespot = { git = "https://github.com/SpoticordMusic/librespot.git", version = "0.5.0-dev", default-features = false }
serenity = "0.12.2"
songbird = { version = "0.4.3", features = ["simd-json"] }
anyhow = "1.0.86"
log = "0.4.22"
base64 = "0.22.1"
poise = "0.6.1"

View File

@ -0,0 +1,585 @@
pub mod lyrics_embed;
pub mod manager;
pub mod playback_embed;
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use librespot::{discovery::Credentials, protocol::authentication::AuthenticationType};
use log::{debug, error, trace};
use lyrics_embed::LyricsEmbed;
use manager::{SessionManager, SessionQuery};
use playback_embed::{PlaybackEmbed, PlaybackEmbedHandle};
use serenity::{
all::{
ChannelId, CommandInteraction, CreateEmbed, CreateMessage, GuildChannel, GuildId, UserId,
},
async_trait,
};
use songbird::{model::payload::ClientDisconnect, Call, CoreEvent, Event, EventContext};
use spoticord_database::Database;
use spoticord_player::{Player, PlayerEvent, PlayerHandle};
use spoticord_utils::{discord::Colors, spotify};
use std::{ops::ControlFlow, sync::Arc, time::Duration};
use tokio::{
sync::{mpsc, oneshot, Mutex},
task::JoinHandle,
};
#[derive(Debug)]
pub enum SessionCommand {
GetOwner(oneshot::Sender<UserId>),
GetPlayer(oneshot::Sender<PlayerHandle>),
GetActive(oneshot::Sender<bool>),
CreatePlaybackEmbed(SessionHandle, CommandInteraction),
CreateLyricsEmbed(SessionHandle, CommandInteraction),
Reactivate(UserId, oneshot::Sender<Result<()>>),
ShutdownPlayer,
Disconnect,
DisconnectTimedOut,
}
pub struct Session {
session_manager: SessionManager,
context: serenity::all::Context,
guild_id: GuildId,
text_channel: GuildChannel,
call: Arc<Mutex<Call>>,
player: PlayerHandle,
owner: UserId,
active: bool,
timeout_tx: Option<oneshot::Sender<()>>,
commands: mpsc::Receiver<SessionCommand>,
events: mpsc::Receiver<PlayerEvent>,
commands_inner_tx: mpsc::Sender<SessionCommand>,
commands_inner_rx: mpsc::Receiver<SessionCommand>,
playback_embed: Option<PlaybackEmbedHandle>,
lyrics_embed: Option<JoinHandle<()>>,
}
impl Session {
pub async fn create(
session_manager: SessionManager,
context: &serenity::all::Context,
guild_id: GuildId,
voice_channel_id: ChannelId,
text_channel_id: ChannelId,
owner: UserId,
) -> Result<SessionHandle> {
// Set up communication channel
let (tx, rx) = mpsc::channel(16);
let handle = SessionHandle {
guild: guild_id,
voice_channel: voice_channel_id,
text_channel: text_channel_id,
commands: tx,
};
// Resolve text channel
let text_channel = text_channel_id
.to_channel(&context)
.await?
.guild()
.ok_or(anyhow!("Text channel is not a guild channel"))?;
// Create channel for internal command communication (timeouts hint hint)
// This uses separate channels as to not cause a cyclic dependency
let (inner_tx, inner_rx) = mpsc::channel(16);
// Grab user credentials and info before joining call
let credentials =
retrieve_credentials(&session_manager.database(), owner.to_string()).await?;
let device_name = session_manager
.database()
.get_user(owner.to_string())
.await?
.device_name;
// Hello Discord I'm here
let call = session_manager
.songbird()
.join(guild_id, voice_channel_id)
.await?;
// Make sure call guard is dropped or else we can't execute session.run
{
let mut call = call.lock().await;
// Wasn't able to confirm if this is true, but this might reduce network bandwith by not receiving user voice packets
_ = call.deafen(true).await;
// Set up call events
call.add_global_event(Event::Core(CoreEvent::DriverDisconnect), handle.clone());
call.add_global_event(Event::Core(CoreEvent::ClientDisconnect), handle.clone());
}
let (player, events) = match Player::create(credentials, call.clone(), device_name).await {
Ok(player) => player,
Err(why) => {
// Leave call on error, otherwise bot will be stuck in call forever until manually disconnected or taken over
_ = call.lock().await.leave().await;
error!("Failed to create player: {why}");
return Err(why);
}
};
let mut session = Self {
session_manager,
context: context.to_owned(),
text_channel,
call,
player,
guild_id,
owner,
active: true,
timeout_tx: None,
commands: rx,
events,
commands_inner_tx: inner_tx,
commands_inner_rx: inner_rx,
playback_embed: None,
lyrics_embed: None,
};
session.start_timeout();
tokio::spawn(session.run());
Ok(handle)
}
pub async fn run(mut self) {
loop {
tokio::select! {
opt_command = self.commands.recv() => {
let Some(command) = opt_command else {
break;
};
if self.handle_command(command).await.is_break() {
break;
}
},
opt_event = self.events.recv(), if self.active => {
let Some(event) = opt_event else {
self.shutdown_player().await;
continue;
};
self.handle_event(event).await;
},
// Internal communication channel
Some(command) = self.commands_inner_rx.recv() => {
if self.handle_command(command).await.is_break() {
break;
}
}
else => break,
}
}
}
async fn handle_command(&mut self, command: SessionCommand) -> ControlFlow<(), ()> {
trace!("SessionCommand::{command:?}");
match command {
SessionCommand::GetOwner(sender) => _ = sender.send(self.owner),
SessionCommand::GetPlayer(sender) => _ = sender.send(self.player.clone()),
SessionCommand::GetActive(sender) => _ = sender.send(self.active),
SessionCommand::CreatePlaybackEmbed(handle, interaction) => {
match PlaybackEmbed::create(self, handle, interaction).await {
Ok(Some(playback_embed)) => {
self.playback_embed = Some(playback_embed);
}
Ok(None) => {}
Err(why) => {
error!("Failed to create playing embed: {why}");
}
};
}
SessionCommand::CreateLyricsEmbed(handle, interaction) => {
match LyricsEmbed::create(self, handle, interaction).await {
Ok(Some(lyrics_embed)) => {
if let Some(current) = self.lyrics_embed.take() {
current.abort();
}
self.lyrics_embed = Some(lyrics_embed);
}
Ok(None) => {}
Err(why) => {
error!("Failed to create lyrics embed: {why}");
}
}
}
SessionCommand::Reactivate(new_owner, tx) => {
_ = tx.send(self.reactivate(new_owner).await)
}
SessionCommand::ShutdownPlayer => self.shutdown_player().await,
SessionCommand::Disconnect => {
self.disconnect().await;
return ControlFlow::Break(());
}
SessionCommand::DisconnectTimedOut => {
self.disconnect().await;
_ = self
.text_channel
.send_message(
&self.context,
CreateMessage::new().embed(
CreateEmbed::new()
.title("It's a little quiet in here")
.description("The bot has been inactive for too long, and has been disconnected.")
.color(Colors::Warning),
),
)
.await;
return ControlFlow::Break(());
}
};
ControlFlow::Continue(())
}
async fn handle_event(&mut self, event: PlayerEvent) {
match event {
PlayerEvent::Play => self.stop_timeout(),
PlayerEvent::Pause => self.start_timeout(),
PlayerEvent::Stopped => self.shutdown_player().await,
PlayerEvent::TrackChanged(_) => {}
}
if let Some(playback_embed) = &self.playback_embed {
if playback_embed.invoke_update().await.is_err() {
self.playback_embed = None;
}
}
}
fn start_timeout(&mut self) {
if let Some(tx) = self.timeout_tx.take() {
_ = tx.send(());
}
let (tx, rx) = oneshot::channel::<()>();
self.timeout_tx = Some(tx);
let inner_tx = self.commands_inner_tx.clone();
tokio::spawn(async move {
let mut timer =
tokio::time::interval(Duration::from_secs(spoticord_config::DISCONNECT_TIME));
// Ignore immediate tick
timer.tick().await;
tokio::select! {
_ = rx => return,
_ = timer.tick() => {}
};
// Disconnect through inner communication
_ = inner_tx.send(SessionCommand::DisconnectTimedOut).await;
});
}
fn stop_timeout(&mut self) {
if let Some(tx) = self.timeout_tx.take() {
_ = tx.send(());
}
}
async fn reactivate(&mut self, new_owner: UserId) -> Result<()> {
if self.active {
return Err(anyhow!("Cannot reactivate session that is already active"));
}
let credentials =
retrieve_credentials(&self.session_manager.database(), new_owner.to_string()).await?;
let device_name = self
.session_manager
.database()
.get_user(new_owner.to_string())
.await?
.device_name;
let (player, player_events) =
Player::create(credentials, self.call.clone(), device_name).await?;
self.owner = new_owner;
self.player = player;
self.events = player_events;
self.active = true;
Ok(())
}
async fn shutdown_player(&mut self) {
self.player.shutdown().await;
self.start_timeout();
self.active = false;
// Remove owner from session manager
self.session_manager
.remove_session(SessionQuery::Owner(self.owner));
}
async fn disconnect(&mut self) {
// Kill timeout if one is running
self.stop_timeout();
// Force close channels, as handles may otherwise hold this struct hostage
self.commands.close();
self.events.close();
// Leave call, ignore errors
let mut call = self.call.lock().await;
_ = call.leave().await;
}
}
impl Drop for Session {
fn drop(&mut self) {
// Abort timeout task
if let Some(tx) = self.timeout_tx.take() {
_ = tx.send(());
}
// Abort lyrics task
if let Some(lyrics) = self.lyrics_embed.take() {
lyrics.abort();
}
// Clean up the session from the session manager
// This is done in Drop::drop to ensure that the session always cleans up after itself
// even if something went wrong
let session_manager = self.session_manager.clone();
let guild_id = self.guild_id;
let owner = self.owner;
session_manager.remove_session(SessionQuery::Guild(guild_id));
session_manager.remove_session(SessionQuery::Owner(owner));
}
}
#[derive(Clone, Debug)]
pub struct SessionHandle {
guild: GuildId,
voice_channel: ChannelId,
text_channel: ChannelId,
commands: mpsc::Sender<SessionCommand>,
}
impl SessionHandle {
/// Check if the session handle is valid
pub fn is_valid(&self) -> bool {
!self.commands.is_closed()
}
pub fn guild(&self) -> GuildId {
self.guild
}
pub fn voice_channel(&self) -> ChannelId {
self.voice_channel
}
pub fn text_channel(&self) -> ChannelId {
self.text_channel
}
/// Retrieve the current owner of the session
pub async fn owner(&self) -> Result<UserId> {
let (tx, rx) = oneshot::channel();
self.commands.send(SessionCommand::GetOwner(tx)).await?;
let result = rx.await?;
Ok(result)
}
/// Retrieve the player handle from the session
pub async fn player(&self) -> Result<PlayerHandle> {
let (tx, rx) = oneshot::channel();
self.commands.send(SessionCommand::GetPlayer(tx)).await?;
let result = rx.await?;
Ok(result)
}
pub async fn active(&self) -> Result<bool> {
let (tx, rx) = oneshot::channel();
self.commands.send(SessionCommand::GetActive(tx)).await?;
let result = rx.await?;
Ok(result)
}
/// Instruct the session to make another user owner.
///
/// This will fail if the session still has an active user assigned to it.
pub async fn reactivate(&self, new_owner: UserId) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.commands
.send(SessionCommand::Reactivate(new_owner, tx))
.await?;
rx.await?
}
/// Create a playback embed as a response to an interaction
///
/// This playback embed will automatically update when certain events happen
pub async fn create_playback_embed(&self, interaction: CommandInteraction) -> Result<()> {
self.commands
.send(SessionCommand::CreatePlaybackEmbed(
self.clone(),
interaction,
))
.await?;
Ok(())
}
/// Create a lyrics embed as a response to an interaction
///
/// This lyrics embed will automatically retrieve the lyrics and update the embed accordingly
pub async fn create_lyrics_embed(&self, interaction: CommandInteraction) -> Result<()> {
self.commands
.send(SessionCommand::CreateLyricsEmbed(self.clone(), interaction))
.await?;
Ok(())
}
/// Instruct the session to destroy the player (but keep voice call).
///
/// This is meant to be used for when the session owner leaves the call
/// and allows other users to become owner using the `/join` command.
///
/// This should also remove the owner from the session manager.
pub async fn shutdown_player(&self) {
if let Err(why) = self.commands.send(SessionCommand::ShutdownPlayer).await {
error!("Failed to send command: {why}");
}
}
/// Instruct the session to destroy itself.
///
/// This should also remove the player and the owner from the session manager.
pub async fn disconnect(&self) {
if let Err(why) = self.commands.send(SessionCommand::Disconnect).await {
error!("Failed to send command: {why}");
}
}
}
#[async_trait]
impl songbird::EventHandler for SessionHandle {
async fn act(&self, event: &EventContext<'_>) -> Option<Event> {
if !self.is_valid() {
return Some(Event::Cancel);
}
match event {
EventContext::DriverDisconnect(_) => {
debug!("Bot disconnected from call, cleaning up");
self.disconnect().await;
}
EventContext::ClientDisconnect(ClientDisconnect { user_id }) => {
// Ignore disconnects if we're inactive
if !self.active().await.unwrap_or(false) {
return None;
}
match self.owner().await {
Ok(id) if id.get() == user_id.0 => {
debug!("Owner of session disconnected, stopping playback");
self.shutdown_player().await;
}
_ => {}
}
}
_ => {}
}
None
}
}
async fn retrieve_credentials(database: &Database, owner: impl AsRef<str>) -> Result<Credentials> {
let account = database.get_account(&owner).await?;
let token = if let Some(session_token) = &account.session_token {
match spotify::validate_token(&account.username, session_token).await {
Ok(Some(token)) => {
database
.update_session_token(&account.user_id, &token)
.await?;
Some(token)
}
Ok(None) => Some(session_token.clone()),
Err(_) => None,
}
} else {
None
};
// Request new session token if previous one was invalid or missing
let token = match token {
Some(token) => token,
None => {
let access_token = database.get_access_token(&account.user_id).await?;
let credentials = spotify::request_session_token(Credentials {
username: account.username.clone(),
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
auth_data: access_token.into_bytes(),
})
.await?;
let token = BASE64.encode(credentials.auth_data);
database
.update_session_token(&account.user_id, &token)
.await?;
token
}
};
Ok(Credentials {
username: account.username,
auth_type: AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS,
auth_data: BASE64.decode(token)?,
})
}

View File

@ -0,0 +1,447 @@
use std::{ops::ControlFlow, time::Duration};
use anyhow::Result;
use librespot::{
core::SpotifyId,
metadata::{
lyrics::{Line, SyncType},
Lyrics,
},
};
use log::error;
use serenity::{
all::{
CommandInteraction, ComponentInteraction, ComponentInteractionCollector, Context,
CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse,
CreateInteractionResponseMessage, EditMessage, Message,
},
futures::StreamExt,
};
use spoticord_player::info::PlaybackInfo;
use spoticord_utils::discord::Colors;
use tokio::task::JoinHandle;
use crate::{Session, SessionHandle};
const PAGE_LENGTH: usize = 3000;
const TIME_OFFSET: u32 = 1000;
pub struct LyricsEmbed {
guild_id: String,
ctx: Context,
session: SessionHandle,
message: Message,
track: SpotifyId,
lyrics: Option<Lyrics>,
page: usize,
}
impl LyricsEmbed {
pub async fn create(
session: &Session,
handle: SessionHandle,
interaction: CommandInteraction,
) -> Result<Option<JoinHandle<()>>> {
let ctx = session.context.clone();
if !session.active {
respond_not_playing(&ctx, interaction).await?;
return Ok(None);
}
let Some(playback_info) = session.player.playback_info().await? else {
respond_not_playing(&ctx, interaction).await?;
return Ok(None);
};
let guild_id = interaction
.guild_id
.expect("interaction was outside of a guild")
.to_string();
let lyrics = session.player.get_lyrics().await?;
// Send initial message
interaction
.create_response(
&ctx,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.embed(lyrics_embed(&lyrics, &playback_info, 0))
.components(vec![lyrics_buttons(&guild_id, &lyrics, 0)]),
),
)
.await?;
// Retrieve message instead of editing interaction response, as those tokens are only valid for 15 minutes
let message = interaction.get_response(&ctx).await?;
let this = Self {
guild_id: guild_id.clone(),
ctx: ctx.clone(),
session: handle,
message,
track: playback_info.track_id(),
lyrics,
page: 0,
};
let collector = ComponentInteractionCollector::new(&ctx)
.filter(move |press| {
let parts = press.data.custom_id.split(':').collect::<Vec<_>>();
matches!(parts.first(), Some(&"lyrics"))
&& matches!(parts.last(), Some(id) if id == &guild_id)
})
.timeout(Duration::from_secs(3600 * 24));
let handle = tokio::spawn(this.run(collector));
Ok(Some(handle))
}
async fn run(mut self, collector: ComponentInteractionCollector) {
let mut stream = collector.stream();
let mut interval = tokio::time::interval(Duration::from_secs(2));
loop {
tokio::select! {
_ = interval.tick() => {
if self.handle_tick().await.is_break() {
break;
}
}
opt_press = stream.next() => {
let Some(press) = opt_press else {
break;
};
// Immediately acknowledge, we don't have to inform the user about the update
_ = press
.create_response(&self.ctx, CreateInteractionResponse::Acknowledge)
.await;
if self.handle_press(press).await.is_break() {
break;
}
}
}
}
}
async fn handle_tick(&mut self) -> ControlFlow<(), ()> {
let Ok(player) = self.session.player().await else {
// Failure means that the session is gone, so we quit
return ControlFlow::Break(());
};
if !matches!(self.session.active().await, Ok(true)) {
// If the session is currently not active, just wait until it becomes active again
return ControlFlow::Continue(());
}
let Ok(Some(playback_info)) = player.playback_info().await else {
// If we're not playing anything, just wait until we are
return ControlFlow::Continue(());
};
if playback_info.track_id() != self.track {
// We're playing another track, reload the lyrics!
let lyrics = match player.get_lyrics().await {
Ok(lyrics) => lyrics,
Err(why) => {
error!("Failed to retrieve lyrics: {why}");
return ControlFlow::Break(());
}
};
self.lyrics = lyrics;
self.page = 0;
self.track = playback_info.track_id();
if let Err(why) = self
.message
.edit(
&self.ctx,
EditMessage::new()
.embed(lyrics_embed(&self.lyrics, &playback_info, self.page))
.components(vec![lyrics_buttons(
&self.guild_id,
&self.lyrics,
self.page,
)]),
)
.await
{
error!("Failed to update lyrics: {why}");
return ControlFlow::Break(());
}
return ControlFlow::Continue(());
}
// We're still playing the same song, check if we need to update the page
let Some(lyrics) = &self.lyrics else {
// No lyrics in current song, just continue until we have one with
return ControlFlow::Continue(());
};
if !matches!(lyrics.lyrics.sync_type, SyncType::LineSynced) {
// Only synced lyrics should auto-swap to new pages
return ControlFlow::Continue(());
}
let new_page = page_at_position(lyrics, playback_info.current_position()).unwrap_or(0);
if new_page != self.page {
// We've arrived on a new page: swap em up!
self.page = new_page;
if let Err(why) = self
.message
.edit(
&self.ctx,
EditMessage::new()
.embed(lyrics_embed(&self.lyrics, &playback_info, new_page))
.components(vec![lyrics_buttons(&self.guild_id, &self.lyrics, new_page)]),
)
.await
{
error!("Failed to update lyrics: {why}");
return ControlFlow::Break(());
}
}
ControlFlow::Continue(())
}
async fn handle_press(&mut self, press: ComponentInteraction) -> ControlFlow<(), ()> {
let next = match press.data.custom_id.split(':').nth(1) {
Some("next") => true,
Some("prev") => false,
_ => return ControlFlow::Continue(()),
};
let Some(lyrics) = &self.lyrics else {
return ControlFlow::Continue(());
};
if !matches!(lyrics.lyrics.sync_type, SyncType::Unsynced) {
// Only allow manual swapping if lyrics are unsynced
return ControlFlow::Continue(());
}
let length = lyrics
.lyrics
.lines
.iter()
.fold(0, |acc, line| acc + line.words.len());
let pages = length / PAGE_LENGTH + if length % PAGE_LENGTH > 0 { 1 } else { 0 };
let Ok(player) = self.session.player().await else {
return ControlFlow::Continue(());
};
let Ok(Some(playback_info)) = player.playback_info().await else {
return ControlFlow::Continue(());
};
match next {
true if self.page < pages - 1 => self.page += 1,
false if self.page > 0 => self.page -= 1,
_ => return ControlFlow::Continue(()),
}
if let Err(why) = self
.message
.edit(
&self.ctx,
EditMessage::new()
.embed(lyrics_embed(&self.lyrics, &playback_info, self.page))
.components(vec![lyrics_buttons(
&self.guild_id,
&self.lyrics,
self.page,
)]),
)
.await
{
error!("Failed to update lyrics: {why}");
return ControlFlow::Break(());
}
ControlFlow::Continue(())
}
}
async fn respond_not_playing(context: &Context, interaction: CommandInteraction) -> Result<()> {
interaction
.create_response(
context,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.embed(not_playing_embed())
.ephemeral(true),
),
)
.await?;
Ok(())
}
fn not_playing_embed() -> CreateEmbed {
CreateEmbed::new()
.title("Cannot get lyrics")
.description("I'm currently not playing any music in this server.")
.color(Colors::Error)
}
fn lyrics_embed(lyrics: &Option<Lyrics>, playback_info: &PlaybackInfo, page: usize) -> CreateEmbed {
match (lyrics, playback_info.artists()) {
(Some(lyrics), Some(artists)) => {
let length = lyrics
.lyrics
.lines
.iter()
.fold(0, |acc, line| acc + line.words.len());
let page = &into_pages(&lyrics.lyrics.lines)
[if page * PAGE_LENGTH > length { 0 } else { page }];
let title = format!(
"{} - {}",
playback_info.name(),
artists
.0
.into_iter()
.map(|artist| artist.name)
.collect::<Vec<_>>()
.join(", "),
);
let description = page
.iter()
.map(|page| page.words.replace('♪', "\n\n"))
.collect::<Vec<_>>()
.join("\n");
let mut footer = format!("Lyrics provided by {}", lyrics.lyrics.provider_display_name);
if matches!(lyrics.lyrics.sync_type, SyncType::LineSynced) {
footer.push_str(" | Synced to song");
}
CreateEmbed::new()
.title(title)
.description(description)
.footer(CreateEmbedFooter::new(footer))
.color(Colors::Info)
}
_ => CreateEmbed::new()
.title("No lyrics available")
.description("This current track has no lyrics available. Just enjoy the tunes!")
.color(Colors::Info),
}
}
fn lyrics_buttons(id: &str, lyrics: &Option<Lyrics>, page: usize) -> CreateActionRow {
let (can_prev, can_next) = match lyrics {
Some(lyrics) => match lyrics.lyrics.sync_type {
SyncType::Unsynced => {
// Only unsynced lyrics can have its pages flipped through by the user
let length = lyrics
.lyrics
.lines
.iter()
.fold(0, |acc, line| acc + line.words.len());
let pages = length / PAGE_LENGTH + if length % PAGE_LENGTH > 0 { 1 } else { 0 };
(page > 0, page < pages - 1)
}
SyncType::LineSynced => (false, false),
},
None => (false, false),
};
CreateActionRow::Buttons(vec![
CreateButton::new(format!("lyrics:prev:{id}"))
.disabled(!can_prev)
.label("<"),
CreateButton::new(format!("lyrics:next:{id}"))
.disabled(!can_next)
.label(">"),
])
}
fn into_pages(lines: &[Line]) -> Vec<Vec<Line>> {
let mut result = vec![];
let mut current = vec![];
let mut current_position = 0;
for line in lines {
if current_position + line.words.len() > PAGE_LENGTH {
result.push(current);
current = vec![line.clone()];
current_position = line.words.len();
continue;
}
current.push(line.clone());
current_position += line.words.len();
}
result.push(current);
result
}
fn page_at_position(lyrics: &Lyrics, position: u32) -> Option<usize> {
let pages = into_pages(&lyrics.lyrics.lines);
for (i, line) in pages.iter().enumerate() {
if let Some(first) = line.first() {
let Ok(time) = first
.start_time_ms
.parse::<u32>()
.map(|v| v.saturating_sub(TIME_OFFSET))
else {
return None;
};
if position < time {
return Some(if i == 0 { 0 } else { i - 1 });
}
}
if let (Some(first), Some(last)) = (line.first(), line.last()) {
let (Ok(first), Ok(last)) = (
first
.start_time_ms
.parse::<u32>()
.map(|v| v.saturating_sub(TIME_OFFSET)),
last.start_time_ms
.parse::<u32>()
.map(|v| v.saturating_sub(TIME_OFFSET)),
) else {
return None;
};
if position >= first && position <= last {
return Some(i);
}
}
}
Some(pages.len() - 1)
}

View File

@ -0,0 +1,126 @@
use anyhow::Result;
use serenity::all::{ChannelId, GuildId, UserId};
use songbird::Songbird;
use spoticord_database::Database;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use super::{Session, SessionHandle};
#[derive(Clone)]
pub struct SessionManager {
songbird: Arc<Songbird>,
database: Database,
sessions: Arc<Mutex<HashMap<GuildId, SessionHandle>>>,
owners: Arc<Mutex<HashMap<UserId, SessionHandle>>>,
}
pub enum SessionQuery {
Guild(GuildId),
Owner(UserId),
}
impl SessionManager {
pub fn new(songbird: Arc<Songbird>, database: Database) -> Self {
Self {
songbird,
database,
sessions: Arc::new(Mutex::new(HashMap::new())),
owners: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn create_session(
&self,
context: &serenity::all::Context,
guild_id: GuildId,
voice_channel_id: ChannelId,
text_channel_id: ChannelId,
owner: UserId,
) -> Result<SessionHandle> {
let handle = Session::create(
self.clone(),
context,
guild_id,
voice_channel_id,
text_channel_id,
owner,
)
.await?;
self.sessions
.lock()
.expect("mutex poisoned")
.insert(guild_id, handle.clone());
self.owners
.lock()
.expect("mutex poisoned")
.insert(owner, handle.clone());
Ok(handle)
}
pub fn get_session(&self, query: SessionQuery) -> Option<SessionHandle> {
match query {
SessionQuery::Guild(guild) => self
.sessions
.lock()
.expect("mutex poisoned")
.get(&guild)
.cloned(),
SessionQuery::Owner(owner) => self
.owners
.lock()
.expect("mutex poisoned")
.get(&owner)
.cloned(),
}
}
pub fn remove_session(&self, query: SessionQuery) {
match query {
SessionQuery::Guild(guild) => {
self.sessions.lock().expect("mutex poisoned").remove(&guild)
}
SessionQuery::Owner(owner) => {
self.owners.lock().expect("mutex poisoned").remove(&owner)
}
};
}
pub fn get_all_sessions(&self) -> Vec<SessionHandle> {
self.sessions
.lock()
.expect("mutex poisoned")
.values()
.cloned()
.collect()
}
/// Disconnects all active sessions and clears out all handles.
///
/// The session manager can still create new sessions after all sessions have been shut down.
/// Sessions might still be created during shutdown.
pub async fn shutdown_all(&self) {
let sessions = self.get_all_sessions();
for session in sessions {
session.disconnect().await;
}
self.owners.lock().expect("mutex poisoned").clear();
self.sessions.lock().expect("mutex poisoned").clear();
}
pub fn songbird(&self) -> Arc<Songbird> {
self.songbird.clone()
}
pub fn database(&self) -> Database {
self.database.clone()
}
}

View File

@ -0,0 +1,401 @@
use anyhow::{anyhow, Result};
use log::{error, trace};
use serenity::{
all::{
ButtonStyle, CommandInteraction, ComponentInteraction, ComponentInteractionCollector,
Context, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter,
CreateInteractionResponse, CreateInteractionResponseFollowup,
CreateInteractionResponseMessage, EditMessage, Message, User,
},
futures::StreamExt,
};
use spoticord_player::{info::PlaybackInfo, PlayerHandle};
use spoticord_utils::discord::Colors;
use std::{ops::ControlFlow, time::Duration};
use tokio::{sync::mpsc, time::Instant};
use crate::{Session, SessionHandle};
#[derive(Debug)]
pub enum Command {
InvokeUpdate,
}
pub struct PlaybackEmbed {
id: u64,
ctx: Context,
session: SessionHandle,
message: Message,
last_update: Instant,
update_in: Option<Duration>,
rx: mpsc::Receiver<Command>,
}
impl PlaybackEmbed {
pub async fn create(
session: &Session,
handle: SessionHandle,
interaction: CommandInteraction,
) -> Result<Option<PlaybackEmbedHandle>> {
let ctx = session.context.clone();
if !session.active {
respond_not_playing(&ctx, interaction).await?;
return Ok(None);
}
let owner = session.owner.to_user(&ctx).await?;
let Some(playback_info) = session.player.playback_info().await? else {
respond_not_playing(&ctx, interaction).await?;
return Ok(None);
};
let ctx_id = interaction.id.get();
// Send initial reply
interaction
.create_response(
&ctx,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.embed(build_embed(&playback_info, &owner))
.components(vec![build_buttons(ctx_id, playback_info.playing())]),
),
)
.await?;
// Retrieve message instead of editing interaction response, as those tokens are only valid for 15 minutes
let message = interaction.get_response(&ctx).await?;
let collector = ComponentInteractionCollector::new(&ctx)
.filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string()))
.timeout(Duration::from_secs(3600 * 24));
let (tx, rx) = mpsc::channel(16);
let this = Self {
id: ctx_id,
ctx,
session: handle,
message,
last_update: Instant::now(),
update_in: None,
rx,
};
tokio::spawn(this.run(collector));
Ok(Some(PlaybackEmbedHandle { tx }))
}
async fn run(mut self, collector: ComponentInteractionCollector) {
let mut stream = collector.stream();
loop {
tokio::select! {
opt_command = self.rx.recv() => {
let Some(command) = opt_command else {
break;
};
if self.handle_command(command).await.is_break() {
break;
}
},
opt_press = stream.next() => {
let Some(press) = opt_press else {
break;
};
self.handle_press(press).await;
}
_ = async {
if let Some(update_in) = self.update_in.take()
{
tokio::time::sleep(update_in).await;
}
}, if self.update_in.is_some() => {
if self.update_embed().await.is_break() {
break;
}
}
}
}
}
async fn handle_command(&mut self, command: Command) -> ControlFlow<(), ()> {
trace!("Received command: {command:?}");
match command {
Command::InvokeUpdate => {
if self.last_update.elapsed() < Duration::from_secs(2) {
if self.update_in.is_some() {
return ControlFlow::Continue(());
}
self.update_in = Some(Duration::from_secs(2) - self.last_update.elapsed());
} else {
self.update_embed().await?;
}
}
}
ControlFlow::Continue(())
}
async fn handle_press(&self, press: ComponentInteraction) {
trace!("Received button press: {press:?}");
let Ok((player, playback_info, owner)) = self.get_info().await else {
_ = press
.create_followup(
&self.ctx,
CreateInteractionResponseFollowup::new()
.embed(
CreateEmbed::new()
.title("Cannot perform action")
.description("I'm currently not playing any music in this server"),
)
.ephemeral(true),
)
.await;
return;
};
if press.user.id != owner.id {
_ = press
.create_followup(
&self.ctx,
CreateInteractionResponseFollowup::new()
.embed(
CreateEmbed::new()
.title("Cannot perform action")
.description("Only the host may use the media buttons"),
)
.ephemeral(true),
)
.await;
return;
}
match press.data.custom_id.split('-').last() {
Some("next") => player.next_track().await,
Some("prev") => player.previous_track().await,
Some("pause") => {
if playback_info.playing() {
player.pause().await
} else {
player.play().await
}
}
_ => {}
}
_ = press
.create_response(&self.ctx, CreateInteractionResponse::Acknowledge)
.await;
}
async fn get_info(&self) -> Result<(PlayerHandle, PlaybackInfo, User)> {
let player = self.session.player().await?;
let owner = self.session.owner().await?.to_user(&self.ctx).await?;
let playback_info = player
.playback_info()
.await?
.ok_or_else(|| anyhow!("No playback info present"))?;
Ok((player, playback_info, owner))
}
async fn update_embed(&mut self) -> ControlFlow<(), ()> {
self.update_in = None;
let Ok(owner) = self.session.owner().await else {
_ = self.update_not_playing().await;
return ControlFlow::Break(());
};
let Ok(player) = self.session.player().await else {
_ = self.update_not_playing().await;
return ControlFlow::Break(());
};
let Ok(Some(playback_info)) = player.playback_info().await else {
_ = self.update_not_playing().await;
return ControlFlow::Break(());
};
let owner = match owner.to_user(&self.ctx).await {
Ok(owner) => owner,
Err(why) => {
error!("Failed to resolve owner: {why}");
return ControlFlow::Break(());
}
};
if let Err(why) = self
.message
.edit(
&self.ctx,
EditMessage::new()
.embed(build_embed(&playback_info, &owner))
.components(vec![build_buttons(self.id, playback_info.playing())]),
)
.await
{
error!("Failed to update playback embed: {why}");
return ControlFlow::Break(());
};
self.last_update = Instant::now();
ControlFlow::Continue(())
}
async fn update_not_playing(&mut self) -> Result<()> {
self.message
.edit(&self.ctx, EditMessage::new().embed(not_playing_embed()))
.await?;
Ok(())
}
}
pub struct PlaybackEmbedHandle {
tx: mpsc::Sender<Command>,
}
impl PlaybackEmbedHandle {
pub fn is_valid(&self) -> bool {
!self.tx.is_closed()
}
pub async fn invoke_update(&self) -> Result<()> {
self.tx.send(Command::InvokeUpdate).await?;
Ok(())
}
}
async fn respond_not_playing(context: &Context, interaction: CommandInteraction) -> Result<()> {
interaction
.create_response(
context,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.embed(not_playing_embed())
.ephemeral(true),
),
)
.await?;
Ok(())
}
fn not_playing_embed() -> CreateEmbed {
CreateEmbed::new()
.title("Cannot display song details")
.description("I'm currently not playing any music in this server.")
.color(Colors::Error)
}
fn build_embed(playback_info: &PlaybackInfo, owner: &User) -> CreateEmbed {
let mut description = String::new();
description += &format!("## [{}]({})\n", playback_info.name(), playback_info.url());
if let Some(artists) = playback_info.artists() {
let artists = artists
.iter()
.map(|artist| {
format!(
"[{}](https://open.spotify.com/artist/{})",
artist.name,
artist.id.to_base62().expect("invalid artist")
)
})
.collect::<Vec<_>>()
.join(", ");
description += &format!("By {artists}\n\n");
}
if let Some(show_name) = playback_info.show_name() {
description += &format!("On {show_name}\n\n");
}
let position = playback_info.current_position();
let index = position * 20 / playback_info.duration();
description.push_str(if playback_info.playing() {
"▶️ "
} else {
"⏸️ "
});
for i in 0..20 {
if i == index {
description.push('🔵');
} else {
description.push('▬');
}
}
description.push_str("\n:alarm_clock: ");
description.push_str(&format!(
"{} / {}",
spoticord_utils::time_to_string(position / 1000),
spoticord_utils::time_to_string(playback_info.duration() / 1000)
));
CreateEmbed::new()
.author(
CreateEmbedAuthor::new("Currently Playing")
.icon_url("https://spoticord.com/spotify-logo.png"),
)
.description(description)
.thumbnail(playback_info.thumbnail())
.footer(
CreateEmbedFooter::new(owner.global_name.as_ref().unwrap_or(&owner.name))
.icon_url(owner.face()),
)
.color(Colors::Info)
}
fn build_buttons(id: u64, playing: bool) -> CreateActionRow {
let prev_button_id = format!("{id}-prev");
let next_button_id = format!("{id}-next");
let pause_button_id = format!("{id}-pause");
let prev_button = CreateButton::new(prev_button_id)
.style(ButtonStyle::Primary)
.label("<<");
let next_button = CreateButton::new(next_button_id)
.style(ButtonStyle::Primary)
.label(">>");
let pause_button = CreateButton::new(pause_button_id)
.style(if playing {
ButtonStyle::Danger
} else {
ButtonStyle::Success
})
.label(if playing { "Pause" } else { "Play" });
CreateActionRow::Buttons(vec![prev_button, pause_button, next_button])
}

View File

@ -0,0 +1,9 @@
[package]
name = "spoticord_stats"
version = "2.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
redis = { version = "0.25.4", default-features = false }

View File

@ -0,0 +1,18 @@
use redis::{Client, Commands, Connection, RedisResult as Result};
pub struct StatsManager {
redis: Connection,
}
impl StatsManager {
pub fn new(url: impl AsRef<str>) -> Result<Self> {
let client = Client::open(url.as_ref())?;
let connection = client.get_connection()?;
Ok(StatsManager { redis: connection })
}
pub fn set_active_count(&mut self, count: usize) -> Result<()> {
self.redis.set("spoticord-active-guilds", count.to_string())
}
}

View File

@ -0,0 +1,11 @@
[package]
name = "spoticord_utils"
version = "2.2.0"
edition = "2021"
[dependencies]
librespot = { git = "https://github.com/SpoticordMusic/librespot.git", version = "0.5.0-dev", default-features = false }
anyhow = "1.0.86"
base64 = "0.22.1"
log = "0.4.22"
poise = "0.6.1"

View File

@ -0,0 +1,27 @@
pub enum Colors {
Info = 0x0773D6,
Success = 0x3BD65D,
Warning = 0xF0D932,
Error = 0xFC1F28,
None = 0,
}
impl From<Colors> for poise::serenity_prelude::Colour {
fn from(value: Colors) -> Self {
Self(value as u32)
}
}
pub fn escape(text: impl Into<String>) -> String {
let text: String = text.into();
text.replace('\\', "\\\\")
.replace('/', "\\/")
.replace('*', "\\*")
.replace('_', "\\_")
.replace('~', "\\~")
.replace('`', "\\`")
// Prevent markdown links
.replace('[', "\\[")
.replace(']', "\\]")
}

View File

@ -0,0 +1,29 @@
pub mod discord;
pub mod spotify;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn get_time() -> 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_string(time: u32) -> String {
let hour = 3600;
let min = 60;
if time / hour >= 1 {
format!(
"{}h{}m{}s",
time / hour,
(time % hour) / min,
(time % hour) % min
)
} else if time / min >= 1 {
format!("{}m{}s", time / min, time % min)
} else {
format!("{}s", time)
}
}

View File

@ -0,0 +1,69 @@
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use librespot::{
core::{connection::AuthenticationError, Session, SessionConfig},
discovery::Credentials,
protocol::{authentication::AuthenticationType, keyexchange::ErrorCode},
};
use log::trace;
pub async fn validate_token(
username: impl Into<String>,
token: impl Into<String>,
) -> Result<Option<String>> {
let auth_data = BASE64.decode(token.into())?;
let credentials = Credentials {
username: username.into(),
auth_type: AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS,
auth_data,
};
trace!("Validating session token for {}", credentials.username);
let new_credentials = request_session_token(credentials.clone()).await?;
if credentials.auth_data != new_credentials.auth_data {
trace!("New session token retrieved for {}", credentials.username);
return Ok(Some(BASE64.encode(new_credentials.auth_data)));
}
Ok(None)
}
pub async fn request_session_token(credentials: Credentials) -> Result<Credentials> {
trace!("Requesting session token for {}", credentials.username);
let session = Session::new(SessionConfig::default(), None);
let mut tries = 0;
Ok(loop {
let (host, port) = session.apresolver().resolve("accesspoint").await?;
let mut transport = librespot::core::connection::connect(&host, port, None).await?;
match librespot::core::connection::authenticate(
&mut transport,
credentials.clone(),
&session.config().device_id,
)
.await
{
Ok(creds) => break creds,
Err(e) => {
if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) =
e.error.downcast_ref::<AuthenticationError>()
{
tries += 1;
if tries > 3 {
return Err(e.into());
}
continue;
} else {
return Err(e.into());
}
}
};
})
}

View File

@ -1,85 +0,0 @@
pub mod stream;
use self::stream::Stream;
use librespot::playback::audio_backend::{Sink, SinkAsBytes, SinkError, SinkResult};
use librespot::playback::convert::Converter;
use librespot::playback::decoder::AudioPacket;
use log::error;
use std::io::Write;
use tokio::sync::mpsc::UnboundedSender;
pub enum SinkEvent {
Start,
Stop,
}
pub struct StreamSink {
stream: Stream,
sender: UnboundedSender<SinkEvent>,
}
impl StreamSink {
pub fn new(stream: Stream, sender: UnboundedSender<SinkEvent>) -> Self {
Self { stream, sender }
}
}
impl Sink for StreamSink {
fn start(&mut self) -> SinkResult<()> {
if let Err(why) = self.sender.send(SinkEvent::Start) {
// WARNING: Returning an error causes librespot-playback to exit the process with status 1
error!("Failed to send start playback event: {why}");
return Err(SinkError::ConnectionRefused(why.to_string()));
}
Ok(())
}
fn stop(&mut self) -> SinkResult<()> {
if let Err(why) = self.sender.send(SinkEvent::Stop) {
// WARNING: Returning an error causes librespot-playback to exit the process with status 1
error!("Failed to send start playback event: {why}");
return Err(SinkError::ConnectionRefused(why.to_string()));
}
self.stream.flush().ok();
Ok(())
}
fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {
use zerocopy::AsBytes;
let AudioPacket::Samples(samples) = packet else {
return Ok(());
};
let samples_f32: &[f32] = &converter.f64_to_f32(&samples);
let resampled = samplerate::convert(
44100,
48000,
2,
samplerate::ConverterType::Linear,
samples_f32,
)
.expect("to succeed");
self.write_bytes(resampled.as_bytes())?;
Ok(())
}
}
impl SinkAsBytes for StreamSink {
fn write_bytes(&mut self, data: &[u8]) -> SinkResult<()> {
self
.stream
.write_all(data)
.map_err(|why| SinkError::OnWrite(why.to_string()))?;
Ok(())
}
}

View File

@ -1,90 +0,0 @@
use std::{
io::{Read, Seek, Write},
sync::{Arc, Condvar, Mutex},
};
use songbird::input::reader::MediaSource;
/// The lower the value, the less latency
///
/// Too low of a value results in unpredictable audio
const MAX_SIZE: usize = 32 * 1024;
#[derive(Clone)]
pub struct Stream {
inner: Arc<(Mutex<Vec<u8>>, Condvar)>,
}
impl Stream {
pub fn new() -> Self {
Self {
inner: Arc::new((Mutex::new(Vec::new()), Condvar::new())),
}
}
}
impl Read for Stream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let (mutex, condvar) = &*self.inner;
let mut buffer = mutex.lock().expect("Mutex was poisoned");
// Prevent Discord jitter by filling buffer with zeroes if we don't have any audio
// (i.e. when you skip too far ahead in a song which hasn't been downloaded yet)
if buffer.is_empty() {
buf.fill(0);
condvar.notify_all();
return Ok(buf.len());
}
let max_read = usize::min(buf.len(), buffer.len());
buf[0..max_read].copy_from_slice(&buffer[0..max_read]);
buffer.drain(0..max_read);
condvar.notify_all();
Ok(max_read)
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let (mutex, condvar) = &*self.inner;
let mut buffer = mutex.lock().expect("Mutex was poisoned");
while buffer.len() + buf.len() > MAX_SIZE {
buffer = condvar.wait(buffer).expect("Mutex was poisoned");
}
buffer.extend_from_slice(buf);
condvar.notify_all();
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
let (mutex, condvar) = &*self.inner;
let mut buffer = mutex.lock().expect("Mutex was poisoned");
buffer.clear();
condvar.notify_all();
Ok(())
}
}
impl Seek for Stream {
fn seek(&mut self, _: std::io::SeekFrom) -> std::io::Result<u64> {
Ok(0)
}
}
impl MediaSource for Stream {
fn byte_len(&self) -> Option<u64> {
None
}
fn is_seekable(&self) -> bool {
false
}
}

153
src/bot.rs 100644
View File

@ -0,0 +1,153 @@
use std::sync::Arc;
use anyhow::{anyhow, Result};
use log::{debug, info};
use poise::{serenity_prelude, Framework, FrameworkContext, FrameworkOptions};
use serenity::all::{ActivityData, FullEvent, Ready, ShardManager};
use spoticord_database::Database;
use spoticord_session::manager::SessionManager;
use crate::commands;
#[cfg(feature = "stats")]
use spoticord_stats::StatsManager;
pub type Context<'a> = poise::Context<'a, Data, anyhow::Error>;
pub type FrameworkError<'a> = poise::FrameworkError<'a, Data, anyhow::Error>;
type Data = SessionManager;
// pub struct Data {
// pub database: Database,
// pub session_manager: SessionManager,
// }
pub fn framework_opts() -> FrameworkOptions<Data, anyhow::Error> {
poise::FrameworkOptions {
commands: vec![
#[cfg(debug_assertions)]
commands::debug::ping(),
#[cfg(debug_assertions)]
commands::debug::token(),
commands::core::help(),
commands::core::version(),
commands::core::rename(),
commands::core::link(),
commands::core::unlink(),
commands::music::join(),
commands::music::disconnect(),
commands::music::stop(),
commands::music::playing(),
commands::music::lyrics(),
],
event_handler: |ctx, event, framework, data| {
Box::pin(event_handler(ctx, event, framework, data))
},
..Default::default()
}
}
pub async fn setup(
ctx: &serenity_prelude::Context,
ready: &Ready,
framework: &Framework<Data, anyhow::Error>,
database: Database,
) -> Result<Data> {
info!("Successfully logged in as {}", ready.user.name);
#[cfg(debug_assertions)]
poise::builtins::register_in_guild(
ctx,
&framework.options().commands,
std::env::var("GUILD_ID")?.parse()?,
)
.await?;
#[cfg(not(debug_assertions))]
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
let songbird = songbird::get(ctx)
.await
.ok_or_else(|| anyhow!("Songbird was not registered during setup"))?;
let manager = SessionManager::new(songbird, database);
#[cfg(feature = "stats")]
let stats = StatsManager::new(std::env::var("KV_URL")?)?;
tokio::spawn(background_loop(
manager.clone(),
framework.shard_manager().clone(),
#[cfg(feature = "stats")]
stats,
));
Ok(manager)
}
async fn event_handler(
ctx: &serenity_prelude::Context,
event: &FullEvent,
_framework: FrameworkContext<'_, Data, anyhow::Error>,
_data: &Data,
) -> Result<()> {
if let FullEvent::Ready { data_about_bot } = event {
if let Some(shard) = data_about_bot.shard {
debug!(
"Shard {} logged in (total shards: {})",
shard.id.0, shard.total
);
}
ctx.set_activity(Some(ActivityData::listening(spoticord_config::MOTD)));
}
Ok(())
}
async fn background_loop(
session_manager: SessionManager,
shard_manager: Arc<ShardManager>,
#[cfg(feature = "stats")] mut stats_manager: spoticord_stats::StatsManager,
) {
#[cfg(feature = "stats")]
use log::{error, trace};
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
#[cfg(feature = "stats")]
{
trace!("Retrieving active sessions count for stats");
let mut count = 0;
for session in session_manager.get_all_sessions() {
if matches!(session.active().await, Ok(true)) {
count += 1;
}
}
if let Err(why) = stats_manager.set_active_count(count) {
error!("Failed to update active sessions: {why}");
} else {
trace!("Active session count set to: {count}");
}
}
}
_ = tokio::signal::ctrl_c() => {
info!("Received interrupt signal, shutting down...");
session_manager.shutdown_all().await;
shard_manager.shutdown_all().await;
#[cfg(feature = "stats")]
stats_manager.set_active_count(0).ok();
break;
}
}
}
}

View File

@ -1,40 +0,0 @@
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 command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Spoticord Help")
.icon_url("https://spoticord.com/logo-standard.webp")
.description("**Welcome to Spoticord**
It seems you have requested some help. Not to worry, we can help you out.\n
**Not sure how the bot works?**
**[Click here](https://spoticord.com/#how-to)** for a quick overview about how to set up Spoticord and how to use it.\n
**Which commands are there?**
You can find all **[the commands](https://spoticord.com/#commands)** on the website. You may also just type `/` in Discord and see which commands are available there.\n
**Need more help?**
If you still need some help, whether you are having issues with the bot or you just want to give us some feedback, you can join our **[Discord server](https://discord.gg/wRCyhVqBZ5)**.".to_string())
.status(Status::Info)
.build(),
false,
)
.await;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command.name(NAME).description("Shows the help message")
}

View File

@ -1,159 +0,0 @@
use log::error;
use reqwest::StatusCode;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
};
use crate::{
bot::commands::{respond_message, CommandOutput},
consts::SPOTICORD_ACCOUNTS_URL,
database::{Database, DatabaseError},
utils::embed::{EmbedBuilder, Status},
};
pub const NAME: &str = "link";
pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let data = ctx.data.read().await;
let database = data.get::<Database>().expect("to contain a value");
if database
.get_user_account(command.user.id.to_string())
.await
.is_ok()
{
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You have already linked your Spotify account.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
if let Ok(request) = database.get_user_request(command.user.id.to_string()).await {
let link = format!(
"{}/spotify/{}",
SPOTICORD_ACCOUNTS_URL.as_str(),
request.token
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Link your Spotify account")
.title_url(&link)
.icon_url("https://spoticord.com/spotify-logo.png")
.description(format!(
"Go to [this link]({}) to connect your Spotify account.",
link
))
.status(Status::Info)
.build(),
true,
)
.await;
return;
}
// 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 link your Spotify account.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
}
_ => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("Something went wrong while trying to link your Spotify account.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
}
}
match database
.create_user_request(command.user.id.to_string())
.await
{
Ok(request) => {
let link = format!(
"{}/spotify/{}",
SPOTICORD_ACCOUNTS_URL.as_str(),
request.token
);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Link your Spotify account")
.title_url(&link)
.icon_url("https://spoticord.com/spotify-logo.png")
.description(format!(
"Go to [this link]({}) to connect your Spotify account.",
link
))
.status(Status::Info)
.build(),
true,
)
.await;
}
Err(why) => {
error!("Error creating user request: {:?}", why);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("An error occurred while serving your request. Please try again later.")
.status(Status::Error)
.build(),
true,
)
.await;
}
};
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Link your Spotify account to Spoticord")
}

View File

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

View File

@ -1,165 +0,0 @@
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 command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let data = ctx.data.read().await;
let database = data.get::<Database>().expect("to contain a value");
// 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().expect("to be a string").to_string(),
None => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You need to provide a name for your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
},
None => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You need to provide a name for your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
if let Err(why) = database
.update_user_device_name(command.user.id.to_string(), &device_name)
.await
{
if let DatabaseError::InvalidInputBody(_) = why {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description(
"Your device name must not exceed 16 characters and be at least 1 character long.",
)
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
error!("Error updating user device name: {:?}", why);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("Something went wrong while trying to rename your Spoticord device.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description(format!(
"Successfully changed the Spotify device name to **{}**",
utils::discord::escape(device_name)
))
.status(Status::Success)
.build(),
true,
)
.await;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Set a new device name that is displayed in Spotify")
.create_option(|option| {
option
.name("name")
.description("The new device name")
.kind(CommandOptionType::String)
.max_length(16)
.required(true)
})
}

View File

@ -1,83 +0,0 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
};
use crate::{
bot::commands::{respond_message, CommandOutput},
database::{Database, DatabaseError},
session::manager::SessionManager,
utils::embed::{EmbedBuilder, Status},
};
pub const NAME: &str = "unlink";
pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let data = ctx.data.read().await;
let database = data.get::<Database>().expect("to contain a value");
let session_manager = data.get::<SessionManager>().expect("to contain a value");
// Disconnect session if user has any
if let Some(session) = session_manager.find(command.user.id).await {
session.disconnect().await;
}
// Check if user exists in the first place
if let Err(why) = database
.delete_user_account(command.user.id.to_string())
.await
{
if let DatabaseError::InvalidStatusCode(status) = why {
if status == 404 {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You cannot unlink your Spotify account if you haven't linked one.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
}
error!("Error deleting user account: {:?}", why);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("An unexpected error has occured while trying to unlink your account. Please try again later.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("Successfully unlinked your Spotify account from Spoticord")
.status(Status::Success)
.build(),
true,
)
.await;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Unlink your Spotify account from Spoticord")
}

View File

@ -1,46 +0,0 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
prelude::Context,
};
use crate::{bot::commands::CommandOutput, consts::VERSION, utils::embed::Status};
pub const NAME: &str = "version";
pub fn command(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://cdn.discordapp.com/avatars/389786424142200835/6bfe3840b0aa6a1baf432bb251b70c9f.webp?size=128")
})
.description(format!("Current version: {}\n\nSpoticord is open source, check out [our GitHub](https://github.com/SpoticordMusic)", VERSION))
.color(Status::Info as u64)
})
})
})
.await
{
error!("Error sending message: {:?}", why);
}
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Shows the current running version of Spoticord")
}

View File

@ -1,309 +0,0 @@
use std::{collections::HashMap, future::Future, pin::Pin};
use log::{debug, error};
use serenity::{
builder::{CreateApplicationCommand, CreateApplicationCommands},
model::application::command::Command,
model::prelude::{
interaction::{
application_command::ApplicationCommandInteraction,
message_component::MessageComponentInteraction, InteractionResponseType,
},
GuildId,
},
prelude::{Context, TypeMapKey},
};
use crate::utils::embed::{make_embed_message, EmbedMessageOptions};
mod core;
mod music;
#[cfg(debug_assertions)]
mod ping;
#[cfg(debug_assertions)]
mod token;
pub async fn respond_message(
ctx: &Context,
command: &ApplicationCommandInteraction,
options: EmbedMessageOptions,
ephemeral: bool,
) {
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message
.embed(|embed| make_embed_message(embed, options))
.ephemeral(ephemeral)
})
})
.await
{
error!("Error sending message: {:?}", why);
}
}
pub async fn respond_component_message(
ctx: &Context,
component: &MessageComponentInteraction,
options: EmbedMessageOptions,
ephemeral: bool,
) {
if let Err(why) = component
.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 async fn update_message(
ctx: &Context,
command: &ApplicationCommandInteraction,
options: EmbedMessageOptions,
) {
if let Err(why) = command
.edit_original_interaction_response(&ctx.http, |message| {
message.embed(|embed| make_embed_message(embed, options))
})
.await
{
error!("Error sending message: {:?}", why);
}
}
pub async fn defer_message(
ctx: &Context,
command: &ApplicationCommandInteraction,
ephemeral: bool,
) {
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::DeferredChannelMessageWithSource)
.interaction_response_data(|message| message.ephemeral(ephemeral))
})
.await
{
error!("Error deferring message: {:?}", why);
}
}
pub type CommandOutput = Pin<Box<dyn Future<Output = ()> + Send>>;
pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput;
pub type ComponentExecutor = fn(Context, MessageComponentInteraction) -> CommandOutput;
#[derive(Clone)]
pub struct CommandManager {
commands: HashMap<String, CommandInfo>,
}
#[derive(Clone)]
pub struct CommandInfo {
pub name: String,
pub command_executor: CommandExecutor,
pub component_executor: Option<ComponentExecutor>,
pub register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand,
}
impl CommandManager {
pub fn new() -> Self {
let mut instance = Self {
commands: HashMap::new(),
};
// Debug-only commands
#[cfg(debug_assertions)]
{
instance.insert(ping::NAME, ping::register, ping::command, None);
instance.insert(token::NAME, token::register, token::command, None);
}
// Core commands
instance.insert(
core::help::NAME,
core::help::register,
core::help::command,
None,
);
instance.insert(
core::version::NAME,
core::version::register,
core::version::command,
None,
);
instance.insert(
core::link::NAME,
core::link::register,
core::link::command,
None,
);
instance.insert(
core::unlink::NAME,
core::unlink::register,
core::unlink::command,
None,
);
instance.insert(
core::rename::NAME,
core::rename::register,
core::rename::command,
None,
);
// Music commands
instance.insert(
music::join::NAME,
music::join::register,
music::join::command,
None,
);
instance.insert(
music::leave::NAME,
music::leave::register,
music::leave::command,
None,
);
instance.insert(
music::playing::NAME,
music::playing::register,
music::playing::command,
Some(music::playing::component),
);
instance
}
pub fn insert(
&mut self,
name: impl Into<String>,
register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand,
command_executor: CommandExecutor,
component_executor: Option<ComponentExecutor>,
) {
let name = name.into();
self.commands.insert(
name.clone(),
CommandInfo {
name,
register,
command_executor,
component_executor,
},
);
}
pub async fn register(&self, ctx: &Context) {
let cmds = &self.commands;
debug!(
"Registering {} command{}",
cmds.len(),
if cmds.len() == 1 { "" } else { "s" }
);
fn _register_commands<'a>(
cmds: &HashMap<String, CommandInfo>,
mut commands: &'a mut CreateApplicationCommands,
) -> &'a mut CreateApplicationCommands {
for command_info in cmds.values() {
commands = commands.create_application_command(|command| (command_info.register)(command));
}
commands
}
if let Ok(guild_id) = std::env::var("GUILD_ID") {
if let Ok(guild_id) = guild_id.parse::<u64>() {
let guild_id = GuildId(guild_id);
guild_id
.set_application_commands(&ctx.http, |command| _register_commands(cmds, command))
.await
.expect("Failed to create guild commands");
return;
}
}
Command::set_global_application_commands(&ctx.http, |command| {
_register_commands(cmds, command)
})
.await
.expect("Failed to create global commands");
}
// On slash command interaction
pub async fn execute_command(&self, ctx: &Context, interaction: ApplicationCommandInteraction) {
let command = self.commands.get(&interaction.data.name);
if let Some(command) = command {
(command.command_executor)(ctx.clone(), interaction.clone()).await;
} else {
// Command does not exist
if let Err(why) = interaction
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message
.content("Woops, that command doesn't exist")
.ephemeral(true)
})
})
.await
{
error!("Failed to respond to command: {}", why);
}
}
}
// On message component interaction (e.g. button)
pub async fn execute_component(&self, ctx: &Context, interaction: MessageComponentInteraction) {
let command = match interaction.data.custom_id.split("::").next() {
Some(command) => command,
None => return,
};
let command = self.commands.get(command);
if let Some(command) = command {
if let Some(executor) = command.component_executor {
executor(ctx.clone(), interaction.clone()).await;
return;
}
}
if let Err(why) = interaction
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message
.content("Woops, that interaction doesn't exist")
.ephemeral(true)
})
})
.await
{
error!("Failed to respond to interaction: {}", why);
}
}
}
impl TypeMapKey for CommandManager {
type Value = CommandManager;
}

View File

@ -1,339 +0,0 @@
use log::error;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::{interaction::application_command::ApplicationCommandInteraction, Channel},
prelude::Context,
};
use crate::{
bot::commands::{defer_message, respond_message, update_message, CommandOutput},
consts::SPOTICORD_ACCOUNTS_URL,
session::manager::{SessionCreateError, SessionManager},
utils::embed::{EmbedBuilder, Status},
};
pub const NAME: &str = "join";
pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let guild = ctx
.cache
.guild(command.guild_id.expect("to contain a value"))
.expect("to be present");
// Get the voice channel id of the calling user
let channel_id = match guild
.voice_states
.get(&command.user.id)
.and_then(|state| state.channel_id)
{
Some(channel_id) => channel_id,
None => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description("You need to connect to a voice channel")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
// Check for Voice Channel permissions
{
let channel = match channel_id.to_channel(&ctx).await {
Ok(channel) => match channel {
Channel::Guild(channel) => channel,
_ => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description("The voice channel you are in is not supported")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
},
Err(why) => {
error!("Failed to get channel: {}", why);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description("The voice channel you are in is not available.\nI might not the permission to see this channel.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
if let Ok(permissions) = channel.permissions_for_user(&ctx.cache, ctx.cache.current_user_id())
{
if !permissions.view_channel() || !permissions.connect() || !permissions.speak() {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description("I do not have the permissions to connect to that voice channel")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
}
}
// Check for Text Channel permissions
{
let channel = match command.channel_id.to_channel(&ctx).await {
Ok(channel) => match channel {
Channel::Guild(channel) => channel,
_ => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description("The text channel you are in is not supported")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
},
Err(why) => {
error!("Failed to get channel: {}", why);
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description("The text channel you are in is not available.\nI might not have the permission to see this channel.")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
if let Ok(permissions) = channel.permissions_for_user(&ctx.cache, ctx.cache.current_user_id())
{
if !permissions.view_channel() || !permissions.send_messages() || !permissions.embed_links()
{
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description(
"I do not have the permissions to send messages / links in this text channel",
)
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
}
}
let data = ctx.data.read().await;
let session_manager = data
.get::<SessionManager>()
.expect("to contain a value")
.clone();
// Check if another session is already active in this server
let mut session_opt = session_manager.get_session(guild.id).await;
if let Some(session) = &session_opt {
if let Some(owner) = session.owner().await {
let msg = if owner == command.user.id {
"You are already controlling the bot"
} else {
"The bot is currently being controlled by someone else"
};
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description(msg)
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
// Prevent duplicate Spotify sessions
if let Some(session) = session_manager.find(command.user.id).await {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description(
format!(
"You are already playing music in another server ({}).\nStop playing in that server first before joining this one.",
ctx.cache.guild(session.guild_id().await).expect("to be present").name
)).status(Status::Error).build(),
true,
)
.await;
return;
}
defer_message(&ctx, &command, false).await;
if let Some(session) = &session_opt {
if session.channel_id().await != channel_id {
session.disconnect().await;
session_opt = None;
// Give serenity/songbird some time to register the disconnect
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
macro_rules! report_error {
($why:ident) => {
match $why {
// User has not linked their account
SessionCreateError::NoSpotify => {
update_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description(format!("You need to link your Spotify account. Use </link:1036714850367320136> or go to [the accounts website]({}) to get started.", SPOTICORD_ACCOUNTS_URL.as_str()))
.status(Status::Error)
.build(),
)
.await;
}
// Spotify credentials have expired or are invalid
SessionCreateError::SpotifyExpired => {
update_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description(format!("Spoticord no longer has access to your Spotify account. Use </link:1036714850367320136> or go to [the accounts website]({}) to relink your Spotify account.", SPOTICORD_ACCOUNTS_URL.as_str()))
.status(Status::Error)
.build(),
).await;
}
// Songbird error
SessionCreateError::JoinError(why) => {
update_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description(format!(
"An error occured while joining the channel. Please try running </join:1036714850367320142> again.\n\nError details: `{why}`"
))
.status(Status::Error)
.build(),
)
.await;
}
// Any other error
_ => {
update_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot join voice channel")
.description("An error occured while joining the channel. Please try again later.")
.status(Status::Error)
.build(),
)
.await;
}
}
return;
};
}
if let Some(session) = session_opt.as_mut() {
if let Err(why) = session.update_owner(&ctx, command.user.id).await {
report_error!(why);
}
} else {
// Create the session, and handle potential errors
if let Err(why) = session_manager
.create_session(
&ctx,
guild.id,
channel_id,
command.channel_id,
command.user.id,
)
.await
{
report_error!(why);
};
}
update_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Connected to voice channel")
.icon_url("https://spoticord.com/speaker.png")
.description(format!("Come listen along in <#{}>", channel_id))
.footer("You must manually go to Spotify and select your device")
.status(Status::Info)
.build(),
)
.await;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Request the bot to join the current voice channel")
}

View File

@ -1,82 +0,0 @@
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::application_command::ApplicationCommandInteraction,
prelude::Context,
};
use crate::{
bot::commands::{respond_message, CommandOutput},
session::manager::SessionManager,
utils::embed::{EmbedBuilder, Status},
};
pub const NAME: &str = "leave";
pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let data = ctx.data.read().await;
let session_manager = data
.get::<SessionManager>()
.expect("to contain a value")
.clone();
let session = match session_manager
.get_session(command.guild_id.expect("to contain a value"))
.await
{
Some(session) => session,
None => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot disconnect bot")
.description("I'm currently not connected to any voice channel")
.status(Status::Error)
.build(),
true,
)
.await;
return;
}
};
if let Some(owner) = session.owner().await {
if owner != command.user.id {
// This message was generated by AI, and I love it.
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.description("You are not the one who summoned me")
.status(Status::Error)
.build(),
true,
)
.await;
return;
};
}
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;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Request the bot to leave the current voice channel")
}

View File

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

View File

@ -1,461 +0,0 @@
use std::time::Duration;
use librespot::core::spotify_id::SpotifyId;
use log::error;
use serenity::{
builder::{CreateApplicationCommand, CreateButton, CreateComponents, CreateEmbed},
model::{
prelude::{
component::ButtonStyle,
interaction::{
application_command::ApplicationCommandInteraction,
message_component::MessageComponentInteraction, InteractionResponseType,
},
},
user::User,
},
prelude::Context,
};
use crate::{
bot::commands::{respond_component_message, respond_message, CommandOutput},
session::{manager::SessionManager, pbi::PlaybackInfo},
utils::{
self,
embed::{EmbedBuilder, Status},
},
};
pub const NAME: &str = "playing";
pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
macro_rules! not_playing {
() => {
respond_message(
&ctx,
&command,
EmbedBuilder::new()
.title("Cannot get track info")
.icon_url("https://spoticord.com/forbidden.png")
.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>()
.expect("to contain a value")
.clone();
let Some(session) = session_manager
.get_session(command.guild_id.expect("to contain a value"))
.await
else {
not_playing!();
return;
};
let Some(owner) = session.owner().await else {
not_playing!();
return;
};
// Get Playback Info from session
let Some(pbi) = session.playback_info().await else {
not_playing!();
return;
};
// Get owner of session
let Some(owner) = utils::discord::get_user(&ctx, owner).await else {
// This shouldn't happen
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 metadata
let (title, description, thumbnail) = get_metadata(&pbi);
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message
.set_embed(build_playing_embed(
title,
pbi.get_type(),
pbi.spotify_id,
description,
owner,
thumbnail,
))
.components(|components| create_button(components, pbi.is_playing))
})
})
.await
{
error!("Error sending message: {why:?}");
}
})
}
pub fn component(ctx: Context, mut interaction: MessageComponentInteraction) -> CommandOutput {
Box::pin(async move {
let error_message = |title: &'static str, description: &'static str| async {
respond_component_message(
&ctx,
&interaction,
EmbedBuilder::new()
.title(title.to_string())
.icon_url("https://spoticord.com/forbidden.png")
.description(description.to_string())
.status(Status::Error)
.build(),
true,
)
.await;
};
let error_edit = |title: &'static str, description: &'static str| {
let mut interaction = interaction.clone();
let ctx = ctx.clone();
async move {
interaction.defer(&ctx.http).await.ok();
if let Err(why) = interaction
.message
.edit(&ctx, |message| {
message.embed(|embed| {
embed
.description(description)
.author(|author| {
author
.name(title)
.icon_url("https://spoticord.com/forbidden.png")
})
.color(Status::Error)
})
})
.await
{
error!("Failed to update playing message: {why}");
}
}
};
let data = ctx.data.read().await;
let session_manager = data
.get::<SessionManager>()
.expect("to contain a value")
.clone();
// Check if session still exists
let Some(mut session) = session_manager
.get_session(interaction.guild_id.expect("to contain a value"))
.await
else {
error_edit(
"Cannot perform action",
"I'm currently not playing any music in this server",
)
.await;
return;
};
// Check if the session contains an owner
let Some(owner) = session.owner().await else {
error_edit(
"Cannot change playback state",
"I'm currently not playing any music in this server",
)
.await;
return;
};
// Get Playback Info from session
let Some(pbi) = session.playback_info().await else {
error_edit(
"Cannot change playback state",
"I'm currently not playing any music in this server",
)
.await;
return;
};
// Check if the user is the owner of the session
if owner != interaction.user.id {
error_message(
"Cannot change playback state",
"You must be the host to use the media buttons",
)
.await;
return;
}
// Get owner of session
let Some(owner) = utils::discord::get_user(&ctx, owner).await else {
// This shouldn't happen
error!("Could not find user with ID: {owner}");
respond_component_message(
&ctx,
&interaction,
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;
};
// Send the desired command to the session
match interaction.data.custom_id.as_str() {
"playing::btn_pause_play" => {
if pbi.is_playing {
session.pause().await
} else {
session.resume().await
}
}
"playing::btn_previous_track" => session.previous().await,
"playing::btn_next_track" => session.next().await,
_ => {
error!("Unknown custom_id: {}", interaction.data.custom_id);
}
};
interaction.defer(&ctx.http).await.ok();
tokio::time::sleep(Duration::from_millis(
if interaction.data.custom_id == "playing::btn_pause_play" {
0
} else {
2500
},
))
.await;
update_embed(&mut interaction, &ctx, owner).await;
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name(NAME)
.description("Display which song is currently being played")
}
fn create_button(components: &mut CreateComponents, playing: bool) -> &mut CreateComponents {
let mut prev_btn = CreateButton::default();
prev_btn
.style(ButtonStyle::Primary)
.label("<<")
.custom_id("playing::btn_previous_track");
let mut toggle_btn = CreateButton::default();
toggle_btn
.style(ButtonStyle::Secondary)
.label(if playing { "Pause" } else { "Play" })
.custom_id("playing::btn_pause_play");
let mut next_btn = CreateButton::default();
next_btn
.style(ButtonStyle::Primary)
.label(">>")
.custom_id("playing::btn_next_track");
components.create_action_row(|ar| {
ar.add_button(prev_btn)
.add_button(toggle_btn)
.add_button(next_btn)
})
}
async fn update_embed(interaction: &mut MessageComponentInteraction, ctx: &Context, owner: User) {
let error_edit = |title: &'static str, description: &'static str| {
let mut interaction = interaction.clone();
let ctx = ctx.clone();
async move {
interaction.defer(&ctx.http).await.ok();
if let Err(why) = interaction
.message
.edit(&ctx, |message| {
message.embed(|embed| {
embed
.description(description)
.author(|author| {
author
.name(title)
.icon_url("https://spoticord.com/forbidden.png")
})
.color(Status::Error)
})
})
.await
{
error!("Failed to update playing message: {why}");
}
}
};
let data = ctx.data.read().await;
let session_manager = data
.get::<SessionManager>()
.expect("to contain a value")
.clone();
// Check if session still exists
let Some(session) = session_manager
.get_session(interaction.guild_id.expect("to contain a value"))
.await
else {
error_edit(
"Cannot perform action",
"I'm currently not playing any music in this server",
)
.await;
return;
};
// Get Playback Info from session
let Some(pbi) = session.playback_info().await else {
error_edit(
"Cannot change playback state",
"I'm currently not playing any music in this server",
)
.await;
return;
};
let (title, description, thumbnail) = get_metadata(&pbi);
if let Err(why) = interaction
.message
.edit(&ctx, |message| {
message
.set_embed(build_playing_embed(
title,
pbi.get_type(),
pbi.spotify_id,
description,
owner,
thumbnail,
))
.components(|components| create_button(components, pbi.is_playing));
message
})
.await
{
error!("Failed to update playing message: {why}");
}
}
fn build_playing_embed(
title: impl Into<String>,
audio_type: impl Into<String>,
spotify_id: SpotifyId,
description: impl Into<String>,
owner: User,
thumbnail: impl Into<String>,
) -> CreateEmbed {
let mut embed = CreateEmbed::default();
embed
.author(|author| {
author
.name("Currently Playing")
.icon_url("https://spoticord.com/spotify-logo.png")
})
.title(title.into())
.url(format!(
"https://open.spotify.com/{}/{}",
audio_type.into(),
spotify_id
.to_base62()
.expect("to be able to convert to base62")
))
.description(description.into())
.footer(|footer| footer.text(&owner.name).icon_url(owner.face()))
.thumbnail(thumbnail.into())
.color(Status::Info);
embed
}
fn get_metadata(pbi: &PlaybackInfo) -> (String, String, String) {
// Create title
let title = format!("{} - {}", pbi.get_artists(), pbi.get_name());
// 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('🔵');
} else {
description.push('▬');
}
}
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 the thumbnail image
let thumbnail = pbi.get_thumbnail_url().expect("to contain a value");
(title, description, thumbnail)
}

View File

@ -1,33 +0,0 @@
use log::info;
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
prelude::Context,
};
use super::CommandOutput;
pub const NAME: &str = "ping";
pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
info!("Pong!");
command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| message.content("Pong!"))
})
.await
.ok();
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name("ping")
.description("Check if the bot is alive")
}

View File

@ -1,42 +0,0 @@
use serenity::{
builder::CreateApplicationCommand,
model::prelude::interaction::{
application_command::ApplicationCommandInteraction, InteractionResponseType,
},
prelude::Context,
};
use crate::database::Database;
use super::CommandOutput;
pub const NAME: &str = "token";
pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput {
Box::pin(async move {
let data = ctx.data.read().await;
let db = data.get::<Database>().expect("to contain a value");
let token = db.get_access_token(command.user.id.to_string()).await;
let content = match token {
Ok(token) => format!("Your token is: {}", token),
Err(why) => format!("You don't have a token yet. (Real: {})", why),
};
command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| message.content(content).ephemeral(true))
})
.await
.ok();
})
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name("token")
.description("Get your Spotify access token")
}

View File

@ -1,136 +0,0 @@
/* This file implements all events for the Discord gateway */
use super::commands::CommandManager;
use crate::consts::MOTD;
use log::*;
use serenity::{
async_trait,
model::prelude::{
interaction::{
application_command::ApplicationCommandInteraction,
message_component::MessageComponentInteraction, Interaction,
},
Activity, GuildId, Ready,
},
prelude::{Context, EventHandler},
};
// If the GUILD_ID environment variable is set, only allow commands from that guild
macro_rules! enforce_guild {
($interaction:ident) => {
if let Ok(guild_id) = std::env::var("GUILD_ID") {
if let Ok(guild_id) = guild_id.parse::<u64>() {
let guild_id = GuildId(guild_id);
if let Some(interaction_guild_id) = $interaction.guild_id {
if guild_id != interaction_guild_id {
return;
}
}
}
}
};
}
// Handler struct with a command parameter, an array of dictionary which takes a string and function
pub struct Handler;
#[async_trait]
impl EventHandler for Handler {
// READY event, emitted when the bot/shard starts up
async fn ready(&self, ctx: Context, ready: Ready) {
let data = ctx.data.read().await;
let command_manager = data.get::<CommandManager>().expect("to contain a value");
debug!("Ready received, logged in as {}", ready.user.name);
command_manager.register(&ctx).await;
ctx.set_activity(Activity::listening(MOTD)).await;
info!("{} has come online", ready.user.name);
}
// INTERACTION_CREATE event, emitted when the bot receives an interaction (slash command, button, etc.)
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::ApplicationCommand(command) => handle_command(ctx, command).await,
Interaction::MessageComponent(component) => handle_component(ctx, component).await,
_ => {}
}
}
}
async fn handle_command(ctx: Context, command: ApplicationCommandInteraction) {
enforce_guild!(command);
// Commands must only be executed inside of guilds
let guild_id = match command.guild_id {
Some(guild_id) => guild_id,
None => {
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message.content("You can only execute commands inside of a server")
})
})
.await {
error!("Failed to send run-in-guild-only error message: {}", why);
}
return;
}
};
trace!(
"Received command interaction: command={} user={} guild={}",
command.data.name,
command.user.id,
guild_id
);
let data = ctx.data.read().await;
let command_manager = data.get::<CommandManager>().expect("to contain a value");
command_manager.execute_command(&ctx, command).await;
}
async fn handle_component(ctx: Context, component: MessageComponentInteraction) {
enforce_guild!(component);
// Components can only be interacted with inside of guilds
let guild_id = match component.guild_id {
Some(guild_id) => guild_id,
None => {
if let Err(why) = component
.create_interaction_response(&ctx.http, |response| {
response
.kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| {
message.content("You can only interact with components inside of a server")
})
})
.await {
error!("Failed to send run-in-guild-only error message: {}", why);
}
return;
}
};
trace!(
"Received component interaction: command={} user={} guild={}",
component.data.custom_id,
component.user.id,
guild_id
);
let data = ctx.data.read().await;
let command_manager = data.get::<CommandManager>().expect("to contain a value");
command_manager.execute_component(&ctx, component).await;
}

View File

@ -1,2 +0,0 @@
pub mod commands;
pub mod events;

View File

@ -0,0 +1,24 @@
**Welcome to Spoticord**
Looks like you could use some help! Lets go over the basics!
**What is Spoticord?**
Spoticord is a Discord music bot that acts like a Spotify speaker.
_What does that mean?_
Well think of it as being together with a group of friends, and playing some music over a bluetooth speaker.
That is what Spoticord does, but instead of being a bluetooth speaker, it's a Discord bot!
**Do I need Spotify Premium?**
**_Yes_**, Spotify Premium is required for this bot to work.
This is a limitation set by Spotify, and even if this wasn't the case Spoticord still wouldn't allow free users.
This is because Spoticord does not support Spotify Free "features" (ads, limited skips, etc).
**How to use the bot**
**[Click here](https://spoticord.com/#how-to)** for a quick overview about how to set up Spoticord, and some basic usage tips.
**What commands can I use?**
For a list of commands, you can check out [the commands section](https://spoticord.com/#commands) on the website.
You can also just type `/` in a text chat and Discord will automatically show you all available commands.
**Still stuck on something?**
If you still need some help, feel free to join the **[Spoticord Discord Server](https://discord.gg/wRCyhVqBZ5)**.

View File

@ -0,0 +1,27 @@
use anyhow::Result;
use poise::CreateReply;
use serenity::all::{CreateEmbed, CreateEmbedAuthor};
use spoticord_utils::discord::Colors;
use crate::bot::Context;
const HELP_MESSAGE: &str = include_str!("help.md");
/// Displays the help message
#[poise::command(slash_command)]
pub async fn help(ctx: Context<'_>) -> Result<()> {
ctx.send(
CreateReply::default().embed(
CreateEmbed::new()
.author(
CreateEmbedAuthor::new("Spoticord Help")
.icon_url("https://spoticord.com/logo-standard.webp"),
)
.description(HELP_MESSAGE)
.color(Colors::Info),
),
)
.await?;
Ok(())
}

View File

@ -0,0 +1,94 @@
use std::fmt::Display;
use anyhow::Result;
use log::error;
use poise::{serenity_prelude::Error, CreateReply};
use serenity::all::{
CreateActionRow, CreateButton, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter,
};
use spoticord_database::error::DatabaseResultExt;
use spoticord_utils::discord::Colors;
use crate::bot::{Context, FrameworkError};
/// Link your Spotify account to Spoticord
#[poise::command(slash_command, on_error = on_error)]
pub async fn link(ctx: Context<'_>) -> Result<()> {
let db = ctx.data().database();
let user_id = ctx.author().id.to_string();
if db.get_account(&user_id).await.optional()?.is_some() {
ctx.send(
CreateReply::default().embed(
CreateEmbed::new()
.title("Spotify account already linked")
.description("You already have a Spotify account linked.")
.footer(CreateEmbedFooter::new(
"If you are trying to re-link your account then please use /unlink first.",
)).color(Colors::Info),
).ephemeral(true),
)
.await?;
return Ok(());
};
if let Some(request) = db.get_request(&user_id).await.optional()? {
if !request.expired() {
send_link_message(ctx, request.token).await?;
return Ok(());
}
}
let user = db.get_or_create_user(&user_id).await?;
let request = db.create_request(user.id).await?;
send_link_message(ctx, request.token).await?;
Ok(())
}
async fn send_link_message(ctx: Context<'_>, token: impl Display) -> Result<(), Error> {
let link = format!("{}/{token}", spoticord_config::link_url());
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.author(
CreateEmbedAuthor::new("Link your Spotify account")
.url(&link)
.icon_url("https://spoticord.com/spotify-logo.png"),
)
.description("Click on the button below to start linking your Spotify account.")
.color(Colors::Info),
)
.components(vec![CreateActionRow::Buttons(vec![
CreateButton::new_link(&link).label("Link your account"),
])])
.ephemeral(true),
)
.await?;
Ok(())
}
async fn on_error(error: FrameworkError<'_>) {
if let FrameworkError::Command { error, ctx, .. } = error {
error!("An error occured during linking of new account: {error}");
_ = ctx
.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.description("An error occured whilst trying to link your account.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await;
} else {
error!("{error}")
}
}

View File

@ -0,0 +1,11 @@
mod help;
mod link;
mod rename;
mod unlink;
mod version;
pub use help::*;
pub use link::*;
pub use rename::*;
pub use unlink::*;
pub use version::*;

View File

@ -0,0 +1,88 @@
use anyhow::Result;
use log::error;
use poise::CreateReply;
use serenity::all::{CreateEmbed, CreateEmbedFooter};
use spoticord_session::manager::SessionQuery;
use spoticord_utils::discord::Colors;
use crate::bot::Context;
#[poise::command(slash_command)]
pub async fn rename(
ctx: Context<'_>,
#[description = "The new device name"]
#[max_length = 32]
#[min_length = 1]
name: String,
) -> Result<()> {
let db = ctx.data().database();
let user = match db.get_or_create_user(ctx.author().id.to_string()).await {
Ok(user) => user,
Err(why) => {
error!("Error fetching user: {why}");
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.description("Something went wrong whilst trying to rename your Spoticord device.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
};
if let Err(why) = db.update_device_name(user.id, &name).await {
error!("Error updating user device name: {why}");
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.description(
"Something went wrong while trying to rename your Spoticord device.",
)
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
let has_session = ctx
.data()
.get_session(SessionQuery::Owner(ctx.author().id))
.is_some();
ctx.send(
CreateReply::default()
.embed({
let mut embed = CreateEmbed::new()
.description(format!(
"Successfully changed the Spotify device name to **{}**",
spoticord_utils::discord::escape(name)
))
.color(Colors::Success);
if has_session {
embed = embed.footer(CreateEmbedFooter::new(
"You must reconnect the player for the new name to show up",
));
}
embed
})
.ephemeral(true),
)
.await?;
Ok(())
}

View File

@ -0,0 +1,90 @@
use anyhow::Result;
use log::error;
use poise::CreateReply;
use serenity::all::{CreateEmbed, CreateEmbedFooter};
use spoticord_session::manager::SessionQuery;
use spoticord_utils::discord::Colors;
use crate::bot::{Context, FrameworkError};
/// Unlink your Spotify account from Spoticord
#[poise::command(slash_command, on_error = on_error)]
pub async fn unlink(
ctx: Context<'_>,
#[description = "Also delete Discord account information"] user_data: Option<bool>,
) -> Result<()> {
let manager = ctx.data();
let db = manager.database();
let user_id = ctx.author().id.to_string();
// Disconnect session if user has any
if let Some(session) = manager.get_session(SessionQuery::Owner(ctx.author().id)) {
session.shutdown_player().await;
}
let deleted_account = db.delete_account(&user_id).await? != 0;
let deleted_user = if user_data.unwrap_or(false) {
db.delete_user(&user_id).await? != 0
} else {
false
};
if !deleted_account && !deleted_user {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("No Spotify account linked")
.description(
"You cannot unlink your Spotify account if you haven't linked one.",
)
.footer(CreateEmbedFooter::new(
"You can use /link to link a new Spotify account.",
))
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Account unlinked")
.description("You have unlinked your Spotify account from Spoticord.")
.footer(CreateEmbedFooter::new(
"Changed your mind? You can use /link to link a new Spotify account.",
))
.color(Colors::Success),
)
.ephemeral(true),
)
.await?;
Ok(())
}
async fn on_error(error: FrameworkError<'_>) {
if let FrameworkError::Command { error, ctx, .. } = error {
error!("An error occured during linking of new account: {error}");
_ = ctx
.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.description("An error occured whilst trying to unlink your account.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await;
} else {
error!("{error}")
}
}

View File

@ -0,0 +1,33 @@
use anyhow::Result;
use poise::CreateReply;
use serenity::all::{CreateEmbed, CreateEmbedAuthor};
use spoticord_config::VERSION;
use spoticord_utils::discord::Colors;
use crate::bot::Context;
const IMAGE_URL: &str = "https://cdn.discordapp.com/avatars/389786424142200835/6bfe3840b0aa6a1baf432bb251b70c9f.webp?size=128";
/// Shows the current active version of Spoticord
#[poise::command(slash_command)]
pub async fn version(ctx: Context<'_>) -> Result<()> {
// Had to pull this from the builder as rustfmt refused to format the file
let description = format!("Current version: {}\n\nSpoticord is open source, check it out [on GitHub](https://github.com/SpoticordMusic)", VERSION);
ctx.send(
CreateReply::default().embed(
CreateEmbed::default()
.title("Spoticord Version")
.author(
CreateEmbedAuthor::new("Maintained by: DaXcess (@daxcess)")
.url("https://github.com/DaXcess")
.icon_url(IMAGE_URL),
)
.description(description)
.color(Colors::Info),
),
)
.await?;
Ok(())
}

View File

@ -0,0 +1,5 @@
mod ping;
mod token;
pub use ping::*;
pub use token::*;

View File

@ -0,0 +1,16 @@
use anyhow::Result;
use log::info;
use poise::CreateReply;
use crate::bot::Context;
/// Very simple ping command
#[poise::command(slash_command)]
pub async fn ping(ctx: Context<'_>) -> Result<()> {
info!("Pong");
ctx.send(CreateReply::default().content("Pong!").reply(true))
.await?;
Ok(())
}

View File

@ -0,0 +1,28 @@
use anyhow::Result;
use poise::CreateReply;
use spoticord_database::error::DatabaseError;
use crate::bot::Context;
/// Retrieve the Spotify access token. For debugging purposes.
#[poise::command(slash_command)]
pub async fn token(ctx: Context<'_>) -> Result<()> {
let token = ctx
.data()
.database()
.get_access_token(ctx.author().id.to_string())
.await;
let content = match token {
Ok(token) => format!("Your token is:\n```\n{token}\n```"),
Err(DatabaseError::NotFound) => {
"You must authenticate first before requesting a token".to_string()
}
Err(why) => format!("Failed to retrieve access token: {why}"),
};
ctx.send(CreateReply::default().content(content).ephemeral(true))
.await?;
Ok(())
}

View File

@ -0,0 +1,5 @@
pub mod core;
pub mod music;
#[cfg(debug_assertions)]
pub mod debug;

View File

@ -0,0 +1,59 @@
use anyhow::Error;
use poise::CreateReply;
use serenity::all::CreateEmbed;
use spoticord_session::manager::SessionQuery;
use spoticord_utils::discord::Colors;
use crate::bot::Context;
#[poise::command(slash_command, guild_only)]
pub async fn disconnect(ctx: Context<'_>) -> Result<(), Error> {
let manager = ctx.data();
let guild = ctx.guild().expect("poise lied to me").id;
let Some(session) = manager.get_session(SessionQuery::Guild(guild)) else {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot disconnect bot")
.description("I'm currently not connected to any voice channel.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
};
if session.active().await? && session.owner().await? != ctx.author().id {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot disconnect bot")
.description("Only the host may disconnect the bot.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
session.disconnect().await;
ctx.send(
CreateReply::default().embed(
CreateEmbed::new()
.title("Goodbye, for now!")
.description("I have left the voice channel, goodbye for now.")
.color(Colors::Info),
),
)
.await?;
Ok(())
}

View File

@ -0,0 +1,248 @@
use std::time::Duration;
use anyhow::Result;
use log::error;
use poise::CreateReply;
use serenity::all::{
Channel, ChannelId, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, Guild, UserId,
};
use spoticord_database::error::DatabaseError;
use spoticord_session::manager::SessionQuery;
use spoticord_utils::discord::Colors;
use crate::bot::Context;
/// Join the current voice channel
#[poise::command(slash_command, guild_only)]
pub async fn join(ctx: Context<'_>) -> Result<()> {
let guild: Guild = ctx.guild().expect("poise lied to me").clone();
let manager = ctx.data();
let Some(channel) = guild
.voice_states
.get(&ctx.author().id)
.and_then(|state| state.channel_id)
else {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot join voice channel")
.description("You need to connect to a voice channel before running /join")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
};
if !has_voice_permissions(ctx, channel).await? {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot join voice channel")
.description(
"The voice channel you are in is not available.
I might not have the permissions to see this channel.",
)
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
if !has_text_permissions(ctx, ctx.channel_id()).await? {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot join voice channel")
.description(
"I do not have permissions to send messages / links in this text channel.",
)
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
// Check whether the user has linked their Spotify account
if let Err(DatabaseError::NotFound) = manager
.database()
.get_account(ctx.author().id.to_string())
.await
{
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("No Spotify account")
.description(
"You need to link your Spotify account to Spoticord before being able to use it.\nUse the `/link` command to link your account.",
)
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
ctx.defer().await?;
let mut session_opt = manager.get_session(SessionQuery::Guild(guild.id));
// Check if this server already has a session active
if let Some(session) = &session_opt {
if session.active().await? {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Spoticord is busy")
.description("Spoticord is already being used in this server.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
}
// Prevent the user from using Spoticord simultaneously in multiple servers
if let Some(session) = manager.get_session(SessionQuery::Owner(ctx.author().id)) {
let server_name = session.guild().to_partial_guild(&ctx).await?.name;
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("You are already using Spoticord")
.description(format!(
"You are already using Spoticord in `{}`\n\n\
Stop playing in that server first before starting a new session.",
spoticord_utils::discord::escape(server_name)
)),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
if let Some(session) = &session_opt {
if session.voice_channel() != channel {
session.disconnect().await;
session_opt = None;
// Give serenity/songbird some time to register the disconnect
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
if let Some(session) = session_opt {
if let Err(why) = session.reactivate(ctx.author().id).await {
error!("Failed to reactivate session: {why}");
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Failed to reactivate session")
.description(
"An error occured whilst trying to reactivate the session.",
)
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
} else if let Err(why) = manager
.create_session(
ctx.serenity_context(),
guild.id,
channel,
ctx.channel_id(),
ctx.author().id,
)
.await
{
error!("Failed to create session: {why}");
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Failed to create session")
.description("An error occured whilst trying to create a session.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
ctx.send(
CreateReply::default().embed(
CreateEmbed::new()
.author(
CreateEmbedAuthor::new("Connected to voice channel")
.icon_url("https://spoticord.com/speaker.png"),
)
.description(format!("Come listen along in <#{}>", channel))
.footer(CreateEmbedFooter::new(
"You must manually select your device in Spotify",
))
.color(Colors::Info),
),
)
.await?;
Ok(())
}
async fn has_voice_permissions(ctx: Context<'_>, channel: ChannelId) -> Result<bool> {
let me: UserId = ctx.cache().current_user().id;
let Ok(Channel::Guild(channel)) = channel.to_channel(ctx).await else {
return Ok(false);
};
let Ok(permissions) = channel.permissions_for_user(ctx, me) else {
return Ok(false);
};
Ok(permissions.view_channel() && permissions.connect() && permissions.speak())
}
async fn has_text_permissions(ctx: Context<'_>, channel: ChannelId) -> Result<bool> {
let me: UserId = ctx.cache().current_user().id;
let Ok(Channel::Guild(channel)) = channel.to_channel(ctx).await else {
return Ok(false);
};
let Ok(permissions) = channel.permissions_for_user(ctx, me) else {
return Ok(false);
};
Ok(permissions.view_channel() && permissions.send_messages() && permissions.embed_links())
}

View File

@ -0,0 +1,40 @@
use anyhow::Result;
use poise::CreateReply;
use serenity::all::CreateEmbed;
use spoticord_session::manager::SessionQuery;
use spoticord_utils::discord::Colors;
use crate::bot::Context;
/// Show the lyrics of the current song that is being played
#[poise::command(slash_command, guild_only)]
pub async fn lyrics(ctx: Context<'_>) -> Result<()> {
let manager = ctx.data();
let guild = ctx.guild().expect("poise lied to me").id;
let Some(session) = manager.get_session(SessionQuery::Guild(guild)) else {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot get lyrics")
.description("I'm currently not playing any music in this server.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
};
let Context::Application(context) = ctx else {
panic!("Slash command is a prefix command?");
};
session
.create_lyrics_embed(context.interaction.clone())
.await?;
Ok(())
}

View File

@ -0,0 +1,11 @@
mod disconnect;
mod join;
mod lyrics;
mod playing;
mod stop;
pub use disconnect::*;
pub use join::*;
pub use lyrics::*;
pub use playing::*;
pub use stop::*;

View File

@ -0,0 +1,40 @@
use anyhow::Result;
use poise::CreateReply;
use serenity::all::CreateEmbed;
use spoticord_session::manager::SessionQuery;
use spoticord_utils::discord::Colors;
use crate::bot::Context;
/// Show details of the current song that is being played
#[poise::command(slash_command, guild_only)]
pub async fn playing(ctx: Context<'_>) -> Result<()> {
let manager = ctx.data();
let guild = ctx.guild().expect("poise lied to me").id;
let Some(session) = manager.get_session(SessionQuery::Guild(guild)) else {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot display song details")
.description("I'm currently not playing any music in this server.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
};
let Context::Application(context) = ctx else {
panic!("Slash command is a prefix command?");
};
session
.create_playback_embed(context.interaction.clone())
.await?;
Ok(())
}

View File

@ -0,0 +1,59 @@
use anyhow::Error;
use poise::CreateReply;
use serenity::all::CreateEmbed;
use spoticord_session::manager::SessionQuery;
use spoticord_utils::discord::Colors;
use crate::bot::Context;
#[poise::command(slash_command, guild_only)]
pub async fn stop(ctx: Context<'_>) -> Result<(), Error> {
let manager = ctx.data();
let guild = ctx.guild().expect("poise lied to me").id;
let Some(session) = manager.get_session(SessionQuery::Guild(guild)) else {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot stop playback")
.description("I'm currently not connected to any voice channel.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
};
if session.active().await? && session.owner().await? != ctx.author().id {
ctx.send(
CreateReply::default()
.embed(
CreateEmbed::new()
.title("Cannot stop playback")
.description("Only the host may stop playback.")
.color(Colors::Error),
)
.ephemeral(true),
)
.await?;
return Ok(());
}
session.shutdown_player().await;
ctx.send(
CreateReply::default().embed(
CreateEmbed::new()
.title("Stopped playback")
.description("I have stopped playing for now. To resume playback, please run the /join command again.")
.color(Colors::Info),
),
)
.await?;
Ok(())
}

View File

@ -1,27 +0,0 @@
use lazy_static::lazy_static;
#[cfg(not(debug_assertions))]
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(debug_assertions)]
pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-dev");
pub const MOTD: &str = "some good 'ol music";
/// The time it takes for Spoticord to disconnect when no music is being played
pub const DISCONNECT_TIME: u64 = 5 * 60;
lazy_static! {
pub static ref DISCORD_TOKEN: String =
std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN environment variable");
pub static ref DATABASE_URL: String =
std::env::var("DATABASE_URL").expect("missing DATABASE_URL environment variable");
pub static ref SPOTICORD_ACCOUNTS_URL: String = std::env::var("SPOTICORD_ACCOUNTS_URL")
.expect("missing SPOTICORD_ACCOUNTS_URL environment variable");
}
#[cfg(feature = "stats")]
lazy_static! {
pub static ref KV_URL: String =
std::env::var("KV_URL").expect("missing KV_URL environment variable");
}

View File

@ -1,355 +0,0 @@
use thiserror::Error;
use log::trace;
use reqwest::{header::HeaderMap, Client, Error, Response, StatusCode};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Value};
use serenity::prelude::TypeMapKey;
use crate::utils;
#[derive(Debug, Error)]
pub enum DatabaseError {
#[error("An error has occured during an I/O operation: {0}")]
IOError(String),
#[error("An error has occured during a parsing operation: {0}")]
ParseError(String),
#[error("An invalid status code was returned from a request: {0}")]
InvalidStatusCode(StatusCode),
#[error("An invalid input body was provided: {0}")]
InvalidInputBody(String),
}
#[derive(Serialize, Deserialize)]
struct GetAccessTokenResponse {
id: String,
access_token: String,
}
#[derive(Deserialize)]
pub struct User {
pub id: String,
pub device_name: String,
pub request: Option<Request>,
pub accounts: Option<Vec<Account>>,
}
#[derive(Deserialize)]
pub struct Account {
pub user_id: String,
pub r#type: String,
pub access_token: String,
pub refresh_token: String,
pub expires: u64,
}
#[derive(Deserialize)]
pub struct Request {
pub token: String,
pub user_id: String,
pub expires: u64,
}
pub struct Database {
base_url: String,
default_headers: Option<HeaderMap>,
}
// Request options
#[derive(Debug, Clone)]
struct RequestOptions {
pub method: Method,
pub path: String,
pub body: Option<Body>,
pub headers: Option<HeaderMap>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
enum Body {
Json(Value),
Text(String),
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
enum Method {
Get,
Post,
Put,
Delete,
Patch,
}
impl Database {
pub fn new(base_url: impl Into<String>, default_headers: Option<HeaderMap>) -> Self {
Self {
base_url: base_url.into(),
default_headers,
}
}
async fn request(&self, options: RequestOptions) -> Result<Response, Error> {
let builder = Client::builder();
let mut headers: HeaderMap = HeaderMap::new();
let mut url = self.base_url.clone();
url.push_str(&options.path);
if let Some(default_headers) = &self.default_headers {
headers.extend(default_headers.clone());
}
if let Some(request_headers) = options.headers {
headers.extend(request_headers);
}
trace!("Requesting {} with headers: {:?}", url, headers);
let client = builder.default_headers(headers).build()?;
let mut request = match options.method {
Method::Get => client.get(url),
Method::Post => client.post(url),
Method::Put => client.put(url),
Method::Delete => client.delete(url),
Method::Patch => client.patch(url),
};
request = if let Some(body) = options.body {
match body {
Body::Json(json) => request.json(&json),
Body::Text(text) => request.body(text),
}
} else {
request
};
let response = request.send().await?;
Ok(response)
}
async fn simple_get<T: DeserializeOwned>(
&self,
path: impl Into<String>,
) -> Result<T, DatabaseError> {
let response = match self
.request(RequestOptions {
method: Method::Get,
path: path.into(),
body: None,
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)
}
async fn json_post<T: DeserializeOwned>(
&self,
value: impl Serialize,
path: impl Into<String>,
) -> Result<T, DatabaseError> {
let body = json!(value);
let response = match self
.request(RequestOptions {
method: Method::Post,
path: path.into(),
body: Some(Body::Json(body)),
headers: None,
})
.await
{
Ok(response) => response,
Err(error) => return Err(DatabaseError::IOError(error.to_string())),
};
match response.status() {
StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => {}
status => return Err(DatabaseError::InvalidStatusCode(status)),
};
let body = match response.json::<T>().await {
Ok(body) => body,
Err(error) => return Err(DatabaseError::ParseError(error.to_string())),
};
Ok(body)
}
}
impl Database {
// Get Spoticord user
pub async fn get_user(&self, user_id: impl Into<String>) -> Result<User, DatabaseError> {
let path = format!("/user/{}", user_id.into());
self.simple_get(path).await
}
// Get the Spotify access token for a user
pub async fn get_access_token(
&self,
user_id: impl Into<String> + Send,
) -> Result<String, DatabaseError> {
let body: GetAccessTokenResponse = self
.simple_get(format!("/user/{}/spotify/access_token", user_id.into()))
.await?;
Ok(body.access_token)
}
// Get the Spotify account for a user
pub async fn get_user_account(
&self,
user_id: impl Into<String> + Send,
) -> Result<Account, DatabaseError> {
let body: Account = self
.simple_get(format!("/account/{}/spotify", user_id.into()))
.await?;
Ok(body)
}
// Get the Request for a user
pub async fn get_user_request(
&self,
user_id: impl Into<String> + Send,
) -> Result<Request, DatabaseError> {
let body: Request = self
.simple_get(format!("/request/by-user/{}", user_id.into()))
.await?;
Ok(body)
}
// Create a Spoticord user
pub async fn create_user(&self, user_id: impl Into<String>) -> Result<User, DatabaseError> {
let body = json!({
"id": user_id.into(),
});
let user: User = self.json_post(body, "/user/new").await?;
Ok(user)
}
// Create the link Request for a user
pub async fn create_user_request(
&self,
user_id: impl Into<String> + Send,
) -> Result<Request, DatabaseError> {
let body = json!({
"user_id": user_id.into(),
"expires": utils::get_time() + (1000 * 60 * 60)
});
let response = match self
.request(RequestOptions {
method: Method::Post,
path: "/request".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 => {}
status => return Err(DatabaseError::InvalidStatusCode(status)),
};
let body = match response.json::<Request>().await {
Ok(body) => body,
Err(error) => return Err(DatabaseError::ParseError(error.to_string())),
};
Ok(body)
}
pub async fn delete_user_account(
&self,
user_id: impl Into<String> + Send,
) -> Result<(), DatabaseError> {
let response = match self
.request(RequestOptions {
method: Method::Delete,
path: format!("/account/{}/spotify", user_id.into()),
body: None,
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 => {}
status => return Err(DatabaseError::InvalidStatusCode(status)),
};
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.is_empty() {
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 => Err(DatabaseError::InvalidStatusCode(status)),
}
}
}
impl TypeMapKey for Database {
type Value = Database;
}

View File

@ -1,18 +0,0 @@
use librespot::discovery::Credentials;
use librespot::protocol::authentication::AuthenticationType;
pub trait CredentialsExt {
fn with_token(username: impl Into<String>, token: impl Into<String>) -> Credentials;
}
impl CredentialsExt for Credentials {
// Enable the use of a token to connect to Spotify
// Wouldn't want to ask users for their password would we?
fn with_token(username: impl Into<String>, token: impl Into<String>) -> Credentials {
Credentials {
username: username.into(),
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
auth_data: token.into().into_bytes(),
}
}
}

View File

@ -1,6 +0,0 @@
// Librespot extensions
// =============================
// Librespot is missing some key features/functionality for Spoticord to work properly.
// This module contains the extensions to librespot that are required for Spoticord to work.
pub mod discovery;

View File

@ -1,152 +1,64 @@
use dotenv::dotenv;
#[cfg(feature = "stats")]
use crate::consts::KV_URL;
use crate::{
bot::commands::CommandManager,
consts::{DATABASE_URL, DISCORD_TOKEN, MOTD},
database::Database,
session::manager::SessionManager,
};
use log::*;
use serenity::{framework::StandardFramework, prelude::GatewayIntents, Client};
use songbird::SerenityInit;
use std::{any::Any, process::exit};
#[cfg(unix)]
use tokio::signal::unix::SignalKind;
mod audio;
mod bot;
mod consts;
mod database;
mod librespot_ext;
mod player;
mod session;
mod utils;
mod commands;
// mod session;
// mod utils;
#[cfg(feature = "stats")]
mod stats;
#[cfg(feature = "stats")]
use crate::stats::StatsManager;
use log::{error, info};
use poise::Framework;
use serenity::all::ClientBuilder;
use songbird::SerenityInit;
use spoticord_database::Database;
#[tokio::main]
async fn main() {
if std::env::var("RUST_LOG").is_err() {
#[cfg(debug_assertions)]
// Setup logging
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();
info!("Today is a good day!");
info!(" - Spoticord");
dotenvy::dotenv().ok();
// Set up database
let database = match Database::connect().await {
Ok(db) => db,
Err(why) => {
error!("Failed to connect to database and perform migrations: {why}");
return;
}
};
// Set up bot
let framework = Framework::builder()
.setup(|ctx, ready, framework| Box::pin(bot::setup(ctx, ready, framework, database)))
.options(bot::framework_opts())
.build();
let mut client = match ClientBuilder::new(
spoticord_config::discord_token(),
spoticord_config::discord_intents(),
)
.framework(framework)
.register_songbird_from_config(songbird::Config::default().use_softclip(false))
.await
{
std::env::set_var("RUST_LOG", "spoticord");
}
#[cfg(not(debug_assertions))]
{
std::env::set_var("RUST_LOG", "spoticord=info");
}
}
env_logger::init();
info!("It's a good day");
info!(" - Spoticord, {}", MOTD);
let result = dotenv();
if let Ok(path) = result {
debug!(
"Loaded environment file: {}",
path.to_str().expect("to get the string")
);
} else {
warn!("No .env file found, expecting all necessary environment variables");
}
#[cfg(feature = "stats")]
let stats_manager = StatsManager::new(KV_URL.as_str()).expect("Failed to connect to redis");
let session_manager = SessionManager::new();
// Create client
let mut client = Client::builder(
DISCORD_TOKEN.as_str(),
GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES,
)
.event_handler(crate::bot::events::Handler)
.framework(StandardFramework::new())
.register_songbird()
.await
.expect("to create a client");
{
let mut data = client.data.write().await;
data.insert::<Database>(Database::new(DATABASE_URL.as_str(), None));
data.insert::<CommandManager>(CommandManager::new());
data.insert::<SessionManager>(session_manager.clone());
}
let shard_manager = client.shard_manager.clone();
#[cfg(unix)]
let mut term: Option<Box<dyn Any + Send>> = Some(Box::new(
tokio::signal::unix::signal(SignalKind::terminate())
.expect("to be able to create the signal stream"),
));
#[cfg(not(unix))]
let term: Option<Box<dyn Any + Send>> = None;
// Background tasks
tokio::spawn(async move {
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
#[cfg(feature = "stats")]
{
let active_count = session_manager.get_active_session_count().await;
if let Err(why) = stats_manager.set_active_count(active_count) {
error!("Failed to update active count: {why}");
}
}
Ok(client) => client,
Err(why) => {
error!("Fatal error when building Serenity client: {why}");
return;
}
};
_ = tokio::signal::ctrl_c() => {
info!("Received interrupt signal, shutting down...");
session_manager.shutdown().await;
shard_manager.lock().await.shutdown_all().await;
break;
}
_ = async {
#[cfg(unix)]
match term {
Some(ref mut term) => {
let term = term.downcast_mut::<tokio::signal::unix::Signal>().expect("to be able to downcast");
term.recv().await
}
_ => None
}
}, if term.is_some() => {
info!("Received terminate signal, shutting down...");
session_manager.shutdown().await;
shard_manager.lock().await.shutdown_all().await;
break;
}
}
if let Err(why) = client.start_autosharded().await {
error!("Fatal error occured during bot operations: {why}");
error!("Bot will now shut down!");
}
});
// Start the bot
if let Err(why) = client.start_autosharded().await {
error!("FATAL Error in bot: {:?}", why);
exit(1);
}
}

View File

@ -1,406 +0,0 @@
use std::{io::Write, sync::Arc};
use anyhow::{anyhow, Result};
use librespot::{
connect::spirc::Spirc,
core::{
config::{ConnectConfig, SessionConfig},
session::Session,
spotify_id::{SpotifyAudioType, SpotifyId},
},
discovery::Credentials,
playback::{
config::{Bitrate, PlayerConfig, VolumeCtrl},
mixer::{self, MixerConfig},
player::{Player as SpotifyPlayer, PlayerEvent as SpotifyEvent},
},
protocol::metadata::{Episode, Track},
};
use log::error;
use protobuf::Message;
use songbird::tracks::TrackHandle;
use tokio::sync::{
broadcast::{Receiver, Sender},
mpsc::UnboundedReceiver,
Mutex,
};
use crate::{
audio::{stream::Stream, SinkEvent, StreamSink},
librespot_ext::discovery::CredentialsExt,
session::pbi::{CurrentTrack, PlaybackInfo},
utils,
};
enum Event {
Player(SpotifyEvent),
Sink(SinkEvent),
Command(PlayerCommand),
}
#[derive(Clone)]
enum PlayerCommand {
Next,
Previous,
Pause,
Play,
Shutdown,
}
#[derive(Clone, Debug)]
pub enum PlayerEvent {
Pause,
Play,
Stopped,
}
#[derive(Clone)]
pub struct Player {
tx: Sender<PlayerCommand>,
pbi: Arc<Mutex<Option<PlaybackInfo>>>,
}
impl Player {
pub async fn create(
stream: Stream,
token: &str,
device_name: &str,
track: TrackHandle,
) -> Result<(Self, Receiver<PlayerEvent>)> {
let username = utils::spotify::get_username(token).await?;
let player_config = PlayerConfig {
bitrate: Bitrate::Bitrate96,
..Default::default()
};
let credentials = Credentials::with_token(username, token);
let (session, _) = Session::connect(
SessionConfig {
ap_port: Some(9999), // Force the use of ap.spotify.com, which has the lowest latency
..Default::default()
},
credentials,
None,
false,
)
.await?;
let mixer = (mixer::find(Some("softvol")).expect("to exist"))(MixerConfig {
volume_ctrl: VolumeCtrl::Linear,
..Default::default()
});
let (tx, rx_sink) = tokio::sync::mpsc::unbounded_channel();
let (player, rx_player) =
SpotifyPlayer::new(player_config, session.clone(), mixer.get_soft_volume(), {
let stream = stream.clone();
move || Box::new(StreamSink::new(stream, tx))
});
let (spirc, spirc_task) = Spirc::new(
ConnectConfig {
name: device_name.into(),
// 50%
initial_volume: Some(65535 / 2),
// Default Spotify behaviour
autoplay: true,
..Default::default()
},
session.clone(),
player,
mixer,
);
let (tx, rx) = tokio::sync::broadcast::channel(10);
let (tx_ev, rx_ev) = tokio::sync::broadcast::channel(10);
let pbi = Arc::new(Mutex::new(None));
let player_task = PlayerTask {
pbi: pbi.clone(),
session: session.clone(),
rx_player,
rx_sink,
rx,
tx: tx_ev,
spirc,
track,
stream,
};
tokio::spawn(spirc_task);
tokio::spawn(player_task.run());
Ok((Self { pbi, tx }, rx_ev))
}
pub fn next(&self) {
self.tx.send(PlayerCommand::Next).ok();
}
pub fn prev(&self) {
self.tx.send(PlayerCommand::Previous).ok();
}
pub fn pause(&self) {
self.tx.send(PlayerCommand::Pause).ok();
}
pub fn play(&self) {
self.tx.send(PlayerCommand::Play).ok();
}
pub fn shutdown(&self) {
self.tx.send(PlayerCommand::Shutdown).ok();
}
pub async fn pbi(&self) -> Option<PlaybackInfo> {
self.pbi.lock().await.as_ref().cloned()
}
}
struct PlayerTask {
stream: Stream,
session: Session,
spirc: Spirc,
track: TrackHandle,
rx_player: UnboundedReceiver<SpotifyEvent>,
rx_sink: UnboundedReceiver<SinkEvent>,
rx: Receiver<PlayerCommand>,
tx: Sender<PlayerEvent>,
pbi: Arc<Mutex<Option<PlaybackInfo>>>,
}
impl PlayerTask {
pub async fn run(mut self) {
let check_result = |result| {
if let Err(why) = result {
error!("Failed to issue track command: {:?}", why);
}
};
loop {
match self.next().await {
// Spotify player events
Some(Event::Player(event)) => match event {
SpotifyEvent::Playing {
play_request_id: _,
track_id,
position_ms,
duration_ms,
} => {
self
.update_pbi(track_id, position_ms, duration_ms, true)
.await;
self.tx.send(PlayerEvent::Play).ok();
}
SpotifyEvent::Paused {
play_request_id: _,
track_id,
position_ms,
duration_ms,
} => {
self
.update_pbi(track_id, position_ms, duration_ms, false)
.await;
self.tx.send(PlayerEvent::Pause).ok();
}
SpotifyEvent::Changed {
old_track_id: _,
new_track_id,
} => {
if let Ok(current) = self.resolve_audio_info(new_track_id).await {
let mut pbi = self.pbi.lock().await;
if let Some(pbi) = pbi.as_mut() {
pbi.update_track(new_track_id, current);
}
}
}
SpotifyEvent::Stopped {
play_request_id: _,
track_id: _,
} => {
check_result(self.track.pause());
self.tx.send(PlayerEvent::Pause).ok();
}
_ => {}
},
// Audio sink events
Some(Event::Sink(event)) => match event {
SinkEvent::Start => {
check_result(self.track.play());
}
SinkEvent::Stop => {
// EXPERIMENT: It may be beneficial to *NOT* pause songbird here
// We already have a fallback if no audio is present in the buffer (write all zeroes aka silence)
// So commenting this out may help prevent a substantial portion of jitter
// This comes at a cost of more bandwidth, though opus should compress it down to almost nothing
// check_result(track.pause());
self.tx.send(PlayerEvent::Pause).ok();
}
},
// The `Player` has instructed us to do something
Some(Event::Command(command)) => match command {
PlayerCommand::Next => self.spirc.next(),
PlayerCommand::Previous => self.spirc.prev(),
PlayerCommand::Pause => self.spirc.pause(),
PlayerCommand::Play => self.spirc.play(),
PlayerCommand::Shutdown => break,
},
None => {
// One of the channels died
log::debug!("Channel died");
break;
}
}
}
self.tx.send(PlayerEvent::Stopped).ok();
}
async fn next(&mut self) -> Option<Event> {
tokio::select! {
event = self.rx_player.recv() => {
event.map(Event::Player)
}
event = self.rx_sink.recv() => {
event.map(Event::Sink)
}
command = self.rx.recv() => {
command.ok().map(Event::Command)
}
}
}
/// Update current playback info, or return early if not necessary
async fn update_pbi(
&self,
spotify_id: SpotifyId,
position_ms: u32,
duration_ms: u32,
playing: bool,
) {
let mut pbi = self.pbi.lock().await;
if let Some(pbi) = pbi.as_mut() {
pbi.update_pos_dur(position_ms, duration_ms, playing);
}
if !pbi
.as_ref()
.map(|pbi| pbi.spotify_id == spotify_id)
.unwrap_or(true)
{
return;
}
if let Ok(current) = self.resolve_audio_info(spotify_id).await {
match pbi.as_mut() {
Some(pbi) => {
pbi.update_track(spotify_id, current);
pbi.update_pos_dur(position_ms, duration_ms, playing);
}
None => {
*pbi = Some(PlaybackInfo::new(
duration_ms,
position_ms,
playing,
current,
spotify_id,
));
}
}
} else {
log::error!("Failed to resolve audio info");
}
}
/// Retrieve the metadata for a `SpotifyId`
async fn resolve_audio_info(&self, spotify_id: SpotifyId) -> Result<CurrentTrack> {
match spotify_id.audio_type {
SpotifyAudioType::Track => self.resolve_track_info(spotify_id).await,
SpotifyAudioType::Podcast => self.resolve_episode_info(spotify_id).await,
SpotifyAudioType::NonPlayable => Err(anyhow!("Cannot resolve non-playable audio type")),
}
}
/// Retrieve the metadata for a Spotify Track
async fn resolve_track_info(&self, spotify_id: SpotifyId) -> Result<CurrentTrack> {
let result = self
.session
.mercury()
.get(format!("hm://metadata/3/track/{}", spotify_id.to_base16()?))
.await
.map_err(|_| anyhow!("Mercury metadata request failed"))?;
if result.status_code != 200 {
return Err(anyhow!("Mercury metadata request invalid status code"));
}
let message = match result.payload.get(0) {
Some(v) => v,
None => return Err(anyhow!("Mercury metadata request invalid payload")),
};
let proto_track = Track::parse_from_bytes(message)?;
Ok(CurrentTrack::Track(proto_track))
}
/// Retrieve the metadata for a Spotify Podcast
async fn resolve_episode_info(&self, spotify_id: SpotifyId) -> Result<CurrentTrack> {
let result = self
.session
.mercury()
.get(format!(
"hm://metadata/3/episode/{}",
spotify_id.to_base16()?
))
.await
.map_err(|_| anyhow!("Mercury metadata request failed"))?;
if result.status_code != 200 {
return Err(anyhow!("Mercury metadata request invalid status code"));
}
let message = match result.payload.get(0) {
Some(v) => v,
None => return Err(anyhow!("Mercury metadata request invalid payload")),
};
let proto_episode = Episode::parse_from_bytes(message)?;
Ok(CurrentTrack::Episode(proto_episode))
}
}
impl Drop for PlayerTask {
fn drop(&mut self) {
log::trace!("drop PlayerTask");
self.track.stop().ok();
self.spirc.shutdown();
self.session.shutdown();
self.stream.flush().ok();
}
}

View File

@ -1,200 +0,0 @@
use std::{collections::HashMap, sync::Arc};
use serenity::{
model::prelude::{ChannelId, GuildId, UserId},
prelude::{Context, TypeMapKey},
};
use songbird::error::JoinError;
use thiserror::Error;
use super::SpoticordSession;
#[derive(Debug, Error)]
pub enum SessionCreateError {
#[error("This session has no owner assigned")]
NoOwner,
#[error("The user has not linked their Spotify account")]
NoSpotify,
#[error("The application no longer has access to the user's Spotify account")]
SpotifyExpired,
#[error("An error has occured while communicating with the database")]
DatabaseError,
#[error("Failed to join voice channel")]
JoinError(JoinError),
#[error("Failed to start the player")]
PlayerStartError,
}
#[derive(Clone)]
pub struct SessionManager(Arc<tokio::sync::RwLock<InnerSessionManager>>);
impl TypeMapKey for SessionManager {
type Value = SessionManager;
}
pub struct InnerSessionManager {
sessions: HashMap<GuildId, SpoticordSession>,
owner_map: HashMap<UserId, GuildId>,
}
impl InnerSessionManager {
pub fn new() -> Self {
Self {
sessions: HashMap::new(),
owner_map: HashMap::new(),
}
}
/// Creates a new session for the given user in the given guild.
pub async fn create_session(
&mut self,
session: SpoticordSession,
guild_id: GuildId,
owner_id: UserId,
) {
self.sessions.insert(guild_id, session);
self.owner_map.insert(owner_id, guild_id);
}
/// Remove a session
pub async fn remove_session(&mut self, guild_id: GuildId, owner: Option<UserId>) {
// Remove the owner from the owner map (if it exists)
if let Some(owner) = owner {
self.owner_map.remove(&owner);
}
self.sessions.remove(&guild_id);
}
/// Remove owner from owner map.
/// Used whenever a user stops playing music without leaving the bot.
pub fn remove_owner(&mut self, owner_id: UserId) {
self.owner_map.remove(&owner_id);
}
/// Set the owner of a session
/// Used when a user joins a session that is already active
pub fn set_owner(&mut self, owner_id: UserId, guild_id: GuildId) {
self.owner_map.insert(owner_id, guild_id);
}
/// Get a session by its guild ID
pub fn get_session(&self, guild_id: GuildId) -> Option<SpoticordSession> {
self.sessions.get(&guild_id).cloned()
}
/// Find a Spoticord session by their current owner's ID
pub fn find(&self, owner_id: UserId) -> Option<SpoticordSession> {
let guild_id = self.owner_map.get(&owner_id)?;
self.sessions.get(guild_id).cloned()
}
/// Get the amount of sessions
pub fn get_session_count(&self) -> usize {
self.sessions.len()
}
/// Get the amount of sessions with an owner
pub async fn get_active_session_count(&self) -> usize {
let mut count: usize = 0;
for session in self.sessions.values() {
let session = session.0.read().await;
if session.owner.is_some() {
count += 1;
}
}
count
}
pub fn sessions(&self) -> Vec<SpoticordSession> {
self.sessions.values().cloned().collect()
}
}
impl SessionManager {
pub fn new() -> Self {
Self(Arc::new(tokio::sync::RwLock::new(
InnerSessionManager::new(),
)))
}
/// Creates a new session for the given user in the given guild.
pub async fn create_session(
&self,
ctx: &Context,
guild_id: GuildId,
channel_id: ChannelId,
text_channel_id: ChannelId,
owner_id: UserId,
) -> Result<(), SessionCreateError> {
// Create session first to make sure locks are kept for as little time as possible
let session =
SpoticordSession::new(ctx, guild_id, channel_id, text_channel_id, owner_id).await?;
self
.0
.write()
.await
.create_session(session, guild_id, owner_id)
.await;
Ok(())
}
/// Remove a session
pub async fn remove_session(&self, guild_id: GuildId, owner: Option<UserId>) {
self.0.write().await.remove_session(guild_id, owner).await;
}
/// Remove owner from owner map.
/// Used whenever a user stops playing music without leaving the bot.
pub async fn remove_owner(&self, owner_id: UserId) {
self.0.write().await.remove_owner(owner_id);
}
/// Set the owner of a session
/// Used when a user joins a session that is already active
pub async fn set_owner(&self, owner_id: UserId, guild_id: GuildId) {
self.0.write().await.set_owner(owner_id, guild_id);
}
/// Get a session by its guild ID
pub async fn get_session(&self, guild_id: GuildId) -> Option<SpoticordSession> {
self.0.read().await.get_session(guild_id)
}
/// Find a Spoticord session by their current owner's ID
pub async fn find(&self, owner_id: UserId) -> Option<SpoticordSession> {
self.0.read().await.find(owner_id)
}
/// Get the amount of sessions
#[allow(dead_code)]
pub async fn get_session_count(&self) -> usize {
self.0.read().await.get_session_count()
}
/// Get the amount of sessions with an owner
#[allow(dead_code)]
pub async fn get_active_session_count(&self) -> usize {
self.0.read().await.get_active_session_count().await
}
/// Tell all sessions to instantly shut down
pub async fn shutdown(&self) {
let sessions = self.0.read().await.sessions();
for session in sessions {
session.disconnect().await;
}
}
}

View File

@ -1,569 +0,0 @@
pub mod manager;
pub mod pbi;
use self::{
manager::{SessionCreateError, SessionManager},
pbi::PlaybackInfo,
};
use crate::{
audio::stream::Stream,
consts::DISCONNECT_TIME,
database::{Database, DatabaseError},
player::{Player, PlayerEvent},
utils::embed::Status,
};
use log::*;
use reqwest::StatusCode;
use serenity::{
async_trait,
http::Http,
model::prelude::{ChannelId, GuildId, UserId},
prelude::{Context, RwLock},
};
use songbird::{
create_player,
input::{Codec, Container, Input, Reader},
tracks::TrackHandle,
Call, Event, EventContext, EventHandler,
};
use std::{
ops::{Deref, DerefMut},
sync::Arc,
time::Duration,
};
use tokio::sync::{Mutex, RwLockReadGuard, RwLockWriteGuard};
#[derive(Clone)]
pub struct SpoticordSession(Arc<RwLock<InnerSpoticordSession>>);
impl Drop for SpoticordSession {
fn drop(&mut self) {
log::trace!("drop SpoticordSession");
}
}
struct InnerSpoticordSession {
owner: Option<UserId>,
guild_id: GuildId,
channel_id: ChannelId,
text_channel_id: ChannelId,
http: Arc<Http>,
session_manager: SessionManager,
call: Arc<Mutex<Call>>,
track: Option<TrackHandle>,
player: Option<Player>,
disconnect_handle: Option<tokio::task::JoinHandle<()>>,
/// Whether the session has been disconnected
/// If this is true then this instance should no longer be used and dropped
disconnected: bool,
}
impl SpoticordSession {
pub async fn new(
ctx: &Context,
guild_id: GuildId,
channel_id: ChannelId,
text_channel_id: ChannelId,
owner_id: UserId,
) -> Result<SpoticordSession, SessionCreateError> {
// Get the Spotify token of the owner
let data = ctx.data.read().await;
let session_manager = data
.get::<SessionManager>()
.expect("to contain a value")
.clone();
// Join the voice channel
let songbird = songbird::get(ctx).await.expect("to be present").clone();
let (call, result) = songbird.join(guild_id, channel_id).await;
if let Err(why) = result {
error!("Error joining voice channel: {:?}", why);
return Err(SessionCreateError::JoinError(why));
}
let inner = InnerSpoticordSession {
owner: Some(owner_id),
guild_id,
channel_id,
text_channel_id,
http: ctx.http.clone(),
session_manager: session_manager.clone(),
call: call.clone(),
track: None,
player: None,
disconnect_handle: None,
disconnected: false,
};
let mut instance = Self(Arc::new(RwLock::new(inner)));
if let Err(why) = instance.create_player(ctx).await {
songbird.remove(guild_id).await.ok();
return Err(why);
}
let mut call = call.lock().await;
// Set up events
call.add_global_event(
songbird::Event::Core(songbird::CoreEvent::DriverDisconnect),
instance.clone(),
);
call.add_global_event(
songbird::Event::Core(songbird::CoreEvent::ClientDisconnect),
instance.clone(),
);
Ok(instance)
}
pub async fn update_owner(
&mut self,
ctx: &Context,
owner_id: UserId,
) -> Result<(), SessionCreateError> {
// Get the Spotify token of the owner
let data = ctx.data.read().await;
let session_manager = data
.get::<SessionManager>()
.expect("to contain a value")
.clone();
{
let mut inner = self.acquire_write().await;
inner.owner = Some(owner_id);
}
{
let guild_id = self.acquire_read().await.guild_id;
session_manager.set_owner(owner_id, guild_id).await;
}
// Create the player
self.create_player(ctx).await?;
Ok(())
}
/// Advance to the next track
pub async fn next(&mut self) {
if let Some(ref player) = self.acquire_read().await.player {
player.next();
}
}
/// Rewind to the previous track
pub async fn previous(&mut self) {
if let Some(ref player) = self.acquire_read().await.player {
player.prev();
}
}
/// Pause the current track
pub async fn pause(&mut self) {
if let Some(ref player) = self.acquire_read().await.player {
player.pause();
}
}
/// Resume the current track
pub async fn resume(&mut self) {
if let Some(ref player) = self.acquire_read().await.player {
player.play();
}
}
async fn create_player(&mut self, ctx: &Context) -> Result<(), SessionCreateError> {
let owner_id = match self.owner().await {
Some(owner_id) => owner_id,
None => return Err(SessionCreateError::NoOwner),
};
let data = ctx.data.read().await;
let database = data.get::<Database>().expect("to contain a value");
let token = match database.get_access_token(owner_id.to_string()).await {
Ok(token) => token,
Err(why) => {
return match why {
DatabaseError::InvalidStatusCode(StatusCode::NOT_FOUND) => {
Err(SessionCreateError::NoSpotify)
}
DatabaseError::InvalidStatusCode(StatusCode::BAD_REQUEST) => {
Err(SessionCreateError::SpotifyExpired)
}
_ => 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);
}
};
// Create stream
let stream = Stream::new();
// Create track (paused, fixes audio glitches)
let (mut track, track_handle) = create_player(Input::new(
true,
Reader::Extension(Box::new(stream.clone())),
Codec::FloatPcm,
Container::Raw,
None,
));
track.pause();
let call = self.call().await;
let mut call = call.lock().await;
// Set call audio to track
call.play_only(track);
let (player, mut rx) =
match Player::create(stream, &token, &user.device_name, track_handle.clone()).await {
Ok(v) => v,
Err(why) => {
error!("Failed to start the player: {:?}", why);
return Err(SessionCreateError::PlayerStartError);
}
};
tokio::spawn({
let session = self.clone();
async move {
loop {
match rx.recv().await {
Ok(event) => match event {
PlayerEvent::Pause => session.start_disconnect_timer().await,
PlayerEvent::Play => session.stop_disconnect_timer().await,
PlayerEvent::Stopped => {
session.player_stopped().await;
break;
}
},
Err(why) => {
error!("Communication with player abruptly ended: {why}");
session.player_stopped().await;
break;
}
}
}
}
});
// Start DC timer by default, as automatic device switching is now gone
self.start_disconnect_timer().await;
let mut inner = self.acquire_write().await;
inner.track = Some(track_handle);
inner.player = Some(player);
Ok(())
}
/// Called when the player must stop, but not leave the call
async fn player_stopped(&self) {
let mut inner = self.acquire_write().await;
if let Some(track) = inner.track.take() {
if let Err(why) = track.stop() {
error!("Failed to stop track: {:?}", why);
}
}
// Clear owner
if let Some(owner_id) = inner.owner.take() {
inner.session_manager.remove_owner(owner_id).await;
}
// Disconnect from Spotify
if let Some(player) = inner.player.take() {
player.shutdown();
}
// Unlock to prevent deadlock in start_disconnect_timer
drop(inner);
// Disconnect automatically after some time
self.start_disconnect_timer().await;
}
// Disconnect from voice channel and remove session from manager
pub async fn disconnect(&self) {
info!(
"[{}] Disconnecting from voice channel {}",
self.guild_id().await,
self.channel_id().await
);
// We must run disconnect_no_abort within a read lock
// This is because `SessionManager::remove_session` will acquire a
// read lock to read the current owner.
// This would deadlock if we have an active write lock
{
let mut inner = self.acquire_write().await;
inner.disconnect_no_abort().await;
}
self.stop_disconnect_timer().await;
}
/// Start the disconnect timer, which will disconnect the bot from the voice channel after a
/// certain amount of time
async fn start_disconnect_timer(&self) {
self.stop_disconnect_timer().await;
let mut inner = self.acquire_write().await;
// Check if we are already disconnected
if inner.disconnected {
return;
}
inner.disconnect_handle = Some(tokio::spawn({
let session = self.clone();
async move {
let mut timer = tokio::time::interval(Duration::from_secs(DISCONNECT_TIME));
// Ignore first (immediate) tick
timer.tick().await;
timer.tick().await;
trace!("Ring ring, time to check :)");
// Make sure this task has not been aborted, if it has this will automatically stop execution.
tokio::task::yield_now().await;
let is_playing = session
.playback_info()
.await
.map(|pbi| pbi.is_playing)
.unwrap_or(false);
trace!("is_playing = {is_playing}");
if !is_playing {
info!("Player is not playing, disconnecting");
session
.disconnect_with_message(
"The player has been inactive for too long, and has been disconnected.",
)
.await;
}
}
}));
}
/// Stop the disconnect timer (if one is running)
async fn stop_disconnect_timer(&self) {
let mut inner = self.acquire_write().await;
if let Some(handle) = inner.disconnect_handle.take() {
handle.abort();
}
}
/// Disconnect from the VC and send a message to the text channel
pub async fn disconnect_with_message(&self, content: &str) {
{
let mut inner = self.acquire_write().await;
// Firstly we disconnect
inner.disconnect_no_abort().await;
// Then we send the message
if let Err(why) = inner
.text_channel_id
.send_message(&inner.http, |message| {
message.embed(|embed| {
embed.title("Disconnected from voice channel");
embed.description(content);
embed.color(Status::Warning as u64);
embed
})
})
.await
{
error!("Failed to send disconnect message: {:?}", why);
}
}
// Finally we stop and remove the disconnect timer
self.stop_disconnect_timer().await;
}
/* Inner getters */
/// Get the owner
pub async fn owner(&self) -> Option<UserId> {
self.acquire_read().await.owner
}
/// Get the session manager
pub async fn session_manager(&self) -> SessionManager {
self.acquire_read().await.session_manager.clone()
}
/// Get the guild id
pub async fn guild_id(&self) -> GuildId {
self.acquire_read().await.guild_id
}
/// Get the channel id
pub async fn channel_id(&self) -> ChannelId {
self.acquire_read().await.channel_id
}
/// Get the channel id
#[allow(dead_code)]
pub async fn text_channel_id(&self) -> ChannelId {
self.acquire_read().await.text_channel_id
}
/// Get the playback info
pub async fn playback_info(&self) -> Option<PlaybackInfo> {
let handle = self.acquire_read().await;
let player = handle.player.as_ref()?;
player.pbi().await
}
pub async fn call(&self) -> Arc<Mutex<Call>> {
self.acquire_read().await.call.clone()
}
#[allow(dead_code)]
pub async fn http(&self) -> Arc<Http> {
self.acquire_read().await.http.clone()
}
async fn acquire_read(&self) -> ReadLock {
ReadLock(self.0.read().await)
}
async fn acquire_write(&self) -> WriteLock {
WriteLock(self.0.write().await)
}
}
struct ReadLock<'a>(RwLockReadGuard<'a, InnerSpoticordSession>);
impl<'a> Deref for ReadLock<'a> {
type Target = RwLockReadGuard<'a, InnerSpoticordSession>;
#[inline]
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a> DerefMut for ReadLock<'a> {
#[inline]
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
struct WriteLock<'a>(RwLockWriteGuard<'a, InnerSpoticordSession>);
impl<'a> Deref for WriteLock<'a> {
type Target = RwLockWriteGuard<'a, InnerSpoticordSession>;
#[inline]
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a> DerefMut for WriteLock<'a> {
#[inline]
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl InnerSpoticordSession {
/// Internal version of disconnect, which does not abort the disconnect timer
async fn disconnect_no_abort(&mut self) {
// Disconnect from Spotify
if let Some(player) = self.player.take() {
player.shutdown();
}
self.disconnected = true;
self
.session_manager
.remove_session(self.guild_id, self.owner)
.await;
if let Some(track) = self.track.take() {
if let Err(why) = track.stop() {
error!("Failed to stop track: {:?}", why);
}
};
let mut call = self.call.lock().await;
if let Err(why) = call.leave().await {
error!("Failed to leave voice channel: {:?}", why);
}
call.remove_all_global_events();
}
}
#[async_trait]
impl EventHandler for SpoticordSession {
async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
match ctx {
EventContext::DriverDisconnect(_) => {
debug!("Driver disconnected, leaving voice channel");
trace!("Arc strong count: {}", Arc::strong_count(&self.0));
self.disconnect().await;
}
EventContext::ClientDisconnect(who) => {
trace!("Client disconnected, {}", who.user_id.to_string());
trace!("Arc strong count: {}", Arc::strong_count(&self.0));
if let Some(session) = self
.session_manager()
.await
.find(UserId(who.user_id.0))
.await
{
if session.guild_id().await == self.guild_id().await
&& session.channel_id().await == self.channel_id().await
{
self.player_stopped().await;
}
}
}
_ => {}
}
return None;
}
}
impl Drop for InnerSpoticordSession {
fn drop(&mut self) {
log::trace!("drop InnerSpoticordSession");
}
}

View File

@ -1,142 +0,0 @@
use librespot::{
core::spotify_id::SpotifyId,
protocol::metadata::{Episode, Track},
};
use crate::utils;
#[derive(Clone)]
pub struct PlaybackInfo {
last_updated: u128,
position_ms: u32,
pub track: CurrentTrack,
pub spotify_id: SpotifyId,
pub duration_ms: u32,
pub is_playing: bool,
}
#[derive(Clone)]
pub enum CurrentTrack {
Track(Track),
Episode(Episode),
}
impl PlaybackInfo {
/// Create a new instance of PlaybackInfo
pub fn new(
duration_ms: u32,
position_ms: u32,
is_playing: bool,
track: CurrentTrack,
spotify_id: SpotifyId,
) -> Self {
Self {
last_updated: utils::get_time_ms(),
track,
spotify_id,
duration_ms,
position_ms,
is_playing,
}
}
/// Update position, duration and playback state
pub 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
pub fn update_track(&mut self, spotify_id: SpotifyId, track: CurrentTrack) {
self.spotify_id = spotify_id;
self.track = track;
}
/// Get the current playback position
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
}
}
/// Get the name of the track or episode
pub fn get_name(&self) -> String {
match &self.track {
CurrentTrack::Track(track) => track.get_name().to_string(),
CurrentTrack::Episode(episode) => episode.get_name().to_string(),
}
}
/// Get the artist(s) or show name of the current track
pub fn get_artists(&self) -> String {
match &self.track {
CurrentTrack::Track(track) => track
.get_artist()
.iter()
.map(|a| a.get_name().to_string())
.collect::<Vec<_>>()
.join(", "),
CurrentTrack::Episode(episode) => episode.get_show().get_name().to_string(),
}
}
/// Get the album art url
pub fn get_thumbnail_url(&self) -> Option<String> {
let file_id = match &self.track {
CurrentTrack::Track(track) => {
let mut images = track.get_album().get_cover_group().get_image().to_vec();
images.sort_by_key(|b| std::cmp::Reverse(b.get_width()));
images
.get(0)
.as_ref()
.map(|image| image.get_file_id())
.map(hex::encode)
}
CurrentTrack::Episode(episode) => {
let mut images = episode.get_covers().get_image().to_vec();
images.sort_by_key(|b| std::cmp::Reverse(b.get_width()));
images
.get(0)
.as_ref()
.map(|image| image.get_file_id())
.map(hex::encode)
}
};
file_id.map(|id| format!("https://i.scdn.co/image/{id}"))
}
/// Get the type of audio (track or episode)
#[allow(dead_code)]
pub fn get_type(&self) -> String {
match &self.track {
CurrentTrack::Track(_) => "track".to_string(),
CurrentTrack::Episode(_) => "episode".to_string(),
}
}
/// Get the public facing url of the track or episode
#[allow(dead_code)]
pub fn get_url(&self) -> Option<&str> {
match &self.track {
CurrentTrack::Track(track) => track
.get_external_id()
.iter()
.find(|id| id.get_typ() == "spotify")
.map(|v| v.get_id()),
CurrentTrack::Episode(episode) => Some(episode.get_external_url()),
}
}
}

View File

@ -1,20 +0,0 @@
use redis::{Client, Commands, RedisResult as Result};
#[derive(Clone)]
pub struct StatsManager {
redis: Client,
}
impl StatsManager {
pub fn new(url: impl AsRef<str>) -> Result<Self> {
let redis = Client::open(url.as_ref())?;
Ok(StatsManager { redis })
}
pub fn set_active_count(&self, count: usize) -> Result<()> {
let mut con = self.redis.get_connection()?;
con.set("sc-bot-active-servers", count.to_string())
}
}

View File

@ -1,27 +0,0 @@
use serenity::{
model::{prelude::UserId, user::User},
prelude::Context,
};
pub fn escape(text: impl Into<String>) -> String {
let text: String = text.into();
text
.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('_', "\\_")
.replace('~', "\\~")
.replace('`', "\\`")
}
pub async fn get_user(ctx: &Context, id: UserId) -> Option<User> {
let user = match ctx.cache.user(id) {
Some(user) => user,
None => match ctx.http.get_user(id.0).await {
Ok(user) => user,
Err(_) => return None,
},
};
Some(user)
}

View File

@ -1,104 +0,0 @@
use serenity::builder::CreateEmbed;
pub enum Status {
Info = 0x0773D6,
Success = 0x3BD65D,
Warning = 0xF0D932,
Error = 0xFC1F28,
None = 0,
}
impl From<Status> for serenity::utils::Colour {
fn from(value: Status) -> Self {
Self(value as u32)
}
}
#[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(
embed: &'_ mut CreateEmbed,
options: EmbedMessageOptions,
) -> &'_ mut CreateEmbed {
let status = options.status.unwrap_or(Status::None);
embed.author(|author| {
if let Some(title) = options.title {
author.name(title);
}
if let Some(title_url) = options.title_url {
author.url(title_url);
}
if let Some(icon_url) = options.icon_url {
author.icon_url(icon_url);
}
author
});
if let Some(text) = options.footer {
embed.footer(|footer| footer.text(text));
}
embed.description(options.description);
embed.color(status as u32);
embed
}

View File

@ -1,37 +0,0 @@
use std::time::{SystemTime, UNIX_EPOCH};
pub mod discord;
pub mod embed;
pub mod spotify;
pub fn get_time() -> u64 {
let now = SystemTime::now();
let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards");
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 {
format!(
"{}h{}m{}s",
time / hour,
(time % hour) / min,
(time % hour) % min
)
} else if time / min >= 1 {
format!("{}m{}s", time / min, time % min)
} else {
format!("{}s", time)
}
}

View File

@ -1,54 +0,0 @@
use anyhow::{anyhow, Result};
use log::{error, trace};
use serde_json::Value;
pub async fn get_username(token: impl Into<String>) -> Result<String> {
let token = token.into();
let client = reqwest::Client::new();
let mut retries = 3;
loop {
let response = match client
.get("https://api.spotify.com/v1/me")
.bearer_auth(&token)
.send()
.await
{
Ok(response) => response,
Err(why) => {
error!("Failed to get username: {}", why);
return Err(why.into());
}
};
if response.status().as_u16() >= 500 && retries > 0 {
retries -= 1;
continue;
}
if response.status() != 200 {
error!("Failed to get username: {}", response.status());
return Err(anyhow!(
"Failed to get track info: Invalid status code: {}",
response.status()
));
}
let body: Value = match response.json().await {
Ok(body) => body,
Err(why) => {
error!("Failed to parse body: {}", why);
return Err(why.into());
}
};
if let Value::String(username) = &body["id"] {
trace!("Got username: {}", username);
return Ok(username.clone());
}
error!("Missing 'id' field in body: {:#?}", body);
return Err(anyhow!("Failed to parse body: Invalid body received"));
}
}