commit
a93c2ff0ac
|
@ -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]
|
[target.aarch64-unknown-linux-gnu]
|
||||||
rustflags = "-C linker=aarch64-linux-gnu-gcc"
|
rustflags = "-C linker=aarch64-linux-gnu-gcc"
|
|
@ -1,5 +1,8 @@
|
||||||
target/
|
# .dockerignore
|
||||||
.env
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
.gitignore
|
|
||||||
.dockerignore
|
# build and deps
|
||||||
Dockerfile
|
/target
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
|
@ -2,10 +2,10 @@ name: Build and push to registry
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main", "dev" ]
|
branches: ["main", "dev"]
|
||||||
tags: [ "v*.*.*" ]
|
tags: ["v*.*.*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main", "dev" ]
|
branches: ["main", "dev"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
@ -53,6 +53,15 @@ jobs:
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=sha
|
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
|
- name: Build image and push to registry
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
@ -62,5 +71,6 @@ jobs:
|
||||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||||
# Some basic caching of the layers...
|
# Some basic caching of the layers...
|
||||||
cache-from: ${{ steps.repo-uri-string.outputs.lowercase }}:latest-cache
|
cache-from: type=gha
|
||||||
cache-to: ${{ steps.repo-uri-string.outputs.lowercase }}:latest-cache
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
# Rust
|
# Rust
|
||||||
/target
|
/target
|
||||||
|
|
||||||
# SQLite database
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
tab_spaces = 2
|
|
35
CHANGELOG.md
35
CHANGELOG.md
|
@ -1,26 +1,44 @@
|
||||||
# Changelog
|
# 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
|
## 2.1.2 | September 28th 2023
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
* Removed OpenSSL dependency
|
|
||||||
* Added aarch64 support
|
- Removed OpenSSL dependency
|
||||||
* Added cross compilation to Github Actions
|
- Added aarch64 support
|
||||||
* Added `dev` branch to Github Actions
|
- Added cross compilation to Github Actions
|
||||||
* Removed hardcoded URL in the /join command
|
- Added `dev` branch to Github Actions
|
||||||
* Fixed an issue in /playing where the bot showed it was playing even though it was paused
|
- 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
|
**Full Changelog**: https://github.com/SpoticordMusic/spoticord/compare/v2.1.1...v2.1.2
|
||||||
|
|
||||||
## 2.1.1 | September 23rd 2023
|
## 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).
|
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
|
### Changes
|
||||||
* Fixed issue #20
|
|
||||||
|
- Fixed issue #20
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/SpoticordMusic/spoticord/compare/v2.1.0...v2.1.1
|
**Full Changelog**: https://github.com/SpoticordMusic/spoticord/compare/v2.1.0...v2.1.1
|
||||||
|
|
||||||
## 2.1.0 | September 20th 2023
|
## 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.
|
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.
|
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
|
- Enable autoplay
|
||||||
- After skipping a song, you will no longer hear a tiny bit of the previous song after the silence
|
- 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
|
**Full Changelog**: https://github.com/SpoticordMusic/spoticord/compare/v2.0.0...v2.1.0
|
||||||
|
|
||||||
### Issues
|
### 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
|
- 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
|
## 2.0.0 | June 8th 2023
|
||||||
|
|
||||||
- Initial Release
|
- Initial Release
|
|
@ -1,8 +1,11 @@
|
||||||
# Compiling from source
|
# Compiling from source
|
||||||
|
|
||||||
## Initial setup
|
## 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).
|
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
|
### 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:
|
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
|
```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.
|
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
|
## 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).
|
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:
|
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
|
## Compiling
|
||||||
|
|
||||||
Now that you have all the dependencies installed, you can compile Spoticord. To do this, you'll first need to clone the repository:
|
Now that you have all the dependencies installed, you can compile Spoticord. To do this, you'll first need to clone the repository:
|
||||||
|
|
||||||
```sh
|
```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:
|
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
|
```sh
|
||||||
cargo build [--release] --features metrics
|
cargo build [--release] --features stats
|
||||||
```
|
```
|
||||||
|
|
||||||
# MSRV
|
# 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`)_.
|
||||||
|
|
|
@ -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:
|
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
|
```bash
|
||||||
git config core.hooksPath .githooks
|
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.
|
File diff suppressed because it is too large
Load Diff
52
Cargo.toml
52
Cargo.toml
|
@ -1,36 +1,46 @@
|
||||||
[package]
|
[package]
|
||||||
name = "spoticord"
|
name = "spoticord"
|
||||||
version = "2.1.2"
|
version = "2.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.65.0"
|
rust-version = "1.75.0"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "spoticord"
|
name = "spoticord"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"spoticord_audio",
|
||||||
|
"spoticord_config",
|
||||||
|
"spoticord_database",
|
||||||
|
"spoticord_player",
|
||||||
|
"spoticord_session",
|
||||||
|
"spoticord_utils",
|
||||||
|
"spoticord_stats",
|
||||||
|
]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
stats = ["redis"]
|
default = ["stats"]
|
||||||
|
stats = ["spoticord_stats"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.75"
|
spoticord_config = { path = "./spoticord_config" }
|
||||||
dotenv = "0.15.0"
|
spoticord_database = { path = "./spoticord_database" }
|
||||||
env_logger = "0.10.0"
|
spoticord_player = { path = "./spoticord_player" }
|
||||||
hex = "0.4.3"
|
spoticord_session = { path = "./spoticord_session" }
|
||||||
lazy_static = "1.4.0"
|
spoticord_utils = { path = "./spoticord_utils" }
|
||||||
librespot = { version = "0.4.2", default-features = false }
|
spoticord_stats = { path = "./spoticord_stats", optional = true }
|
||||||
log = "0.4.20"
|
|
||||||
protobuf = "2.28.0"
|
anyhow = "1.0.86"
|
||||||
redis = { version = "0.23.3", optional = true, default-features = false }
|
dotenvy = "0.15.7"
|
||||||
reqwest = { version = "0.11.20", default-features = false }
|
env_logger = "0.11.5"
|
||||||
samplerate = "0.2.4"
|
log = "0.4.22"
|
||||||
serde = "1.0.188"
|
poise = "0.6.1"
|
||||||
serde_json = "1.0.107"
|
serenity = "0.12.2"
|
||||||
serenity = { version = "0.11.6", features = ["framework", "cache", "standard_framework", "rustls_backend", "gateway"], default-features = false }
|
songbird = { version = "0.4.3", features = ["simd-json"] }
|
||||||
songbird = { version = "0.3.2", features = ["driver", "serenity-rustls"], default-features = false }
|
tokio = { version = "1.39.2", features = ["full"] }
|
||||||
thiserror = "1.0.48"
|
|
||||||
tokio = { version = "1.32.0", features = ["rt", "full"] }
|
|
||||||
zerocopy = "0.7.5"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
strip = true
|
||||||
|
|
35
Dockerfile
35
Dockerfile
|
@ -1,41 +1,44 @@
|
||||||
# Builder
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Add extra build dependencies here
|
# Add extra build dependencies here
|
||||||
RUN apt-get update && apt-get install -yqq \
|
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 . .
|
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
|
# Add `--no-default-features` if you don't want stats collection
|
||||||
RUN cargo build --features=stats --release \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--target=x86_64-unknown-linux-gnu --target=aarch64-unknown-linux-gnu
|
--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
|
# Runtime
|
||||||
FROM debian:buster-slim
|
FROM debian:buster-slim
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
ENV TARGETPLATFORM=$TARGETPLATFORM
|
ENV TARGETPLATFORM=${TARGETPLATFORM}
|
||||||
|
|
||||||
# Add extra runtime dependencies here
|
# Add extra runtime dependencies here
|
||||||
# RUN apt-get update && apt-get install -yqq --no-install-recommends \
|
RUN apt update && apt install -y ca-certificates
|
||||||
# openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy spoticord binaries from builder to /tmp
|
# Copy spoticord binaries from builder to /tmp so we can dynamically use them
|
||||||
COPY --from=builder \
|
COPY --from=builder \
|
||||||
/app/target/x86_64-unknown-linux-gnu/release/spoticord /tmp/x86_64
|
/app/x86_64 /tmp/x86_64
|
||||||
COPY --from=builder \
|
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
|
# Copy appropriate binary for target arch from /tmp
|
||||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||||
cp /tmp/x86_64 /usr/local/bin/spoticord; \
|
cp /tmp/x86_64 /usr/local/bin/spoticord; \
|
||||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||||
cp /tmp/aarch64 /usr/local/bin/spoticord; \
|
cp /tmp/aarch64 /usr/local/bin/spoticord; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Delete unused binaries
|
# Delete unused binaries
|
||||||
|
|
143
LICENSE
143
LICENSE
|
@ -1,5 +1,5 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
@ -7,17 +7,15 @@
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
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
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
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
|
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
|
software for all its users.
|
||||||
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.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
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
|
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.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
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:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
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
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
@ -72,7 +60,7 @@ modification follow.
|
||||||
|
|
||||||
0. Definitions.
|
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
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
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
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
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
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
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
|
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,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
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
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
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
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
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.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
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
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
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>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
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
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
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/>.
|
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.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
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
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
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".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
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.
|
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/>.
|
<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>.
|
|
||||||
|
|
20
README.md
20
README.md
|
@ -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.
|
Being built on top of rust, Spoticord is relatively lightweight and can run on low-spec hardware.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
### Official bot
|
### 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/).
|
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
|
### Environment variables
|
||||||
|
|
||||||
Spoticord uses environment variables to configure itself. The following variables are required:
|
Spoticord uses environment variables to configure itself. The following variables are required:
|
||||||
|
|
||||||
- `DISCORD_TOKEN`: The Discord bot token used for authenticating with Discord.
|
- `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).
|
- `DATABASE_URL`: The URL of the postgres database where spoticord will store user data. Currently only postgresql databases are supported.
|
||||||
- `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).
|
- `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:
|
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.
|
- `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
|
#### Providing environment variables
|
||||||
|
|
||||||
You can provide environment variables in a `.env` file at the root of the working directory of Spoticord.
|
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.
|
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).
|
Environment variables set this way take precedence over those in the `.env` file (if one exists).
|
||||||
|
|
||||||
# Compiling
|
# Compiling
|
||||||
|
|
||||||
For information about how to compile Spoticord from source, check out [COMPILING.md](COMPILING.md).
|
For information about how to compile Spoticord from source, check out [COMPILING.md](COMPILING.md).
|
||||||
|
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
For information about how to contribute to Spoticord, check out [CONTRIBUTING.md](CONTRIBUTING.md).
|
For information about how to contribute to Spoticord, check out [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
# Contact
|
# Contact
|
||||||
|
|
||||||
![Discord Shield](https://discordapp.com/api/guilds/779292533053456404/widget.png?style=shield)
|
![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)!
|
If you have any questions, feel free to join the [Spoticord Discord server](https://discord.gg/wRCyhVqBZ5)!
|
||||||
|
|
||||||
# License
|
# 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).
|
||||||
|
|
|
@ -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"
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod sink;
|
||||||
|
pub mod stream;
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Spoticord: Configuration
|
||||||
|
|
||||||
|
This module contains configuration items for spoticord, like environment variables.
|
|
@ -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");
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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"
|
|
@ -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"
|
|
@ -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();
|
|
@ -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;
|
|
@ -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();
|
|
@ -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();
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
);
|
|
@ -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"
|
|
@ -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 { .. })
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
|
@ -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)?,
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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])
|
||||||
|
}
|
|
@ -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 }
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
|
@ -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(']', "\\]")
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
|
@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
pub mod help;
|
|
||||||
pub mod link;
|
|
||||||
pub mod rename;
|
|
||||||
pub mod unlink;
|
|
||||||
pub mod version;
|
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
pub mod join;
|
|
||||||
pub mod leave;
|
|
||||||
pub mod playing;
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod commands;
|
|
||||||
pub mod events;
|
|
|
@ -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)**.
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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::*;
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod ping;
|
||||||
|
mod token;
|
||||||
|
|
||||||
|
pub use ping::*;
|
||||||
|
pub use token::*;
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod core;
|
||||||
|
pub mod music;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub mod debug;
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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::*;
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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");
|
|
||||||
}
|
|
355
src/database.rs
355
src/database.rs
|
@ -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;
|
|
||||||
}
|
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
196
src/main.rs
196
src/main.rs
|
@ -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 bot;
|
||||||
mod consts;
|
mod commands;
|
||||||
mod database;
|
// mod session;
|
||||||
mod librespot_ext;
|
// mod utils;
|
||||||
mod player;
|
|
||||||
mod session;
|
|
||||||
mod utils;
|
|
||||||
|
|
||||||
#[cfg(feature = "stats")]
|
use log::{error, info};
|
||||||
mod stats;
|
use poise::Framework;
|
||||||
|
use serenity::all::ClientBuilder;
|
||||||
#[cfg(feature = "stats")]
|
use songbird::SerenityInit;
|
||||||
use crate::stats::StatsManager;
|
use spoticord_database::Database;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
if std::env::var("RUST_LOG").is_err() {
|
// Setup logging
|
||||||
#[cfg(debug_assertions)]
|
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");
|
Ok(client) => client,
|
||||||
}
|
Err(why) => {
|
||||||
|
error!("Fatal error when building Serenity client: {why}");
|
||||||
#[cfg(not(debug_assertions))]
|
return;
|
||||||
{
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
_ = tokio::signal::ctrl_c() => {
|
if let Err(why) = client.start_autosharded().await {
|
||||||
info!("Received interrupt signal, shutting down...");
|
error!("Fatal error occured during bot operations: {why}");
|
||||||
|
error!("Bot will now shut 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Start the bot
|
|
||||||
if let Err(why) = client.start_autosharded().await {
|
|
||||||
error!("FATAL Error in bot: {:?}", why);
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
20
src/stats.rs
20
src/stats.rs
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue