Merge remote-tracking branch 'upstream/master'
All checks were successful
ci/woodpecker/push/docker Pipeline was successful

This commit is contained in:
Oliver Traber 2025-06-23 23:51:00 +02:00
commit 3caa0f1dc5
Signed by: Bluemedia
GPG key ID: C0674B105057136C
52 changed files with 4040 additions and 1937 deletions

View file

@ -10,6 +10,9 @@ SPOTIFY_CLIENT_SECRET=
# CACHE_LIMIT=2GB
# ENABLE_SPONSORBLOCK=true
# SPONSORBLOCK_TIMEOUT=5 # Delay (in mn) before retrying when SponsorBlock server are unreachable.
# See the README for details on the below variables
# BOT_STATUS=
# BOT_ACTIVITY_TYPE=

View file

@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-node@v2
with:
node-version: '16'
node-version: '22'
cache: 'yarn'
- name: Install dependencies
run: yarn install

108
.github/workflows/pr-release.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: Release snapshot of PR
on:
workflow_run:
workflows: ["Build snapshot of PR"]
types:
- completed
env:
REGISTRY_IMAGE: ghcr.io/museofficial/muse
jobs:
release-and-comment:
name: Release snapshot and comment in PR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Buildx
uses: docker/setup-buildx-action@v1
- name: Download images
uses: actions/download-artifact@v4
with:
path: /tmp/images
pattern: image-linux-*
merge-multiple: true
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Load image
shell: bash
run: |
docker load -i /tmp/images/image-linux-amd64.tar
docker load -i /tmp/images/image-linux-arm64.tar
- name: Download SHA
uses: actions/download-artifact@v4
with:
path: /tmp/SHA
pattern: sha
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Read SHA
shell: bash
run: |
echo "SHA=$(cat /tmp/SHA/sha/sha.txt | tr -d '\n')" >> $GITHUB_ENV
- name: Push images
run: |
docker push ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-amd64
docker push ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-arm64
- name: Download Docker metadata
uses: actions/download-artifact@v4
with:
path: /tmp/metadata
pattern: metadata
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Read the metadata.json file
id: metadata_reader
uses: juliangruber/read-file-action@v1.0.0
with:
path: /tmp/metadata/metadata/metadata.json
- name: Download PR number
uses: actions/download-artifact@v4
with:
path: /tmp/pull_request_number
pattern: pull_request_number
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GH_PAT }}
- name: Read PR number
shell: bash
run: |
echo "PR_NUMBER=$(cat /tmp/pull_request_number/pull_request_number/pull_request_number.txt | tr -d '\n')" >> $GITHUB_ENV
- name: Create manifest list and push
run: |
docker buildx imagetools create $(cat /tmp/metadata/metadata/metadata.json | jq -cr '.tags | map("-t " + .) | join(" ")') ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-amd64 ${{ env.REGISTRY_IMAGE }}:${{ env.SHA }}-linux-arm64
- name: Create comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: "pr-release"
number: ${{ env.PR_NUMBER }}
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
message: |
#### :package: :robot: A new release has been made for this pull request.
To play around with this PR, pull `${{ env.REGISTRY_IMAGE }}:pr-${{ env.PR_NUMBER }}`.
Images are available for x86_64 and ARM64.
> Latest commit: ${{ env.SHA }}

101
.github/workflows/pr-snapshot.yml vendored Normal file
View file

@ -0,0 +1,101 @@
name: Build snapshot of PR
on: pull_request
env:
REGISTRY_IMAGE: ghcr.io/museofficial/muse
jobs:
build:
name: Build snapshot
strategy:
matrix:
runner-platform:
- ubuntu-latest
- namespace-profile-default-arm64
include:
- runner-platform: ubuntu-latest
build-arch: linux/amd64
- runner-platform: namespace-profile-default-arm64
build-arch: linux/arm64
runs-on: ${{ matrix.runner-platform }}
steps:
- name: Prepare
run: |
platform=${{ matrix.build-arch }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
tags: type=ref,event=pr
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get current time
uses: josStorer/get-current-time@v2
id: current-time
- name: Build
id: build
uses: docker/build-push-action@v6
with:
outputs: type=docker,dest=/tmp/image-${{ env.PLATFORM_PAIR }}.tar
platforms: ${{ matrix.build-arch }}
tags: |
${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-${{ env.PLATFORM_PAIR }}
build-args: |
COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ steps.current-time.outputs.time }}
- name: Export Docker meta output
shell: bash
run: echo $DOCKER_METADATA_OUTPUT_JSON > /tmp/metadata.json
- name: Upload metadata
uses: actions/upload-artifact@v4
with:
name: metadata
path: /tmp/metadata.json
overwrite: true
- name: Export SHA
run: |
echo "${{ github.sha }}" > /tmp/sha.txt
- name: Upload SHA
uses: actions/upload-artifact@v4
with:
name: sha
path: /tmp/sha.txt
overwrite: true
- name: Upload image
uses: actions/upload-artifact@v4
with:
name: image-${{ env.PLATFORM_PAIR }}
path: /tmp/image-${{ env.PLATFORM_PAIR }}.tar
if-no-files-found: error
retention-days: 1
- name: Save PR number in artifact
shell: bash
env:
PR_NUMBER: ${{ github.event.number }}
run: echo $PR_NUMBER > /tmp/pull_request_number.txt
- name: Upload PR number
uses: actions/upload-artifact@v4
with:
name: pull_request_number
path: /tmp/pull_request_number.txt
overwrite: true

View file

@ -1,91 +0,0 @@
name: PR Workflow
on: pull_request_target
jobs:
release-snapshot:
name: Release snapshot
strategy:
matrix:
runner-platform:
- ubuntu-latest
- buildjet-4vcpu-ubuntu-2204-arm
include:
- runner-platform: ubuntu-latest
build-arch: linux/amd64
tagged-platform: amd64
- runner-platform: buildjet-4vcpu-ubuntu-2204-arm
build-arch: linux/arm64
tagged-platform: arm64
runs-on: ${{ matrix.runner-platform }}
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
# AWS data transfer is pricy
if: ${{ matrix.runner-platform != 'buildjet-4vcpu-ubuntu-2204-arm' }}
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}-${{ github.event.pull_request.head.sha }}
restore-keys: |
${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get current time
uses: josStorer/get-current-time@v2
id: current-time
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: codetheweb/muse:${{ github.event.pull_request.head.sha }}-${{ matrix.tagged-platform }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=min
platforms: ${{ matrix.build-arch }}
build-args: |
COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ steps.current-time.outputs.time }}
combine-and-comment:
name: Combine platform tags and leave comment
runs-on: ubuntu-latest
needs: release-snapshot
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Combine tags
run: docker buildx imagetools create -t 'codetheweb/muse:pr-${{ github.event.number }}' -t 'codetheweb/muse:${{ github.event.pull_request.head.sha }}' 'codetheweb/muse:${{ github.event.pull_request.head.sha }}-arm64' 'codetheweb/muse:${{ github.event.pull_request.head.sha }}-amd64'
- name: Create comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: "pr-release"
message: |
#### :package: A new release has been made for this pull request.
To play around with this PR, pull `codetheweb/muse:pr-${{ github.event.number }}` or `codetheweb/muse:${{ github.event.pull_request.head.sha }}`.
Images are available for x86_64 and ARM64.
> Latest commit: ${{ github.event.pull_request.head.sha }}

View file

@ -5,34 +5,32 @@ on:
tags:
- 'v*'
env:
REGISTRY_IMAGE: ghcr.io/museofficial/muse
jobs:
publish:
strategy:
matrix:
runner-platform:
- ubuntu-latest
- buildjet-4vcpu-ubuntu-2204-arm
- namespace-profile-default-arm64
include:
- runner-platform: ubuntu-latest
build-arch: linux/amd64
tagged-platform: amd64
- runner-platform: buildjet-4vcpu-ubuntu-2204-arm
- runner-platform: namespace-profile-default-arm64
build-arch: linux/arm64
tagged-platform: arm64
runs-on: ${{ matrix.runner-platform }}
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
# AWS data transfer is pricy
if: ${{ matrix.runner-platform != 'buildjet-4vcpu-ubuntu-2204-arm' }}
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-prs-${{ matrix.build-arch }}
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v1
@ -40,19 +38,26 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get current time
uses: josStorer/get-current-time@v2
id: current-time
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
push: true
tags: codetheweb/muse:${{ github.sha }}-${{ matrix.tagged-platform }}
tags: |
codetheweb/muse:${{ github.sha }}-${{ matrix.tagged-platform }}
${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-${{ matrix.tagged-platform }}
platforms: ${{ matrix.build-arch }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=min
build-args: |
COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ steps.current-time.outputs.time }}
@ -61,6 +66,11 @@ jobs:
name: Combine platform tags
runs-on: ubuntu-latest
needs: publish
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- uses: actions/checkout@v1
@ -73,21 +83,37 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get tags
id: get-tags
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get tags (Docker Hub)
id: get-tags-dockerhub
uses: Surgo/docker-smart-tag-action@v1
with:
docker_image: codetheweb/muse
- name: Combine tags
run: docker buildx imagetools create $(echo '${{ steps.get-tags.outputs.tag }}' | tr "," "\0" | xargs -0 printf -- '-t %s ') 'codetheweb/muse:${{ github.sha }}-arm64' 'codetheweb/muse:${{ github.sha }}-amd64'
- name: Get tags (ghcr.io)
id: get-tags-ghcr
uses: Surgo/docker-smart-tag-action@v1
with:
docker_image: ${{ env.REGISTRY_IMAGE }}
- name: Combine tags (Docker Hub)
run: docker buildx imagetools create $(echo '${{ steps.get-tags-dockerhub.outputs.tag }}' | tr "," "\0" | xargs -0 printf -- '-t %s ') 'codetheweb/muse:${{ github.sha }}-arm64' 'codetheweb/muse:${{ github.sha }}-amd64'
- name: Combine tags (GitHub Container Registry)
run: docker buildx imagetools create $(echo '${{ steps.get-tags-ghcr.outputs.tag }}' | tr "," "\0" | xargs -0 printf -- '-t %s ') '${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-arm64' '${{ env.REGISTRY_IMAGE }}:${{ github.sha }}-amd64'
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v2.4.3
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }}
DOCKERHUB_REPOSITORY: codetheweb/muse
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: codetheweb/muse
release:
name: Create GitHub release

View file

@ -8,6 +8,6 @@ jobs:
steps:
- uses: apexskier/github-release-commenter@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
comment-template: |
🚀 Released in {release_link}.

View file

@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-node@v2
with:
node-version: '16'
node-version: '22'
cache: 'yarn'
- name: Install dependencies
run: yarn install

View file

@ -6,6 +6,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.11.1] - 2025-04-07
- Revert Dockerfile to inherit dependencies image from base image
## [2.11.0] - 2025-03-31
- Updated ytdl-core to 4.16.5 distubejs/ytdl-core@4.15.9...4.16.6 which includes distubejs/ytdl-core@1f57d78 fixing the sig parsing
- ytdl-core dropped node 18 support distubejs/ytdl-core@60f0ab1 so updated to latest Node LTS 22
- Updated to @discordjs/opus v0.10.0 for Node 22 support
- Updated to @discordjs/voice v0.18.0 to remove support for depricated encryption https://github.com/discordjs/discord.js/releases/tag/%40discordjs%2Fvoice%400.18.0
## [2.10.1] - 2025-01-28
- Remove Spotify requirement
- Dependency update
## [2.10.0] - 2024-11-04
- New `/config set-reduce-vol-when-voice` command to automatically turn down the volume when people are speaking in the channel
- New `/config set-reduce-vol-when-voice-target` command to set the target volume percentage (0-100) when people are speaking in the channel
- Support for using only YouTube, spotify credentials are now optional.
- Dependency update (Additional downgrade for p-queue)
## [2.9.5] - 2024-10-29
- Dependency update
- Pull request #1040 merged (Used incorrect PR number, apoligies)
## [2.9.4] - 2024-08-28
### Added
- An optional `page-size` to `/queue` command
- Add `default-queue-page-size` setting
## [2.9.3] - 2024-08-19
### Fixed
- bumped @discordjs/voice
- bumped @distube/ytdl-core
## [2.9.2] - 2024-08-18
### Changed
- Muse has new maintainers! I ([@codetheweb](https://github.com/codetheweb)) am stepping aside as I haven't used Muse myself for a few years and haven't been able to spend as much time on Muse as I'd like. See [this issue](https://github.com/museofficial/muse/issues/1063) for details. Welcome @museofficial/maintainers!
- This repository has been moved to museofficial/muse.
- Docker images are now published to `ghcr.io/museofficial/muse`. **Please update your image source if you use Docker**.
## [2.9.1] - 2024-08-04
### Fixed
- bumped ytdl-core
## [2.9.0] - 2024-07-17
### Added
- A `skip` option to the `/play` command
### Fixed
- Fixed playback issue
- Audioplayer not stopping properly
## [2.8.1] - 2024-04-28
### Fixed
- Fixed import issue that broke Muse inside of Docker. Thanks @sonroyaalmerol!
## [2.8.0] - 2024-04-28
### Added
- SponsorBlock is now supported as an opt-in feature and will skip non-music segments of videos when possible. Check the readme for config details. Thanks @Charlignon!
- There's a new config setting to make Muse responses when adding items to the queue visible only to the requester. Thanks @Sheeley7!
## [2.7.1] - 2024-03-18
### Changed
- Reduced Docker image size
## [2.7.0] - 2024-03-12
### Added 🔊
- A `/volume` command is now available.
- Set the default volume with `/config set-default-volume`
## [2.6.0] - 2024-03-03
### Added
- Muse can now auto-announce new tracks in your voice channel on the transition of a new track. Use `/config set-auto-announce-next-song True` to enable.
## [2.5.0] - 2024-01-16
### Added
@ -13,7 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.4.4] - 2023-12-21
- Optimized Docker container to run JS code directly with node instead of yarn, npm and tsx. Reduces memory usage.
- Optimized Docker container to run JS code directly with node instead of yarn, npm and tsx. Reduces memory usage.
## [2.4.3] - 2023-09-10
@ -280,7 +367,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial release
[unreleased]: https://github.com/codetheweb/muse/compare/v2.5.0...HEAD
[unreleased]: https://github.com/museofficial/muse/compare/v2.11.1...HEAD
[2.11.1]: https://github.com/museofficial/muse/compare/v2.11.0...v2.11.1
[2.11.0]: https://github.com/museofficial/muse/compare/v2.10.1...v2.11.0
[2.10.1]: https://github.com/museofficial/muse/compare/v2.10.0...v2.10.1
[2.10.0]: https://github.com/museofficial/muse/compare/v2.9.5...v2.10.0
[2.9.5]: https://github.com/museofficial/muse/compare/v2.9.4...v2.9.5
[2.9.4]: https://github.com/codetheweb/muse/compare/v2.9.3...v2.9.4
[2.9.3]: https://github.com/codetheweb/muse/compare/v2.9.2...v2.9.3
[2.9.2]: https://github.com/codetheweb/muse/compare/v2.9.1...v2.9.2
[2.9.1]: https://github.com/codetheweb/muse/compare/v2.9.0...v2.9.1
[2.9.0]: https://github.com/codetheweb/muse/compare/v2.8.1...v2.9.0
[2.8.1]: https://github.com/codetheweb/muse/compare/v2.8.0...v2.8.1
[2.8.0]: https://github.com/codetheweb/muse/compare/v2.7.1...v2.8.0
[2.7.1]: https://github.com/codetheweb/muse/compare/v2.7.0...v2.7.1
[2.7.0]: https://github.com/codetheweb/muse/compare/v2.6.0...v2.7.0
[2.6.0]: https://github.com/codetheweb/muse/compare/v2.5.0...v2.6.0
[2.5.0]: https://github.com/codetheweb/muse/compare/v2.4.4...v2.5.0
[2.4.4]: https://github.com/codetheweb/muse/compare/v2.4.3...v2.4.4
[2.4.3]: https://github.com/codetheweb/muse/compare/v2.4.2...v2.4.3

View file

@ -1,15 +1,33 @@
FROM node:18.7.0-slim AS base
FROM node:22-bookworm-slim AS base
# openssl will be a required package if base is updated to 18.16+ due to node:*-slim base distro change
# https://github.com/prisma/prisma/issues/19729#issuecomment-1591270599
# Install ffmpeg
RUN apt-get update && \
apt-get install -y ffmpeg tini libssl-dev ca-certificates git && \
rm -rf /var/lib/apt/lists/*
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
ffmpeg \
tini \
openssl \
ca-certificates \
&& apt-get autoclean \
&& apt-get autoremove \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
FROM base AS dependencies
WORKDIR /usr/app
# Add Python and build tools to compile native modules
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
python3 \
python-is-python3 \
build-essential \
&& apt-get autoclean \
&& apt-get autoremove \
&& rm -rf /var/lib/apt/lists/*
COPY package.json .
COPY yarn.lock .
@ -40,9 +58,10 @@ COPY . .
ARG COMMIT_HASH=unknown
ARG BUILD_DATE=unknown
ENV DATA_DIR /data
ENV NODE_ENV production
ENV COMMIT_HASH $COMMIT_HASH
ENV BUILD_DATE $BUILD_DATE
ENV DATA_DIR=/data
ENV NODE_ENV=production
ENV COMMIT_HASH=$COMMIT_HASH
ENV BUILD_DATE=$BUILD_DATE
ENV ENV_FILE=/config
CMD ["tini", "--", "node", "--enable-source-maps", "dist/scripts/migrate-and-start.js"]
CMD ["tini", "--", "node", "--enable-source-maps", "dist/scripts/migrate-and-start.js"]

View file

@ -1,8 +1,10 @@
<p align="center">
<img width="250" height="250" src="https://raw.githubusercontent.com/codetheweb/muse/master/.github/logo.png">
<img width="250" height="250" src="https://raw.githubusercontent.com/museofficial/muse/master/.github/logo.png">
</p>
🚨: v1.0.0 was a breaking change. Please take a look at the [release notes](https://github.com/codetheweb/muse/releases/tag/v1.0.0) for upgrade instructions
> [!WARNING]
> I ([@codetheweb](https://github.com/codetheweb)) am no longer the primary maintainer of Muse. **If you use the Docker image, update your image source to `ghcr.io/museofficial/muse`.** We are currently publishing new releases to both `ghcr.io/museofficial/muse` and `codetheweb/muse`, but this may change in the future.
> Thank you to all the people who stepped up to help maintain Muse!
------
@ -28,12 +30,9 @@ Muse is a **highly-opinionated midwestern self-hosted** Discord music bot **that
Muse is written in TypeScript. You can either run Muse with Docker (recommended) or directly with Node.js. Both methods require API keys passed in as environment variables:
- `DISCORD_TOKEN` can be acquired [here](https://discordapp.com/developers/applications) by creating a 'New Application', then going to 'Bot'.
- `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` can be acquired [here](https://developer.spotify.com/dashboard/applications) with 'Create a Client ID'.
- `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` can be acquired [here](https://developer.spotify.com/dashboard/applications) with 'Create a Client ID' (Optional).
- `YOUTUBE_API_KEY` can be acquired by [creating a new project](https://console.developers.google.com) in Google's Developer Console, enabling the YouTube API, and creating an API key under credentials.
> [!WARNING]
> Even if you don't plan on using Spotify, you must still provide the client ID and secret; otherwise Muse will not function.
Muse will log a URL when run. Open this URL in a browser to invite Muse to your server. Muse will DM the server owner after it's added with setup instructions.
A 64-bit OS is required to run Muse.
@ -42,7 +41,7 @@ A 64-bit OS is required to run Muse.
The `master` branch acts as the developing / bleeding edge branch and is not guaranteed to be stable.
When running a production instance, I recommend that you use the [latest release](https://github.com/codetheweb/muse/releases/).
When running a production instance, I recommend that you use the [latest release](https://github.com/museofficial/muse/releases/).
### 🐳 Docker
@ -56,19 +55,19 @@ There are a variety of image tags available:
(Replace empty config strings with correct values.)
```bash
docker run -it -v "$(pwd)/data":/data -e DISCORD_TOKEN='' -e SPOTIFY_CLIENT_ID='' -e SPOTIFY_CLIENT_SECRET='' -e YOUTUBE_API_KEY='' codetheweb/muse:latest
docker run -it -v "$(pwd)/data":/data -e DISCORD_TOKEN='' -e SPOTIFY_CLIENT_ID='' -e SPOTIFY_CLIENT_SECRET='' -e YOUTUBE_API_KEY='' ghcr.io/museofficial/muse:latest
```
This starts Muse and creates a data directory in your current directory.
You can also store your tokens in an environment file and make it available to your container. By default, the container will look for a `/config` environment file. You can customize this path with the `ENV_FILE` environment variable to use with, for example, [docker secrets](https://docs.docker.com/engine/swarm/secrets/).
**Docker Compose**:
```yaml
version: '3.4'
services:
muse:
image: codetheweb/muse:latest
image: ghcr.io/museofficial/muse:latest
restart: always
volumes:
- ./muse:/data
@ -82,16 +81,16 @@ services:
### Node.js
**Prerequisites**:
* Node.js (16.x is recommended because it's the current LTS version)
* Node.js (18.17.0 or latest 18.xx.xx is required and latest 18.x.x LTS is recommended) (Version 18 due to opus dependency)
* ffmpeg (4.1 or later)
1. `git clone https://github.com/codetheweb/muse.git && cd muse`
1. `git clone https://github.com/museofficial/muse.git && cd muse`
2. Copy `.env.example` to `.env` and populate with values
3. I recommend checking out a tagged release with `git checkout v[latest release]`
4. `yarn install` (or `npm i`)
5. `yarn start` (or `npm run start`)
**Note**: if you're on Windows, you may need to manually set the ffmpeg path. See [#345](https://github.com/codetheweb/muse/issues/345) for details.
**Note**: if you're on Windows, you may need to manually set the ffmpeg path. See [#345](https://github.com/museofficial/muse/issues/345) for details.
## ⚙️ Additional configuration (advanced)
@ -99,6 +98,11 @@ services:
By default, Muse limits the total cache size to around 2 GB. If you want to change this, set the environment variable `CACHE_LIMIT`. For example, `CACHE_LIMIT=512MB` or `CACHE_LIMIT=10GB`.
### SponsorBlock
Muse can skip non-music segments at the beginning or end of a Youtube music video (Using [SponsorBlock](https://sponsor.ajay.app/)). It is disabled by default. If you want to enable it, set the environment variable `ENABLE_SPONSORBLOCK=true` or uncomment it in your .env.
Being a community project, the server may be down or overloaded. When it happen, Muse will skip requests to SponsorBlock for a few minutes. You can change the skip duration by setting the value of `SPONSORBLOCK_TIMEOUT`.
### Custom Bot Status
In the default state, Muse has the status "Online" and the text "Listening to Music". You can change the status through environment variables:
@ -134,3 +138,12 @@ In the default state, Muse has the status "Online" and the text "Listening to Mu
### Bot-wide commands
If you have Muse running in a lot of guilds (10+) you may want to switch to registering commands bot-wide rather than for each guild. (The downside to this is that command updates can take up to an hour to propagate.) To do this, set the environment variable `REGISTER_COMMANDS_ON_BOT` to `true`.
### Automatically turn down volume when people speak
You can configure the bot to automatically turn down the volume when people are speaking in the channel using the following commands:
- `/config set-reduce-vol-when-voice true` - Enable automatic volume reduction
- `/config set-reduce-vol-when-voice false` - Disable automatic volume reduction
- `/config set-reduce-vol-when-voice-target <volume>` - Set the target volume percentage when people speak (0-100, default is 70)

5
RELEASING.md Normal file
View file

@ -0,0 +1,5 @@
# Releasing
1. Confirm that CHANGELOG.md is updated (any new changes should go under "Unreleased" at the top).
2. On the master branch, run `yarn release` and follow the prompts.
3. After a new tag is pushed from the above step, the [publish workflow](./.github/workflows/publish.yml) will automatically build & push Docker images and create a GitHub release for the tag.

View file

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,18 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"queueAddResponseEphemeral" BOOLEAN NOT NULL DEFAULT false,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,19 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"queueAddResponseEphemeral" BOOLEAN NOT NULL DEFAULT false,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"defaultQueuePageSize" INTEGER NOT NULL DEFAULT 10,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,21 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"queueAddResponseEphemeral" BOOLEAN NOT NULL DEFAULT false,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"defaultQueuePageSize" INTEGER NOT NULL DEFAULT 10,
"turnDownVolumeWhenPeopleSpeak" BOOLEAN NOT NULL DEFAULT false,
"turnDownVolumeWhenPeopleSpeakTarget" INTEGER NOT NULL DEFAULT 20,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "defaultQueuePageSize", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "defaultQueuePageSize", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -1,15 +1,15 @@
{
"name": "muse",
"version": "2.5.0",
"version": "2.11.1",
"description": "🎧 a self-hosted Discord music bot that doesn't suck ",
"repository": "git@github.com:codetheweb/muse.git",
"repository": "git@github.com:museofficial/muse.git",
"author": "Max Isom <hi@maxisom.me>",
"license": "MIT",
"private": true,
"types": "dts/types",
"type": "module",
"engines": {
"node": ">=16.0.0"
"node": ">=18.17.0"
},
"files": [
"src"
@ -25,9 +25,11 @@
"migrations:generate": "npm run prisma:with-env migrate dev",
"migrations:run": "npm run prisma:with-env migrate deploy",
"prisma:with-env": "npm run env:set-database-url prisma",
"prisma:generate": "prisma generate",
"env:set-database-url": "tsx src/scripts/run-with-database-url.ts",
"release": "release-it",
"build": "tsc"
"build": "tsc",
"postinstall": "patch-package"
},
"devDependencies": {
"@release-it/keep-a-changelog": "^2.3.0",
@ -35,19 +37,20 @@
"@types/debug": "^4.1.5",
"@types/fluent-ffmpeg": "^2.1.17",
"@types/fs-capacitor": "^2.0.0",
"@types/ms": "0.7.31",
"@types/ms": "0.7.34",
"@types/node": "^17.0.0",
"@types/node-emoji": "^1.8.1",
"@types/spotify-web-api-node": "^5.0.2",
"@types/validator": "^13.1.4",
"@types/validator": "^13.12.2",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "^4.31.1",
"@typescript-eslint/parser": "^4.31.1",
"eslint": "^7.32.0",
"eslint-config-xo": "^0.39.0",
"eslint-config-xo-typescript": "^0.44.0",
"eslint-plugin-import": "2.29.1",
"husky": "^4.3.8",
"prisma": "^3.14.0",
"prisma": "5.21.1",
"release-it": "^14.11.8",
"type-fest": "^2.12.0",
"typescript": "^4.6.4"
@ -61,12 +64,19 @@
"parserOptions": {
"project": "./tsconfig.json"
},
"plugins": [
"import"
],
"rules": {
"new-cap": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-vars-experimental": "error",
"@typescript-eslint/prefer-readonly-parameter-types": "off",
"@typescript-eslint/no-implicit-any-catch": "off"
"@typescript-eslint/no-implicit-any-catch": "off",
"import/extensions": [
"error",
"ignorePackages"
]
}
},
"husky": {
@ -76,47 +86,52 @@
},
"dependencies": {
"@discordjs/builders": "1.1.0",
"@discordjs/opus": "^0.8.0",
"@discordjs/opus": "^0.10.0",
"@discordjs/rest": "1.0.1",
"@discordjs/voice": "0.11.0",
"@prisma/client": "^4.1.1",
"@discordjs/voice": "0.18.0",
"@distube/ytdl-core": "^4.16.10",
"@distube/ytsr": "^2.0.4",
"@prisma/client": "4.16.0",
"@types/libsodium-wrappers": "^0.7.9",
"array-shuffle": "^3.0.0",
"debug": "^4.3.3",
"delay": "^5.0.0",
"discord-api-types": "0.32.1",
"discord.js": "14.11.0",
"dotenv": "^16.0.0",
"dotenv": "^16.4.5",
"execa": "^6.1.0",
"fluent-ffmpeg": "^2.1.2",
"fluent-ffmpeg": "^2.1.3",
"fs-capacitor": "^7.0.1",
"get-youtube-id": "^1.0.1",
"got": "^12.0.2",
"hasha": "^5.2.2",
"inversify": "^6.0.1",
"iso8601-duration": "^1.3.0",
"iso8601-duration": "^2.1.2",
"libsodium-wrappers": "^0.7.9",
"make-dir": "^3.1.0",
"node-emoji": "^1.10.0",
"nodesplash": "^0.1.1",
"ora": "^6.1.0",
"ora": "^8.1.0",
"p-event": "^5.0.1",
"p-limit": "^4.0.0",
"p-queue": "^7.2.0",
"p-retry": "4.6.2",
"p-limit": "^6.1.0",
"p-queue": "8.1.0",
"p-retry": "6.2.0",
"pagination.djs": "^4.0.10",
"parse-duration": "1.0.2",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"read-pkg": "7.1.0",
"reflect-metadata": "^0.1.13",
"reflect-metadata": "^0.2.2",
"sponsorblock-api": "^0.2.4",
"spotify-uri": "^3.0.2",
"spotify-web-api-node": "^5.0.2",
"sync-fetch": "^0.3.1",
"tsx": "3.8.2",
"xbytes": "^1.7.0",
"ytdl-core": "git+https://github.com/khlevon/node-ytdl-core.git#v4.11.4-patch.2",
"ytsr": "^3.8.4"
},
"resolutions": {
"@types/ws": "8.5.4"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View file

@ -24,12 +24,18 @@ model KeyValueCache {
}
model Setting {
guildId String @id
playlistLimit Int @default(50)
secondsToWaitAfterQueueEmpties Int @default(30)
leaveIfNoListeners Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guildId String @id
playlistLimit Int @default(50)
secondsToWaitAfterQueueEmpties Int @default(30)
leaveIfNoListeners Boolean @default(true)
queueAddResponseEphemeral Boolean @default(false)
autoAnnounceNextSong Boolean @default(false)
defaultVolume Int @default(100)
defaultQueuePageSize Int @default(10)
turnDownVolumeWhenPeopleSpeak Boolean @default(false)
turnDownVolumeWhenPeopleSpeakTarget Int @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FavoriteQuery {

View file

@ -3,7 +3,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
@injectable()
export default class implements Command {

View file

@ -33,11 +33,62 @@ export default class implements Command {
.setName('value')
.setDescription('whether to leave when everyone else leaves')
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-queue-add-response-hidden')
.setDescription('set whether bot responses to queue additions are only displayed to the requester')
.addBooleanOption(option => option
.setName('value')
.setDescription('whether bot responses to queue additions are only displayed to the requester')
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-reduce-vol-when-voice')
.setDescription('set whether to turn down the volume when people speak')
.addBooleanOption(option => option
.setName('value')
.setDescription('whether to turn down the volume when people speak')
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-reduce-vol-when-voice-target')
.setDescription('set the target volume when people speak')
.addIntegerOption(option => option
.setName('volume')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-auto-announce-next-song')
.setDescription('set whether to announce the next song in the queue automatically')
.addBooleanOption(option => option
.setName('value')
.setDescription('whether to announce the next song in the queue automatically')
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-default-volume')
.setDescription('set default volume used when entering the voice channel')
.addIntegerOption(option => option
.setName('level')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-default-queue-page-size')
.setDescription('set the default page size of the /queue command')
.addIntegerOption(option => option
.setName('page-size')
.setDescription('page size of the /queue command')
.setMinValue(1)
.setMaxValue(30)
.setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('get')
.setDescription('show all settings'));
async execute(interaction: ChatInputCommandInteraction) {
// Ensure guild settings exist before trying to update
await getGuildSettings(interaction.guild!.id);
switch (interaction.options.getSubcommand()) {
case 'set-playlist-limit': {
const limit: number = interaction.options.getInteger('limit')!;
@ -94,6 +145,108 @@ export default class implements Command {
break;
}
case 'set-queue-add-response-hidden': {
const value = interaction.options.getBoolean('value')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
queueAddResponseEphemeral: value,
},
});
await interaction.reply('👍 queue add notification setting updated');
break;
}
case 'set-auto-announce-next-song': {
const value = interaction.options.getBoolean('value')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
autoAnnounceNextSong: value,
},
});
await interaction.reply('👍 auto announce setting updated');
break;
}
case 'set-default-volume': {
const value = interaction.options.getInteger('level')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
defaultVolume: value,
},
});
await interaction.reply('👍 volume setting updated');
break;
}
case 'set-default-queue-page-size': {
const value = interaction.options.getInteger('page-size')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
defaultQueuePageSize: value,
},
});
await interaction.reply('👍 default queue page size updated');
break;
}
case 'set-reduce-vol-when-voice': {
const value = interaction.options.getBoolean('value')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
turnDownVolumeWhenPeopleSpeak: value,
},
});
await interaction.reply('👍 turn down volume setting updated');
break;
}
case 'set-reduce-vol-when-voice-target': {
const value = interaction.options.getInteger('volume')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
turnDownVolumeWhenPeopleSpeakTarget: value,
},
});
await interaction.reply('👍 turn down volume target setting updated');
break;
}
case 'get': {
const embed = new EmbedBuilder().setTitle('Config');
@ -105,6 +258,11 @@ export default class implements Command {
? 'never leave'
: `${config.secondsToWaitAfterQueueEmpties}s`,
'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no',
'Auto announce next song in queue': config.autoAnnounceNextSong ? 'yes' : 'no',
'Add to queue reponses show for requester only': config.autoAnnounceNextSong ? 'yes' : 'no',
'Default Volume': config.defaultVolume,
'Default queue page size': config.defaultQueuePageSize,
'Reduce volume when people speak': config.turnDownVolumeWhenPeopleSpeak ? 'yes' : 'no',
};
let description = '';

View file

@ -3,7 +3,7 @@ import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
@injectable()
export default class implements Command {

View file

@ -1,7 +1,7 @@
import {SlashCommandBuilder} from '@discordjs/builders';
import {APIEmbedField, AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify';
import Command from '.';
import Command from './index.js';
import AddQueryToQueue from '../services/add-query-to-queue.js';
import {TYPES} from '../types.js';
import {prisma} from '../utils/db.js';
@ -28,7 +28,10 @@ export default class implements Command {
.setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option
.setName('split')
.setDescription('if a track has chapters, split it')))
.setDescription('if a track has chapters, split it'))
.addBooleanOption(option => option
.setName('skip')
.setDescription('skip the currently playing track')))
.addSubcommand(subcommand => subcommand
.setName('list')
.setDescription('list all favorites'))
@ -124,6 +127,7 @@ export default class implements Command {
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
shouldSplitChapters: interaction.options.getBoolean('split') ?? false,
skipCurrentTrack: interaction.options.getBoolean('skip') ?? false,
});
}

View file

@ -3,7 +3,7 @@ import {SlashCommandBuilder} from '@discordjs/builders';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {prettyTime} from '../utils/time.js';
import durationStringToSeconds from '../utils/duration-string-to-seconds.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {STATUS} from '../services/player.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {STATUS} from '../services/player.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
@injectable()

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';

View file

@ -4,7 +4,7 @@ import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import {STATUS} from '../services/player.js';
import Command from '.';
import Command from './index.js';
@injectable()
export default class implements Command {

View file

@ -1,9 +1,9 @@
import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
import {URL} from 'url';
import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
import {SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder} from '@discordjs/builders';
import {inject, injectable, optional} from 'inversify';
import Spotify from 'spotify-web-api-node';
import Command from '.';
import Command from './index.js';
import {TYPES} from '../types.js';
import ThirdParty from '../services/third-party.js';
import getYouTubeAndSpotifySuggestionsFor from '../utils/get-youtube-and-spotify-suggestions-for.js';
@ -13,34 +13,43 @@ import AddQueryToQueue from '../services/add-query-to-queue.js';
@injectable()
export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('play')
.setDescription('play a song')
.addStringOption(option => option
.setName('query')
.setDescription('YouTube URL, Spotify URL, or search query')
.setAutocomplete(true)
.setRequired(true))
.addBooleanOption(option => option
.setName('immediate')
.setDescription('add track to the front of the queue'))
.addBooleanOption(option => option
.setName('shuffle')
.setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option
.setName('split')
.setDescription('if a track has chapters, split it'));
public readonly slashCommand: Partial<SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
public requiresVC = true;
private readonly spotify: Spotify;
private readonly spotify?: Spotify;
private readonly cache: KeyValueCacheProvider;
private readonly addQueryToQueue: AddQueryToQueue;
constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
this.spotify = thirdParty.spotify;
constructor(@inject(TYPES.ThirdParty) @optional() thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
this.spotify = thirdParty?.spotify;
this.cache = cache;
this.addQueryToQueue = addQueryToQueue;
const queryDescription = thirdParty === undefined
? 'YouTube URL or search query'
: 'YouTube URL, Spotify URL, or search query';
this.slashCommand = new SlashCommandBuilder()
.setName('play')
.setDescription('play a song')
.addStringOption(option => option
.setName('query')
.setDescription(queryDescription)
.setAutocomplete(true)
.setRequired(true))
.addBooleanOption(option => option
.setName('immediate')
.setDescription('add track to the front of the queue'))
.addBooleanOption(option => option
.setName('shuffle')
.setDescription('shuffle the input if you\'re adding multiple tracks'))
.addBooleanOption(option => option
.setName('split')
.setDescription('if a track has chapters, split it'))
.addBooleanOption(option => option
.setName('skip')
.setDescription('skip the currently playing track'));
}
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
@ -52,6 +61,7 @@ export default class implements Command {
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
shouldSplitChapters: interaction.options.getBoolean('split') ?? false,
skipCurrentTrack: interaction.options.getBoolean('skip') ?? false,
});
}

View file

@ -3,8 +3,9 @@ import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {buildQueueEmbed} from '../utils/build-embed.js';
import {getGuildSettings} from '../utils/get-guild-settings.js';
@injectable()
export default class implements Command {
@ -14,6 +15,12 @@ export default class implements Command {
.addIntegerOption(option => option
.setName('page')
.setDescription('page of queue to show [default: 1]')
.setRequired(false))
.addIntegerOption(option => option
.setName('page-size')
.setDescription('how many items to display per page [default: 10, max: 30]')
.setMinValue(1)
.setMaxValue(30)
.setRequired(false));
private readonly playerManager: PlayerManager;
@ -23,9 +30,17 @@ export default class implements Command {
}
public async execute(interaction: ChatInputCommandInteraction) {
const player = this.playerManager.get(interaction.guild!.id);
const guildId = interaction.guild!.id;
const player = this.playerManager.get(guildId);
const embed = buildQueueEmbed(player, interaction.options.getInteger('page') ?? 1);
const pageSizeFromOptions = interaction.options.getInteger('page-size');
const pageSize = pageSizeFromOptions ?? (await getGuildSettings(guildId)).defaultQueuePageSize;
const embed = buildQueueEmbed(
player,
interaction.options.getInteger('page') ?? 1,
pageSize,
);
await interaction.reply({embeds: [embed]});
}

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
@injectable()

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
@injectable()

View file

@ -1,6 +1,6 @@
import {SlashCommandBuilder} from '@discordjs/builders';
import {inject, injectable} from 'inversify';
import Command from '.';
import Command from './index.js';
import {TYPES} from '../types.js';
import PlayerManager from '../managers/player.js';
import {STATUS} from '../services/player.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {parseTime, prettyTime} from '../utils/time.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import durationStringToSeconds from '../utils/duration-string-to-seconds.js';

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
@injectable()

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';

View file

@ -4,7 +4,7 @@ import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import {STATUS} from '../services/player.js';
import Command from '.';
import Command from './index.js';
@injectable()
export default class implements Command {

View file

@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';

42
src/commands/volume.ts Normal file
View file

@ -0,0 +1,42 @@
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from './index.js';
import {SlashCommandBuilder} from '@discordjs/builders';
@injectable()
export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('volume')
.setDescription('set current player volume level')
.addIntegerOption(option =>
option.setName('level')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true),
);
public requiresVC = true;
private readonly playerManager: PlayerManager;
constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
this.playerManager = playerManager;
}
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const currentSong = player.getCurrent();
if (!currentSong) {
throw new Error('nothing is playing');
}
const level = interaction.options.getInteger('level') ?? 100;
player.setVolume(level);
await interaction.reply(`Set volume to ${level}%`);
}
}

View file

@ -1,6 +1,6 @@
import {Client, Guild} from 'discord.js';
import container from '../inversify.config.js';
import Command from '../commands';
import Command from '../commands/index.js';
import {TYPES} from '../types.js';
import Config from '../services/config.js';
import {prisma} from '../utils/db.js';
@ -40,5 +40,5 @@ export default async (guild: Guild): Promise<void> => {
}
const owner = await guild.fetchOwner();
await owner.send('👋 Hi! Someone (probably you) just invited me to a server you own. By default, I\'m usable by all guild member in all guild channels. To change this, check out the wiki page on permissions: https://github.com/codetheweb/muse/wiki/Configuring-Bot-Permissions.');
await owner.send('👋 Hi! Someone (probably you) just invited me to a server you own. By default, I\'m usable by all guild member in all guild channels. To change this, check out the wiki page on permissions: https://github.com/museofficial/muse/wiki/Configuring-Bot-Permissions.');
};

View file

@ -15,7 +15,7 @@ import YoutubeAPI from './services/youtube-api.js';
import SpotifyAPI from './services/spotify-api.js';
// Commands
import Command from './commands';
import Command from './commands/index.js';
import Clear from './commands/clear.js';
import Config from './commands/config.js';
import Disconnect from './commands/disconnect.js';
@ -37,6 +37,7 @@ import Shuffle from './commands/shuffle.js';
import Skip from './commands/skip.js';
import Stop from './commands/stop.js';
import Unskip from './commands/unskip.js';
import Volume from './commands/volume.js';
import ThirdParty from './services/third-party.js';
import FileCacheProvider from './services/file-cache.js';
import KeyValueCacheProvider from './services/key-value-cache.js';
@ -56,11 +57,20 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
// Managers
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
// Config values
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
// Services
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope();
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
// Only instanciate spotify dependencies if the Spotify client ID and secret are set
const config = container.get<ConfigProvider>(TYPES.Config);
if (config.SPOTIFY_CLIENT_ID !== '' && config.SPOTIFY_CLIENT_SECRET !== '') {
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
container.bind(TYPES.ThirdParty).to(ThirdParty);
}
// Commands
[
@ -85,16 +95,12 @@ container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton
Skip,
Stop,
Unskip,
Volume,
].forEach(command => {
container.bind<Command>(TYPES.Command).to(command).inSingletonScope();
});
// Config values
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
// Static libraries
container.bind(TYPES.ThirdParty).to(ThirdParty);
container.bind(TYPES.FileCache).to(FileCacheProvider);
container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider);

View file

@ -1,19 +1,35 @@
/* eslint-disable complexity */
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
import {URL} from 'node:url';
import {inject, injectable} from 'inversify';
import shuffle from 'array-shuffle';
import {TYPES} from '../types.js';
import GetSongs from '../services/get-songs.js';
import {SongMetadata, STATUS} from './player.js';
import {MediaSource, SongMetadata, STATUS} from './player.js';
import PlayerManager from '../managers/player.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
import {getGuildSettings} from '../utils/get-guild-settings.js';
import {SponsorBlock} from 'sponsorblock-api';
import Config from './config.js';
import KeyValueCacheProvider from './key-value-cache.js';
import {ONE_HOUR_IN_SECONDS} from '../utils/constants.js';
@injectable()
export default class AddQueryToQueue {
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs, @inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager) {
private readonly sponsorBlock?: SponsorBlock;
private sponsorBlockDisabledUntil?: Date;
private readonly sponsorBlockTimeoutDelay;
private readonly cache: KeyValueCacheProvider;
constructor(@inject(TYPES.Services.GetSongs) private readonly getSongs: GetSongs,
@inject(TYPES.Managers.Player) private readonly playerManager: PlayerManager,
@inject(TYPES.Config) private readonly config: Config,
@inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) {
this.sponsorBlockTimeoutDelay = config.SPONSORBLOCK_TIMEOUT;
this.sponsorBlock = config.ENABLE_SPONSORBLOCK
? new SponsorBlock('muse-sb-integration') // UserID matters only for submissions
: undefined;
this.cache = cache;
}
public async addToQueue({
@ -21,12 +37,14 @@ export default class AddQueryToQueue {
addToFrontOfQueue,
shuffleAdditions,
shouldSplitChapters,
skipCurrentTrack,
interaction,
}: {
query: string;
addToFrontOfQueue: boolean;
shuffleAdditions: boolean;
shouldSplitChapters: boolean;
skipCurrentTrack: boolean;
interaction: ChatInputCommandInteraction;
}): Promise<void> {
const guildId = interaction.guild!.id;
@ -37,78 +55,11 @@ export default class AddQueryToQueue {
const settings = await getGuildSettings(guildId);
const {playlistLimit} = settings;
const {playlistLimit, queueAddResponseEphemeral} = settings;
await interaction.deferReply();
await interaction.deferReply({ephemeral: queueAddResponseEphemeral});
let newSongs: SongMetadata[] = [];
let extraMsg = '';
// Test if it's a complete URL
try {
const url = new URL(query);
const YOUTUBE_HOSTS = [
'www.youtube.com',
'youtu.be',
'youtube.com',
'music.youtube.com',
'www.music.youtube.com',
];
if (YOUTUBE_HOSTS.includes(url.host)) {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
} else {
const songs = await this.getSongs.youtubeVideo(url.href, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
const [convertedSongs, nSongsNotFound, totalSongs] = await this.getSongs.spotifySource(query, playlistLimit, shouldSplitChapters);
if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
}
if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
extraMsg += ' and ';
}
if (nSongsNotFound !== 0) {
if (nSongsNotFound === 1) {
extraMsg += '1 song was not found';
} else {
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
}
}
newSongs.push(...convertedSongs);
} else {
const song = await this.getSongs.httpLiveStream(query);
if (song) {
newSongs.push(song);
} else {
throw new Error('that doesn\'t exist');
}
}
} catch (_: unknown) {
// Not a URL, must search YouTube
const songs = await this.getSongs.youtubeVideoSearch(query, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
let [newSongs, extraMsg] = await this.getSongs.getSongs(query, playlistLimit, shouldSplitChapters);
if (newSongs.length === 0) {
throw new Error('no songs found');
@ -118,6 +69,10 @@ export default class AddQueryToQueue {
newSongs = shuffle(newSongs);
}
if (this.config.ENABLE_SPONSORBLOCK) {
newSongs = await Promise.all(newSongs.map(this.skipNonMusicSegments.bind(this)));
}
newSongs.forEach(song => {
player.add({
...song,
@ -148,6 +103,14 @@ export default class AddQueryToQueue {
await player.play();
}
if (skipCurrentTrack) {
try {
await player.forward(1);
} catch (_: unknown) {
throw new Error('no song to skip to');
}
}
// Build response message
if (statusMsg !== '') {
if (extraMsg === '') {
@ -164,7 +127,69 @@ export default class AddQueryToQueue {
if (newSongs.length === 1) {
await interaction.editReply(`**${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`);
} else {
await interaction.editReply(`**${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${extraMsg}`);
await interaction.editReply(`**${firstSong.title}** and ${newSongs.length - 1} other songs were added to the queue${skipCurrentTrack ? 'and current track skipped' : ''}${extraMsg}`);
}
}
private async skipNonMusicSegments(song: SongMetadata) {
if (!this.sponsorBlock
|| (this.sponsorBlockDisabledUntil && new Date() < this.sponsorBlockDisabledUntil)
|| song.source !== MediaSource.Youtube
|| !song.url) {
return song;
}
try {
const segments = await this.cache.wrap(
async () => this.sponsorBlock?.getSegments(song.url, ['music_offtopic']),
{
key: song.url, // Value is too short for hashing
expiresIn: ONE_HOUR_IN_SECONDS,
},
) ?? [];
const skipSegments = segments
.sort((a, b) => a.startTime - b.startTime)
.reduce((acc: Array<{startTime: number; endTime: number}>, {startTime, endTime}) => {
const previousSegment = acc[acc.length - 1];
// If segments overlap merge
if (previousSegment && previousSegment.endTime > startTime) {
acc[acc.length - 1].endTime = endTime;
} else {
acc.push({startTime, endTime});
}
return acc;
}, []);
const intro = skipSegments[0];
const outro = skipSegments.at(-1);
if (outro && outro?.endTime >= song.length - 2) {
song.length -= outro.endTime - outro.startTime;
}
if (intro?.startTime <= 2) {
song.offset = Math.floor(intro.endTime);
song.length -= song.offset;
}
return song;
} catch (e) {
if (!(e instanceof Error)) {
console.error('Unexpected event occurred while fetching skip segments : ', e);
return song;
}
if (!e.message.includes('404')) {
// Don't log 404 response, it just means that there are no segments for given video
console.warn(`Could not fetch skip segments for "${song.url}" :`, e);
}
if (e.message.includes('504')) {
// Stop fetching SponsorBlock data when servers are down
this.sponsorBlockDisabledUntil = new Date(new Date().getTime() + (this.sponsorBlockTimeoutDelay * 60_000));
}
return song;
}
}
}

View file

@ -5,15 +5,15 @@ import path from 'path';
import xbytes from 'xbytes';
import {ConditionalKeys} from 'type-fest';
import {ActivityType, PresenceStatusData} from 'discord.js';
dotenv.config();
dotenv.config({path: process.env.ENV_FILE ?? path.resolve(process.cwd(), '.env')});
export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data');
const CONFIG_MAP = {
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID,
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID ?? '',
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET ?? '',
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
DATA_DIR,
CACHE_DIR: path.join(DATA_DIR, 'cache'),
@ -22,6 +22,8 @@ const CONFIG_MAP = {
BOT_ACTIVITY_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'LISTENING',
BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '',
BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music',
ENABLE_SPONSORBLOCK: process.env.ENABLE_SPONSORBLOCK === 'true',
SPONSORBLOCK_TIMEOUT: process.env.ENABLE_SPONSORBLOCK ?? 5,
} as const;
const BOT_ACTIVITY_TYPE_MAP = {
@ -45,6 +47,8 @@ export default class Config {
readonly BOT_ACTIVITY_TYPE!: Exclude<ActivityType, ActivityType.Custom>;
readonly BOT_ACTIVITY_URL!: string;
readonly BOT_ACTIVITY!: string;
readonly ENABLE_SPONSORBLOCK!: boolean;
readonly SPONSORBLOCK_TIMEOUT!: number;
constructor() {
for (const [key, value] of Object.entries(CONFIG_MAP)) {

View file

@ -1,34 +1,120 @@
import {inject, injectable} from 'inversify';
import {inject, injectable, optional} from 'inversify';
import * as spotifyURI from 'spotify-uri';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js';
import ffmpeg from 'fluent-ffmpeg';
import YoutubeAPI from './youtube-api.js';
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
import {URL} from 'node:url';
@injectable()
export default class {
private readonly youtubeAPI: YoutubeAPI;
private readonly spotifyAPI: SpotifyAPI;
private readonly spotifyAPI?: SpotifyAPI;
constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) spotifyAPI: SpotifyAPI) {
constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) @optional() spotifyAPI?: SpotifyAPI) {
this.youtubeAPI = youtubeAPI;
this.spotifyAPI = spotifyAPI;
}
async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
async getSongs(query: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], string]> {
const newSongs: SongMetadata[] = [];
let extraMsg = '';
// Test if it's a complete URL
try {
const url = new URL(query);
const YOUTUBE_HOSTS = [
'www.youtube.com',
'youtu.be',
'youtube.com',
'music.youtube.com',
'www.music.youtube.com',
];
if (YOUTUBE_HOSTS.includes(url.host)) {
// YouTube source
if (url.searchParams.get('list')) {
// YouTube playlist
newSongs.push(...await this.youtubePlaylist(url.searchParams.get('list')!, shouldSplitChapters));
} else {
const songs = await this.youtubeVideo(url.href, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
if (this.spotifyAPI === undefined) {
throw new Error('Spotify is not enabled!');
}
const [convertedSongs, nSongsNotFound, totalSongs] = await this.spotifySource(query, playlistLimit, shouldSplitChapters);
if (totalSongs > playlistLimit) {
extraMsg = `a random sample of ${playlistLimit} songs was taken`;
}
if (totalSongs > playlistLimit && nSongsNotFound !== 0) {
extraMsg += ' and ';
}
if (nSongsNotFound !== 0) {
if (nSongsNotFound === 1) {
extraMsg += '1 song was not found';
} else {
extraMsg += `${nSongsNotFound.toString()} songs were not found`;
}
}
newSongs.push(...convertedSongs);
} else {
const song = await this.httpLiveStream(query);
if (song) {
newSongs.push(song);
} else {
throw new Error('that doesn\'t exist');
}
}
} catch (err: any) {
if (err instanceof Error && err.message === 'Spotify is not enabled!') {
throw err;
}
// Not a URL, must search YouTube
const songs = await this.youtubeVideoSearch(query, shouldSplitChapters);
if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
return [newSongs, extraMsg];
}
private async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.search(query, shouldSplitChapters);
}
async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
private async youtubeVideo(url: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.getVideo(url, shouldSplitChapters);
}
async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
private async youtubePlaylist(listId: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
}
async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
private async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
if (this.spotifyAPI === undefined) {
return [[], 0, 0];
}
const parsed = spotifyURI.parse(url);
switch (parsed.type) {
@ -58,7 +144,7 @@ export default class {
}
}
async httpLiveStream(url: string): Promise<SongMetadata> {
private async httpLiveStream(url: string): Promise<SongMetadata> {
return new Promise((resolve, reject) => {
ffmpeg(url).ffprobe((err, _) => {
if (err) {

View file

@ -1,14 +1,14 @@
import {VoiceChannel, Snowflake} from 'discord.js';
import {Readable} from 'stream';
import hasha from 'hasha';
import ytdl, {videoFormat} from 'ytdl-core';
import ytdl, {videoFormat} from '@distube/ytdl-core';
import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle';
import {
AudioPlayer,
AudioPlayerState,
AudioPlayerStatus,
AudioPlayerStatus, AudioResource,
createAudioPlayer,
createAudioResource, DiscordGatewayAdapterCreator,
joinVoiceChannel,
@ -19,6 +19,8 @@ import {
import FileCacheProvider from './file-cache.js';
import debug from '../utils/debug.js';
import {getGuildSettings} from '../utils/get-guild-settings.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import {Setting} from '@prisma/client';
export enum MediaSource {
Youtube,
@ -33,7 +35,7 @@ export interface QueuedPlaylist {
export interface SongMetadata {
title: string;
artist: string;
url: string;
url: string; // For YT, it's the video ID (not the full URI)
length: number;
offset: number;
playlist: QueuedPlaylist | null;
@ -58,16 +60,21 @@ export interface PlayerEvents {
type YTDLVideoFormat = videoFormat & {loudnessDb?: number};
export const DEFAULT_VOLUME = 100;
export default class {
public voiceConnection: VoiceConnection | null = null;
public status = STATUS.PAUSED;
public guildId: string;
public loopCurrentSong = false;
public loopCurrentQueue = false;
private currentChannel: VoiceChannel | undefined;
private queue: QueuedSong[] = [];
private queuePosition = 0;
private audioPlayer: AudioPlayer | null = null;
private audioResource: AudioResource | null = null;
private volume?: number;
private defaultVolume: number = DEFAULT_VOLUME;
private nowPlaying: QueuedSong | null = null;
private playPositionInterval: NodeJS.Timeout | undefined;
private lastSongURL = '';
@ -76,18 +83,28 @@ export default class {
private readonly fileCache: FileCacheProvider;
private disconnectTimer: NodeJS.Timeout | null = null;
private readonly channelToSpeakingUsers: Map<string, Set<string>> = new Map();
constructor(fileCache: FileCacheProvider, guildId: string) {
this.fileCache = fileCache;
this.guildId = guildId;
}
async connect(channel: VoiceChannel): Promise<void> {
// Always get freshest default volume setting value
const settings = await getGuildSettings(this.guildId);
const {defaultVolume = DEFAULT_VOLUME} = settings;
this.defaultVolume = defaultVolume;
this.voiceConnection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
selfDeaf: false,
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator,
});
const guildSettings = await getGuildSettings(this.guildId);
// Workaround to disable keepAlive
this.voiceConnection.on('stateChange', (oldState, newState) => {
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
@ -102,6 +119,11 @@ export default class {
oldNetworking?.off('stateChange', networkStateChangeHandler);
newNetworking?.on('stateChange', networkStateChangeHandler);
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */
this.currentChannel = channel;
if (newState.status === VoiceConnectionStatus.Ready) {
this.registerVoiceActivityListener(guildSettings);
}
});
}
@ -113,10 +135,11 @@ export default class {
this.loopCurrentSong = false;
this.voiceConnection.destroy();
this.audioPlayer?.stop();
this.audioPlayer?.stop(true);
this.voiceConnection = null;
this.audioPlayer = null;
this.audioResource = null;
}
}
@ -152,9 +175,7 @@ export default class {
},
});
this.voiceConnection.subscribe(this.audioPlayer);
this.audioPlayer.play(createAudioResource(stream, {
inputType: StreamType.WebmOpus,
}));
this.playAudioPlayerResource(this.createAudioStream(stream));
this.attachListeners();
this.startTrackingPosition(positionSeconds);
@ -217,11 +238,7 @@ export default class {
},
});
this.voiceConnection.subscribe(this.audioPlayer);
const resource = createAudioResource(stream, {
inputType: StreamType.WebmOpus,
});
this.audioPlayer.play(resource);
this.playAudioPlayerResource(this.createAudioStream(stream));
this.attachListeners();
@ -272,8 +289,8 @@ export default class {
if (this.getCurrent() && this.status !== STATUS.PAUSED) {
await this.play();
} else {
this.audioPlayer?.stop();
this.status = STATUS.IDLE;
this.audioPlayer?.stop(true);
const settings = await getGuildSettings(this.guildId);
@ -294,6 +311,63 @@ export default class {
}
}
registerVoiceActivityListener(guildSettings: Setting) {
const {turnDownVolumeWhenPeopleSpeak, turnDownVolumeWhenPeopleSpeakTarget} = guildSettings;
if (!turnDownVolumeWhenPeopleSpeak || !this.voiceConnection) {
return;
}
this.voiceConnection.receiver.speaking.on('start', (userId: string) => {
if (!this.currentChannel) {
return;
}
const member = this.currentChannel.members.get(userId);
const channelId = this.currentChannel?.id;
if (member) {
if (!this.channelToSpeakingUsers.has(channelId)) {
this.channelToSpeakingUsers.set(channelId, new Set());
}
this.channelToSpeakingUsers.get(channelId)?.add(member.id);
}
this.suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget);
});
this.voiceConnection.receiver.speaking.on('end', (userId: string) => {
if (!this.currentChannel) {
return;
}
const member = this.currentChannel.members.get(userId);
const channelId = this.currentChannel.id;
if (member) {
if (!this.channelToSpeakingUsers.has(channelId)) {
this.channelToSpeakingUsers.set(channelId, new Set());
}
this.channelToSpeakingUsers.get(channelId)?.delete(member.id);
}
this.suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget);
});
}
suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget: number): void {
if (!this.currentChannel) {
return;
}
const speakingUsers = this.channelToSpeakingUsers.get(this.currentChannel.id);
if (speakingUsers && speakingUsers.size > 0) {
this.setVolume(turnDownVolumeWhenPeopleSpeakTarget);
} else {
this.setVolume(this.defaultVolume);
}
}
canGoForward(skip: number) {
return (this.queuePosition + skip - 1) < this.queue.length;
}
@ -405,11 +479,28 @@ export default class {
return this.queue[this.queuePosition + to];
}
setVolume(level: number): void {
// Level should be a number between 0 and 100 = 0% => 100%
this.volume = level;
this.setAudioPlayerVolume(level);
}
getVolume(): number {
// Only use default volume if player volume is not already set (in the event of a reconnect we shouldn't reset)
return this.volume ?? this.defaultVolume;
}
private getHashForCache(url: string): string {
return hasha(url);
}
private async getStream(song: QueuedSong, options: {seek?: number; to?: number} = {}): Promise<Readable> {
if (this.status === STATUS.PLAYING) {
this.audioPlayer?.stop();
} else if (this.status === STATUS.PAUSED) {
this.audioPlayer?.stop(true);
}
if (song.source === MediaSource.HLS) {
return this.createReadStream({url: song.url, cacheKey: song.url});
}
@ -433,6 +524,10 @@ export default class {
format = formats.find(filter);
const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat | undefined => {
if (formats.length < 1) {
return undefined;
}
if (formats[0].isLive) {
formats = formats.sort((a, b) => (b as unknown as {audioBitrate: number}).audioBitrate - (a as unknown as {audioBitrate: number}).audioBitrate); // Bad typings
@ -559,6 +654,14 @@ export default class {
if (newState.status === AudioPlayerStatus.Idle && this.status === STATUS.PLAYING) {
await this.forward(1);
// Auto announce the next song if configured to
const settings = await getGuildSettings(this.guildId);
const {autoAnnounceNextSong} = settings;
if (autoAnnounceNextSong && this.currentChannel) {
await this.currentChannel.send({
embeds: this.getCurrent() ? [buildPlayingMessageEmbed(this)] : [],
});
}
}
}
@ -586,17 +689,40 @@ export default class {
}
})
.on('start', command => {
debug(`Spawned ffmpeg with ${command as string}`);
debug(`Spawned ffmpeg with ${command}`);
});
stream.pipe(capacitor);
returnedStream.on('close', () => {
stream.kill('SIGKILL');
if (!options.cache) {
stream.kill('SIGKILL');
}
hasReturnedStreamClosed = true;
});
resolve(returnedStream);
});
}
private createAudioStream(stream: Readable) {
return createAudioResource(stream, {
inputType: StreamType.WebmOpus,
inlineVolume: true,
});
}
private playAudioPlayerResource(resource: AudioResource) {
if (this.audioPlayer !== null) {
this.audioResource = resource;
this.setAudioPlayerVolume();
this.audioPlayer.play(this.audioResource);
}
}
private setAudioPlayerVolume(level?: number) {
// Audio resource expects a float between 0 and 1 to represent level percentage
this.audioResource?.volume?.setVolume((level ?? this.getVolume()) / 100);
}
}

View file

@ -1,7 +1,7 @@
import {inject, injectable} from 'inversify';
import {toSeconds, parse} from 'iso8601-duration';
import got, {Got} from 'got';
import ytsr, {Video} from 'ytsr';
import ytsr, {Video} from '@distube/ytsr';
import PQueue from 'p-queue';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js';
@ -74,7 +74,7 @@ export default class {
}
async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
const {items} = await this.ytsrQueue.add(async () => this.cache.wrap(
const result = await this.ytsrQueue.add<ytsr.VideoResult>(async () => this.cache.wrap(
ytsr,
query,
{
@ -85,9 +85,13 @@ export default class {
},
));
if (result === undefined) {
return [];
}
let firstVideo: Video | undefined;
for (const item of items) {
for (const item of result.items) {
if (item.type === 'video') {
firstVideo = item;
break;
@ -95,7 +99,7 @@ export default class {
}
if (!firstVideo) {
throw new Error('No video found.');
return [];
}
return this.getVideo(firstVideo.url, shouldSplitChapters);

View file

@ -5,8 +5,6 @@ import getProgressBar from './get-progress-bar.js';
import {prettyTime} from './time.js';
import {truncate} from './string.js';
const PAGE_SIZE = 10;
const getMaxSongTitleLength = (title: string) => {
// eslint-disable-next-line no-control-regex
const nonASCII = /[^\x00-\x7F]+/;
@ -44,10 +42,11 @@ const getPlayerUI = (player: Player) => {
const position = player.getPosition();
const button = player.status === STATUS.PLAYING ? '⏹️' : '▶️';
const progressBar = getProgressBar(15, position / song.length);
const progressBar = getProgressBar(10, position / song.length);
const elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`;
const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : '';
return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉 ${loop}`;
const vol: string = typeof player.getVolume() === 'number' ? `${player.getVolume()!}%` : '';
return `${button} ${progressBar} \`[${elapsedTime}]\`🔉 ${vol} ${loop}`;
};
export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
@ -76,7 +75,7 @@ export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
return message;
};
export const buildQueueEmbed = (player: Player, page: number): EmbedBuilder => {
export const buildQueueEmbed = (player: Player, page: number, pageSize: number): EmbedBuilder => {
const currentlyPlaying = player.getCurrent();
if (!currentlyPlaying) {
@ -84,14 +83,14 @@ export const buildQueueEmbed = (player: Player, page: number): EmbedBuilder => {
}
const queueSize = player.queueSize();
const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE);
const maxQueuePage = Math.ceil((queueSize + 1) / pageSize);
if (page > maxQueuePage) {
throw new Error('the queue isn\'t that big');
}
const queuePageBegin = (page - 1) * PAGE_SIZE;
const queuePageEnd = queuePageBegin + PAGE_SIZE;
const queuePageBegin = (page - 1) * pageSize;
const queuePageEnd = queuePageBegin + pageSize;
const queuedSongs = player
.getQueue()
.slice(queuePageBegin, queuePageEnd)

View file

@ -14,28 +14,18 @@ const filterDuplicates = <T extends {name: string}>(items: T[]) => {
return results;
};
const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: SpotifyWebApi, limit = 10): Promise<APIApplicationCommandOptionChoice[]> => {
const [youtubeSuggestions, spotifyResults] = await Promise.all([
getYouTubeSuggestionsFor(query),
spotify.search(query, ['track', 'album'], {limit: 5}),
]);
const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify?: SpotifyWebApi, limit = 10): Promise<APIApplicationCommandOptionChoice[]> => {
// Only search Spotify if enabled
const spotifySuggestionPromise = spotify === undefined
? undefined
: spotify.search(query, ['album', 'track'], {limit});
const youtubeSuggestions = await getYouTubeSuggestionsFor(query);
const totalYouTubeResults = youtubeSuggestions.length;
const numOfYouTubeSuggestions = Math.min(limit, totalYouTubeResults);
const spotifyAlbums = filterDuplicates(spotifyResults.body.albums?.items ?? []);
const spotifyTracks = filterDuplicates(spotifyResults.body.tracks?.items ?? []);
const totalSpotifyResults = spotifyAlbums.length + spotifyTracks.length;
// Number of results for each source should be roughly the same.
// If we don't have enough Spotify suggestions, prioritize YouTube results.
const maxSpotifySuggestions = Math.floor(limit / 2);
const numOfSpotifySuggestions = Math.min(maxSpotifySuggestions, totalSpotifyResults);
const maxYouTubeSuggestions = limit - numOfSpotifySuggestions;
const numOfYouTubeSuggestions = Math.min(maxYouTubeSuggestions, totalYouTubeResults);
const suggestions: APIApplicationCommandOptionChoice[] = [];
let suggestions: APIApplicationCommandOptionChoice[] = [];
suggestions.push(
...youtubeSuggestions
@ -46,23 +36,40 @@ const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: Spotif
}),
));
const maxSpotifyAlbums = Math.floor(numOfSpotifySuggestions / 2);
const numOfSpotifyAlbums = Math.min(maxSpotifyAlbums, spotifyResults.body.albums?.items.length ?? 0);
const maxSpotifyTracks = numOfSpotifySuggestions - numOfSpotifyAlbums;
if (spotify !== undefined && spotifySuggestionPromise !== undefined) {
const spotifyResponse = (await spotifySuggestionPromise).body;
const spotifyAlbums = filterDuplicates(spotifyResponse.albums?.items ?? []);
const spotifyTracks = filterDuplicates(spotifyResponse.tracks?.items ?? []);
suggestions.push(
...spotifyAlbums.slice(0, maxSpotifyAlbums).map(album => ({
name: `Spotify: 💿 ${album.name}${album.artists.length > 0 ? ` - ${album.artists[0].name}` : ''}`,
value: `spotify:album:${album.id}`,
})),
);
const totalSpotifyResults = spotifyAlbums.length + spotifyTracks.length;
suggestions.push(
...spotifyTracks.slice(0, maxSpotifyTracks).map(track => ({
name: `Spotify: 🎵 ${track.name}${track.artists.length > 0 ? ` - ${track.artists[0].name}` : ''}`,
value: `spotify:track:${track.id}`,
})),
);
// Number of results for each source should be roughly the same.
// If we don't have enough Spotify suggestions, prioritize YouTube results.
const maxSpotifySuggestions = Math.floor(limit / 2);
const numOfSpotifySuggestions = Math.min(maxSpotifySuggestions, totalSpotifyResults);
const maxSpotifyAlbums = Math.floor(numOfSpotifySuggestions / 2);
const numOfSpotifyAlbums = Math.min(maxSpotifyAlbums, spotifyResponse.albums?.items.length ?? 0);
const maxSpotifyTracks = numOfSpotifySuggestions - numOfSpotifyAlbums;
// Make room for spotify results
const maxYouTubeSuggestions = limit - numOfSpotifySuggestions;
suggestions = suggestions.slice(0, maxYouTubeSuggestions);
suggestions.push(
...spotifyAlbums.slice(0, maxSpotifyAlbums).map(album => ({
name: `Spotify: 💿 ${album.name}${album.artists.length > 0 ? ` - ${album.artists[0].name}` : ''}`,
value: `spotify:album:${album.id}`,
})),
);
suggestions.push(
...spotifyTracks.slice(0, maxSpotifyTracks).map(track => ({
name: `Spotify: 🎵 ${track.name}${track.artists.length > 0 ? ` - ${track.artists[0].name}` : ''}`,
value: `spotify:track:${track.id}`,
})),
);
}
return suggestions;
};

View file

@ -1,6 +1,6 @@
import {REST} from '@discordjs/rest';
import {Routes} from 'discord-api-types/v10';
import Command from '../commands';
import Command from '../commands/index.js';
interface RegisterCommandsOnGuildOptions {
rest: REST;

View file

@ -9,7 +9,8 @@
"esModuleInterop": true,
"sourceMap": true,
"resolveJsonModule": true,
"outDir": "dist"
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules"]

4345
yarn.lock

File diff suppressed because it is too large Load diff