mirror of
https://github.com/BluemediaDev/muse.git
synced 2025-06-26 16:52:42 +02:00
Merge remote-tracking branch 'upstream/master'
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
This commit is contained in:
commit
3caa0f1dc5
52 changed files with 4040 additions and 1937 deletions
|
@ -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=
|
||||
|
|
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
@ -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
108
.github/workflows/pr-release.yml
vendored
Normal 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
101
.github/workflows/pr-snapshot.yml
vendored
Normal 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
|
91
.github/workflows/pr.yml
vendored
91
.github/workflows/pr.yml
vendored
|
@ -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 }}
|
76
.github/workflows/publish.yml
vendored
76
.github/workflows/publish.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/release-comment.yml
vendored
2
.github/workflows/release-comment.yml
vendored
|
@ -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}.
|
||||
|
|
2
.github/workflows/type-check.yml
vendored
2
.github/workflows/type-check.yml
vendored
|
@ -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
|
||||
|
|
106
CHANGELOG.md
106
CHANGELOG.md
|
@ -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
|
||||
|
|
37
Dockerfile
37
Dockerfile
|
@ -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"]
|
41
README.md
41
README.md
|
@ -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
5
RELEASING.md
Normal 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.
|
|
@ -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;
|
||||
|
17
migrations/20240312135407_add_default_volume/migration.sql
Normal file
17
migrations/20240312135407_add_default_volume/migration.sql
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
57
package.json
57
package.json
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 = '';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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]});
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
42
src/commands/volume.ts
Normal 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}%`);
|
||||
}
|
||||
}
|
|
@ -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.');
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -9,7 +9,8 @@
|
|||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue