mirror of
https://github.com/BluemediaDev/muse.git
synced 2025-06-27 01:02:41 +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
|
# 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
|
# See the README for details on the below variables
|
||||||
# BOT_STATUS=
|
# BOT_STATUS=
|
||||||
# BOT_ACTIVITY_TYPE=
|
# 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/checkout@v1
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '22'
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install
|
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:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY_IMAGE: ghcr.io/museofficial/muse
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
runner-platform:
|
runner-platform:
|
||||||
- ubuntu-latest
|
- ubuntu-latest
|
||||||
- buildjet-4vcpu-ubuntu-2204-arm
|
- namespace-profile-default-arm64
|
||||||
include:
|
include:
|
||||||
- runner-platform: ubuntu-latest
|
- runner-platform: ubuntu-latest
|
||||||
build-arch: linux/amd64
|
build-arch: linux/amd64
|
||||||
tagged-platform: amd64
|
tagged-platform: amd64
|
||||||
- runner-platform: buildjet-4vcpu-ubuntu-2204-arm
|
- runner-platform: namespace-profile-default-arm64
|
||||||
build-arch: linux/arm64
|
build-arch: linux/arm64
|
||||||
tagged-platform: arm64
|
tagged-platform: arm64
|
||||||
runs-on: ${{ matrix.runner-platform }}
|
runs-on: ${{ matrix.runner-platform }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
|
@ -40,19 +38,26 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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
|
- name: Get current time
|
||||||
uses: josStorer/get-current-time@v2
|
uses: josStorer/get-current-time@v2
|
||||||
id: current-time
|
id: current-time
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
push: true
|
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 }}
|
platforms: ${{ matrix.build-arch }}
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=min
|
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_HASH=${{ github.sha }}
|
COMMIT_HASH=${{ github.sha }}
|
||||||
BUILD_DATE=${{ steps.current-time.outputs.time }}
|
BUILD_DATE=${{ steps.current-time.outputs.time }}
|
||||||
|
@ -61,6 +66,11 @@ jobs:
|
||||||
name: Combine platform tags
|
name: Combine platform tags
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: publish
|
needs: publish
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
|
@ -73,21 +83,37 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get tags
|
- name: Login to GitHub Container Registry
|
||||||
id: get-tags
|
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
|
uses: Surgo/docker-smart-tag-action@v1
|
||||||
with:
|
with:
|
||||||
docker_image: codetheweb/muse
|
docker_image: codetheweb/muse
|
||||||
|
|
||||||
- name: Combine tags
|
- name: Get tags (ghcr.io)
|
||||||
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'
|
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
|
- name: Update Docker Hub description
|
||||||
uses: peter-evans/dockerhub-description@v2.4.3
|
uses: peter-evans/dockerhub-description@v2.4.3
|
||||||
env:
|
with:
|
||||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
DOCKERHUB_REPOSITORY: codetheweb/muse
|
repository: codetheweb/muse
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Create GitHub 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:
|
steps:
|
||||||
- uses: apexskier/github-release-commenter@v1
|
- uses: apexskier/github-release-commenter@v1
|
||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||||
comment-template: |
|
comment-template: |
|
||||||
🚀 Released in {release_link}.
|
🚀 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/checkout@v1
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '22'
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install
|
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]
|
## [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
|
## [2.5.0] - 2024-01-16
|
||||||
|
|
||||||
### Added
|
### 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
|
## [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
|
## [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
|
### Added
|
||||||
- Initial release
|
- 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.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.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
|
[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
|
# Install ffmpeg
|
||||||
RUN apt-get update && \
|
RUN apt-get update \
|
||||||
apt-get install -y ffmpeg tini libssl-dev ca-certificates git && \
|
&& apt-get install --no-install-recommends -y \
|
||||||
rm -rf /var/lib/apt/lists/*
|
ffmpeg \
|
||||||
|
tini \
|
||||||
|
openssl \
|
||||||
|
ca-certificates \
|
||||||
|
&& apt-get autoclean \
|
||||||
|
&& apt-get autoremove \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
FROM base AS dependencies
|
FROM base AS dependencies
|
||||||
|
|
||||||
WORKDIR /usr/app
|
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 package.json .
|
||||||
COPY yarn.lock .
|
COPY yarn.lock .
|
||||||
|
|
||||||
|
@ -40,9 +58,10 @@ COPY . .
|
||||||
ARG COMMIT_HASH=unknown
|
ARG COMMIT_HASH=unknown
|
||||||
ARG BUILD_DATE=unknown
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
ENV DATA_DIR /data
|
ENV DATA_DIR=/data
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
ENV COMMIT_HASH $COMMIT_HASH
|
ENV COMMIT_HASH=$COMMIT_HASH
|
||||||
ENV BUILD_DATE $BUILD_DATE
|
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">
|
<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>
|
</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:
|
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'.
|
- `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.
|
- `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.
|
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.
|
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.
|
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
|
### 🐳 Docker
|
||||||
|
@ -56,19 +55,19 @@ There are a variety of image tags available:
|
||||||
(Replace empty config strings with correct values.)
|
(Replace empty config strings with correct values.)
|
||||||
|
|
||||||
```bash
|
```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.
|
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**:
|
**Docker Compose**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.4'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
muse:
|
muse:
|
||||||
image: codetheweb/muse:latest
|
image: ghcr.io/museofficial/muse:latest
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./muse:/data
|
- ./muse:/data
|
||||||
|
@ -82,16 +81,16 @@ services:
|
||||||
### Node.js
|
### Node.js
|
||||||
|
|
||||||
**Prerequisites**:
|
**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)
|
* 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
|
2. Copy `.env.example` to `.env` and populate with values
|
||||||
3. I recommend checking out a tagged release with `git checkout v[latest release]`
|
3. I recommend checking out a tagged release with `git checkout v[latest release]`
|
||||||
4. `yarn install` (or `npm i`)
|
4. `yarn install` (or `npm i`)
|
||||||
5. `yarn start` (or `npm run start`)
|
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)
|
## ⚙️ 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`.
|
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
|
### 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:
|
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
|
### 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`.
|
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",
|
"name": "muse",
|
||||||
"version": "2.5.0",
|
"version": "2.11.1",
|
||||||
"description": "🎧 a self-hosted Discord music bot that doesn't suck ",
|
"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>",
|
"author": "Max Isom <hi@maxisom.me>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"types": "dts/types",
|
"types": "dts/types",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=18.17.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src"
|
"src"
|
||||||
|
@ -25,9 +25,11 @@
|
||||||
"migrations:generate": "npm run prisma:with-env migrate dev",
|
"migrations:generate": "npm run prisma:with-env migrate dev",
|
||||||
"migrations:run": "npm run prisma:with-env migrate deploy",
|
"migrations:run": "npm run prisma:with-env migrate deploy",
|
||||||
"prisma:with-env": "npm run env:set-database-url prisma",
|
"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",
|
"env:set-database-url": "tsx src/scripts/run-with-database-url.ts",
|
||||||
"release": "release-it",
|
"release": "release-it",
|
||||||
"build": "tsc"
|
"build": "tsc",
|
||||||
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@release-it/keep-a-changelog": "^2.3.0",
|
"@release-it/keep-a-changelog": "^2.3.0",
|
||||||
|
@ -35,19 +37,20 @@
|
||||||
"@types/debug": "^4.1.5",
|
"@types/debug": "^4.1.5",
|
||||||
"@types/fluent-ffmpeg": "^2.1.17",
|
"@types/fluent-ffmpeg": "^2.1.17",
|
||||||
"@types/fs-capacitor": "^2.0.0",
|
"@types/fs-capacitor": "^2.0.0",
|
||||||
"@types/ms": "0.7.31",
|
"@types/ms": "0.7.34",
|
||||||
"@types/node": "^17.0.0",
|
"@types/node": "^17.0.0",
|
||||||
"@types/node-emoji": "^1.8.1",
|
"@types/node-emoji": "^1.8.1",
|
||||||
"@types/spotify-web-api-node": "^5.0.2",
|
"@types/spotify-web-api-node": "^5.0.2",
|
||||||
"@types/validator": "^13.1.4",
|
"@types/validator": "^13.12.2",
|
||||||
"@types/ws": "8.5.4",
|
"@types/ws": "8.5.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.31.1",
|
"@typescript-eslint/eslint-plugin": "^4.31.1",
|
||||||
"@typescript-eslint/parser": "^4.31.1",
|
"@typescript-eslint/parser": "^4.31.1",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-xo": "^0.39.0",
|
"eslint-config-xo": "^0.39.0",
|
||||||
"eslint-config-xo-typescript": "^0.44.0",
|
"eslint-config-xo-typescript": "^0.44.0",
|
||||||
|
"eslint-plugin-import": "2.29.1",
|
||||||
"husky": "^4.3.8",
|
"husky": "^4.3.8",
|
||||||
"prisma": "^3.14.0",
|
"prisma": "5.21.1",
|
||||||
"release-it": "^14.11.8",
|
"release-it": "^14.11.8",
|
||||||
"type-fest": "^2.12.0",
|
"type-fest": "^2.12.0",
|
||||||
"typescript": "^4.6.4"
|
"typescript": "^4.6.4"
|
||||||
|
@ -61,12 +64,19 @@
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"project": "./tsconfig.json"
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
|
"plugins": [
|
||||||
|
"import"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"new-cap": "off",
|
"new-cap": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars-experimental": "error",
|
"@typescript-eslint/no-unused-vars-experimental": "error",
|
||||||
"@typescript-eslint/prefer-readonly-parameter-types": "off",
|
"@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": {
|
"husky": {
|
||||||
|
@ -76,47 +86,52 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/builders": "1.1.0",
|
"@discordjs/builders": "1.1.0",
|
||||||
"@discordjs/opus": "^0.8.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/rest": "1.0.1",
|
"@discordjs/rest": "1.0.1",
|
||||||
"@discordjs/voice": "0.11.0",
|
"@discordjs/voice": "0.18.0",
|
||||||
"@prisma/client": "^4.1.1",
|
"@distube/ytdl-core": "^4.16.10",
|
||||||
|
"@distube/ytsr": "^2.0.4",
|
||||||
|
"@prisma/client": "4.16.0",
|
||||||
"@types/libsodium-wrappers": "^0.7.9",
|
"@types/libsodium-wrappers": "^0.7.9",
|
||||||
"array-shuffle": "^3.0.0",
|
"array-shuffle": "^3.0.0",
|
||||||
"debug": "^4.3.3",
|
"debug": "^4.3.3",
|
||||||
"delay": "^5.0.0",
|
"delay": "^5.0.0",
|
||||||
"discord-api-types": "0.32.1",
|
"discord-api-types": "0.32.1",
|
||||||
"discord.js": "14.11.0",
|
"discord.js": "14.11.0",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.4.5",
|
||||||
"execa": "^6.1.0",
|
"execa": "^6.1.0",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"fs-capacitor": "^7.0.1",
|
"fs-capacitor": "^7.0.1",
|
||||||
"get-youtube-id": "^1.0.1",
|
"get-youtube-id": "^1.0.1",
|
||||||
"got": "^12.0.2",
|
"got": "^12.0.2",
|
||||||
"hasha": "^5.2.2",
|
"hasha": "^5.2.2",
|
||||||
"inversify": "^6.0.1",
|
"inversify": "^6.0.1",
|
||||||
"iso8601-duration": "^1.3.0",
|
"iso8601-duration": "^2.1.2",
|
||||||
"libsodium-wrappers": "^0.7.9",
|
"libsodium-wrappers": "^0.7.9",
|
||||||
"make-dir": "^3.1.0",
|
"make-dir": "^3.1.0",
|
||||||
"node-emoji": "^1.10.0",
|
"node-emoji": "^1.10.0",
|
||||||
"nodesplash": "^0.1.1",
|
"nodesplash": "^0.1.1",
|
||||||
"ora": "^6.1.0",
|
"ora": "^8.1.0",
|
||||||
"p-event": "^5.0.1",
|
"p-event": "^5.0.1",
|
||||||
"p-limit": "^4.0.0",
|
"p-limit": "^6.1.0",
|
||||||
"p-queue": "^7.2.0",
|
"p-queue": "8.1.0",
|
||||||
"p-retry": "4.6.2",
|
"p-retry": "6.2.0",
|
||||||
"pagination.djs": "^4.0.10",
|
"pagination.djs": "^4.0.10",
|
||||||
"parse-duration": "1.0.2",
|
"parse-duration": "1.0.2",
|
||||||
|
"patch-package": "^8.0.0",
|
||||||
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"read-pkg": "7.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-uri": "^3.0.2",
|
||||||
"spotify-web-api-node": "^5.0.2",
|
"spotify-web-api-node": "^5.0.2",
|
||||||
"sync-fetch": "^0.3.1",
|
"sync-fetch": "^0.3.1",
|
||||||
"tsx": "3.8.2",
|
"tsx": "3.8.2",
|
||||||
"xbytes": "^1.7.0",
|
"xbytes": "^1.7.0",
|
||||||
"ytdl-core": "git+https://github.com/khlevon/node-ytdl-core.git#v4.11.4-patch.2",
|
|
||||||
"ytsr": "^3.8.4"
|
"ytsr": "^3.8.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/ws": "8.5.4"
|
"@types/ws": "8.5.4"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,12 +24,18 @@ model KeyValueCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Setting {
|
model Setting {
|
||||||
guildId String @id
|
guildId String @id
|
||||||
playlistLimit Int @default(50)
|
playlistLimit Int @default(50)
|
||||||
secondsToWaitAfterQueueEmpties Int @default(30)
|
secondsToWaitAfterQueueEmpties Int @default(30)
|
||||||
leaveIfNoListeners Boolean @default(true)
|
leaveIfNoListeners Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
queueAddResponseEphemeral Boolean @default(false)
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model FavoriteQuery {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class implements Command {
|
export default class implements Command {
|
||||||
|
|
|
@ -33,11 +33,62 @@ export default class implements Command {
|
||||||
.setName('value')
|
.setName('value')
|
||||||
.setDescription('whether to leave when everyone else leaves')
|
.setDescription('whether to leave when everyone else leaves')
|
||||||
.setRequired(true)))
|
.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
|
.addSubcommand(subcommand => subcommand
|
||||||
.setName('get')
|
.setName('get')
|
||||||
.setDescription('show all settings'));
|
.setDescription('show all settings'));
|
||||||
|
|
||||||
async execute(interaction: ChatInputCommandInteraction) {
|
async execute(interaction: ChatInputCommandInteraction) {
|
||||||
|
// Ensure guild settings exist before trying to update
|
||||||
|
await getGuildSettings(interaction.guild!.id);
|
||||||
|
|
||||||
switch (interaction.options.getSubcommand()) {
|
switch (interaction.options.getSubcommand()) {
|
||||||
case 'set-playlist-limit': {
|
case 'set-playlist-limit': {
|
||||||
const limit: number = interaction.options.getInteger('limit')!;
|
const limit: number = interaction.options.getInteger('limit')!;
|
||||||
|
@ -94,6 +145,108 @@ export default class implements Command {
|
||||||
break;
|
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': {
|
case 'get': {
|
||||||
const embed = new EmbedBuilder().setTitle('Config');
|
const embed = new EmbedBuilder().setTitle('Config');
|
||||||
|
|
||||||
|
@ -105,6 +258,11 @@ export default class implements Command {
|
||||||
? 'never leave'
|
? 'never leave'
|
||||||
: `${config.secondsToWaitAfterQueueEmpties}s`,
|
: `${config.secondsToWaitAfterQueueEmpties}s`,
|
||||||
'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no',
|
'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 = '';
|
let description = '';
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class implements Command {
|
export default class implements Command {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {APIEmbedField, AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
|
import {APIEmbedField, AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import AddQueryToQueue from '../services/add-query-to-queue.js';
|
import AddQueryToQueue from '../services/add-query-to-queue.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {prisma} from '../utils/db.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'))
|
.setDescription('shuffle the input if you\'re adding multiple tracks'))
|
||||||
.addBooleanOption(option => option
|
.addBooleanOption(option => option
|
||||||
.setName('split')
|
.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
|
.addSubcommand(subcommand => subcommand
|
||||||
.setName('list')
|
.setName('list')
|
||||||
.setDescription('list all favorites'))
|
.setDescription('list all favorites'))
|
||||||
|
@ -124,6 +127,7 @@ export default class implements Command {
|
||||||
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
|
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
|
||||||
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
|
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
|
||||||
shouldSplitChapters: interaction.options.getBoolean('split') ?? 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 {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {prettyTime} from '../utils/time.js';
|
import {prettyTime} from '../utils/time.js';
|
||||||
import durationStringToSeconds from '../utils/duration-string-to-seconds.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 {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {STATUS} from '../services/player.js';
|
import {STATUS} from '../services/player.js';
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {STATUS} from '../services/player.js';
|
import {STATUS} from '../services/player.js';
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import {STATUS} from '../services/player.js';
|
import {STATUS} from '../services/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class implements Command {
|
export default class implements Command {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
|
import {AutocompleteInteraction, ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {URL} from 'url';
|
import {URL} from 'url';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder} from '@discordjs/builders';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable, optional} from 'inversify';
|
||||||
import Spotify from 'spotify-web-api-node';
|
import Spotify from 'spotify-web-api-node';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import ThirdParty from '../services/third-party.js';
|
import ThirdParty from '../services/third-party.js';
|
||||||
import getYouTubeAndSpotifySuggestionsFor from '../utils/get-youtube-and-spotify-suggestions-for.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()
|
@injectable()
|
||||||
export default class implements Command {
|
export default class implements Command {
|
||||||
public readonly slashCommand = new SlashCommandBuilder()
|
public readonly slashCommand: Partial<SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder> & Pick<SlashCommandBuilder, 'toJSON'>;
|
||||||
.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 requiresVC = true;
|
public requiresVC = true;
|
||||||
|
|
||||||
private readonly spotify: Spotify;
|
private readonly spotify?: Spotify;
|
||||||
private readonly cache: KeyValueCacheProvider;
|
private readonly cache: KeyValueCacheProvider;
|
||||||
private readonly addQueryToQueue: AddQueryToQueue;
|
private readonly addQueryToQueue: AddQueryToQueue;
|
||||||
|
|
||||||
constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
|
constructor(@inject(TYPES.ThirdParty) @optional() thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.Services.AddQueryToQueue) addQueryToQueue: AddQueryToQueue) {
|
||||||
this.spotify = thirdParty.spotify;
|
this.spotify = thirdParty?.spotify;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.addQueryToQueue = addQueryToQueue;
|
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> {
|
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
@ -52,6 +61,7 @@ export default class implements Command {
|
||||||
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
|
addToFrontOfQueue: interaction.options.getBoolean('immediate') ?? false,
|
||||||
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
|
shuffleAdditions: interaction.options.getBoolean('shuffle') ?? false,
|
||||||
shouldSplitChapters: interaction.options.getBoolean('split') ?? 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 {inject, injectable} from 'inversify';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {buildQueueEmbed} from '../utils/build-embed.js';
|
import {buildQueueEmbed} from '../utils/build-embed.js';
|
||||||
|
import {getGuildSettings} from '../utils/get-guild-settings.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class implements Command {
|
export default class implements Command {
|
||||||
|
@ -14,6 +15,12 @@ export default class implements Command {
|
||||||
.addIntegerOption(option => option
|
.addIntegerOption(option => option
|
||||||
.setName('page')
|
.setName('page')
|
||||||
.setDescription('page of queue to show [default: 1]')
|
.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));
|
.setRequired(false));
|
||||||
|
|
||||||
private readonly playerManager: PlayerManager;
|
private readonly playerManager: PlayerManager;
|
||||||
|
@ -23,9 +30,17 @@ export default class implements Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execute(interaction: ChatInputCommandInteraction) {
|
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]});
|
await interaction.reply({embeds: [embed]});
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import {STATUS} from '../services/player.js';
|
import {STATUS} from '../services/player.js';
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {parseTime, prettyTime} from '../utils/time.js';
|
import {parseTime, prettyTime} from '../utils/time.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import durationStringToSeconds from '../utils/duration-string-to-seconds.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 {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import {STATUS} from '../services/player.js';
|
import {STATUS} from '../services/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class implements Command {
|
export default class implements Command {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {ChatInputCommandInteraction} from 'discord.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import PlayerManager from '../managers/player.js';
|
import PlayerManager from '../managers/player.js';
|
||||||
import Command from '.';
|
import Command from './index.js';
|
||||||
import {SlashCommandBuilder} from '@discordjs/builders';
|
import {SlashCommandBuilder} from '@discordjs/builders';
|
||||||
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
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 {Client, Guild} from 'discord.js';
|
||||||
import container from '../inversify.config.js';
|
import container from '../inversify.config.js';
|
||||||
import Command from '../commands';
|
import Command from '../commands/index.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import Config from '../services/config.js';
|
import Config from '../services/config.js';
|
||||||
import {prisma} from '../utils/db.js';
|
import {prisma} from '../utils/db.js';
|
||||||
|
@ -40,5 +40,5 @@ export default async (guild: Guild): Promise<void> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = await guild.fetchOwner();
|
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';
|
import SpotifyAPI from './services/spotify-api.js';
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
import Command from './commands';
|
import Command from './commands/index.js';
|
||||||
import Clear from './commands/clear.js';
|
import Clear from './commands/clear.js';
|
||||||
import Config from './commands/config.js';
|
import Config from './commands/config.js';
|
||||||
import Disconnect from './commands/disconnect.js';
|
import Disconnect from './commands/disconnect.js';
|
||||||
|
@ -37,6 +37,7 @@ import Shuffle from './commands/shuffle.js';
|
||||||
import Skip from './commands/skip.js';
|
import Skip from './commands/skip.js';
|
||||||
import Stop from './commands/stop.js';
|
import Stop from './commands/stop.js';
|
||||||
import Unskip from './commands/unskip.js';
|
import Unskip from './commands/unskip.js';
|
||||||
|
import Volume from './commands/volume.js';
|
||||||
import ThirdParty from './services/third-party.js';
|
import ThirdParty from './services/third-party.js';
|
||||||
import FileCacheProvider from './services/file-cache.js';
|
import FileCacheProvider from './services/file-cache.js';
|
||||||
import KeyValueCacheProvider from './services/key-value-cache.js';
|
import KeyValueCacheProvider from './services/key-value-cache.js';
|
||||||
|
@ -56,11 +57,20 @@ container.bind<Client>(TYPES.Client).toConstantValue(new Client({intents}));
|
||||||
// Managers
|
// Managers
|
||||||
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
|
container.bind<PlayerManager>(TYPES.Managers.Player).to(PlayerManager).inSingletonScope();
|
||||||
|
|
||||||
|
// Config values
|
||||||
|
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
|
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
|
||||||
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
|
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
|
||||||
container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).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
|
// Commands
|
||||||
[
|
[
|
||||||
|
@ -85,16 +95,12 @@ container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton
|
||||||
Skip,
|
Skip,
|
||||||
Stop,
|
Stop,
|
||||||
Unskip,
|
Unskip,
|
||||||
|
Volume,
|
||||||
].forEach(command => {
|
].forEach(command => {
|
||||||
container.bind<Command>(TYPES.Command).to(command).inSingletonScope();
|
container.bind<Command>(TYPES.Command).to(command).inSingletonScope();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Config values
|
|
||||||
container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
|
|
||||||
|
|
||||||
// Static libraries
|
// Static libraries
|
||||||
container.bind(TYPES.ThirdParty).to(ThirdParty);
|
|
||||||
|
|
||||||
container.bind(TYPES.FileCache).to(FileCacheProvider);
|
container.bind(TYPES.FileCache).to(FileCacheProvider);
|
||||||
container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider);
|
container.bind(TYPES.KeyValueCache).to(KeyValueCacheProvider);
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,35 @@
|
||||||
/* eslint-disable complexity */
|
/* eslint-disable complexity */
|
||||||
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
|
import {ChatInputCommandInteraction, GuildMember} from 'discord.js';
|
||||||
import {URL} from 'node:url';
|
|
||||||
import {inject, injectable} from 'inversify';
|
import {inject, injectable} from 'inversify';
|
||||||
import shuffle from 'array-shuffle';
|
import shuffle from 'array-shuffle';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import GetSongs from '../services/get-songs.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 PlayerManager from '../managers/player.js';
|
||||||
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
||||||
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
|
import {getMemberVoiceChannel, getMostPopularVoiceChannel} from '../utils/channels.js';
|
||||||
import {getGuildSettings} from '../utils/get-guild-settings.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()
|
@injectable()
|
||||||
export default class AddQueryToQueue {
|
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({
|
public async addToQueue({
|
||||||
|
@ -21,12 +37,14 @@ export default class AddQueryToQueue {
|
||||||
addToFrontOfQueue,
|
addToFrontOfQueue,
|
||||||
shuffleAdditions,
|
shuffleAdditions,
|
||||||
shouldSplitChapters,
|
shouldSplitChapters,
|
||||||
|
skipCurrentTrack,
|
||||||
interaction,
|
interaction,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
addToFrontOfQueue: boolean;
|
addToFrontOfQueue: boolean;
|
||||||
shuffleAdditions: boolean;
|
shuffleAdditions: boolean;
|
||||||
shouldSplitChapters: boolean;
|
shouldSplitChapters: boolean;
|
||||||
|
skipCurrentTrack: boolean;
|
||||||
interaction: ChatInputCommandInteraction;
|
interaction: ChatInputCommandInteraction;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const guildId = interaction.guild!.id;
|
const guildId = interaction.guild!.id;
|
||||||
|
@ -37,78 +55,11 @@ export default class AddQueryToQueue {
|
||||||
|
|
||||||
const settings = await getGuildSettings(guildId);
|
const settings = await getGuildSettings(guildId);
|
||||||
|
|
||||||
const {playlistLimit} = settings;
|
const {playlistLimit, queueAddResponseEphemeral} = settings;
|
||||||
|
|
||||||
await interaction.deferReply();
|
await interaction.deferReply({ephemeral: queueAddResponseEphemeral});
|
||||||
|
|
||||||
let newSongs: SongMetadata[] = [];
|
let [newSongs, extraMsg] = await this.getSongs.getSongs(query, playlistLimit, shouldSplitChapters);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newSongs.length === 0) {
|
if (newSongs.length === 0) {
|
||||||
throw new Error('no songs found');
|
throw new Error('no songs found');
|
||||||
|
@ -118,6 +69,10 @@ export default class AddQueryToQueue {
|
||||||
newSongs = shuffle(newSongs);
|
newSongs = shuffle(newSongs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.config.ENABLE_SPONSORBLOCK) {
|
||||||
|
newSongs = await Promise.all(newSongs.map(this.skipNonMusicSegments.bind(this)));
|
||||||
|
}
|
||||||
|
|
||||||
newSongs.forEach(song => {
|
newSongs.forEach(song => {
|
||||||
player.add({
|
player.add({
|
||||||
...song,
|
...song,
|
||||||
|
@ -148,6 +103,14 @@ export default class AddQueryToQueue {
|
||||||
await player.play();
|
await player.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skipCurrentTrack) {
|
||||||
|
try {
|
||||||
|
await player.forward(1);
|
||||||
|
} catch (_: unknown) {
|
||||||
|
throw new Error('no song to skip to');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build response message
|
// Build response message
|
||||||
if (statusMsg !== '') {
|
if (statusMsg !== '') {
|
||||||
if (extraMsg === '') {
|
if (extraMsg === '') {
|
||||||
|
@ -164,7 +127,69 @@ export default class AddQueryToQueue {
|
||||||
if (newSongs.length === 1) {
|
if (newSongs.length === 1) {
|
||||||
await interaction.editReply(`**${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`);
|
await interaction.editReply(`**${firstSong.title}** added to the${addToFrontOfQueue ? ' front of the' : ''} queue${extraMsg}`);
|
||||||
} else {
|
} 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 xbytes from 'xbytes';
|
||||||
import {ConditionalKeys} from 'type-fest';
|
import {ConditionalKeys} from 'type-fest';
|
||||||
import {ActivityType, PresenceStatusData} from 'discord.js';
|
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');
|
export const DATA_DIR = path.resolve(process.env.DATA_DIR ? process.env.DATA_DIR : './data');
|
||||||
|
|
||||||
const CONFIG_MAP = {
|
const CONFIG_MAP = {
|
||||||
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
|
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
|
||||||
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
|
YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY,
|
||||||
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID,
|
SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID ?? '',
|
||||||
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET,
|
SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET ?? '',
|
||||||
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
|
REGISTER_COMMANDS_ON_BOT: process.env.REGISTER_COMMANDS_ON_BOT === 'true',
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
CACHE_DIR: path.join(DATA_DIR, 'cache'),
|
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_TYPE: process.env.BOT_ACTIVITY_TYPE ?? 'LISTENING',
|
||||||
BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '',
|
BOT_ACTIVITY_URL: process.env.BOT_ACTIVITY_URL ?? '',
|
||||||
BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music',
|
BOT_ACTIVITY: process.env.BOT_ACTIVITY ?? 'music',
|
||||||
|
ENABLE_SPONSORBLOCK: process.env.ENABLE_SPONSORBLOCK === 'true',
|
||||||
|
SPONSORBLOCK_TIMEOUT: process.env.ENABLE_SPONSORBLOCK ?? 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const BOT_ACTIVITY_TYPE_MAP = {
|
const BOT_ACTIVITY_TYPE_MAP = {
|
||||||
|
@ -45,6 +47,8 @@ export default class Config {
|
||||||
readonly BOT_ACTIVITY_TYPE!: Exclude<ActivityType, ActivityType.Custom>;
|
readonly BOT_ACTIVITY_TYPE!: Exclude<ActivityType, ActivityType.Custom>;
|
||||||
readonly BOT_ACTIVITY_URL!: string;
|
readonly BOT_ACTIVITY_URL!: string;
|
||||||
readonly BOT_ACTIVITY!: string;
|
readonly BOT_ACTIVITY!: string;
|
||||||
|
readonly ENABLE_SPONSORBLOCK!: boolean;
|
||||||
|
readonly SPONSORBLOCK_TIMEOUT!: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
for (const [key, value] of Object.entries(CONFIG_MAP)) {
|
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 * as spotifyURI from 'spotify-uri';
|
||||||
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
|
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import YoutubeAPI from './youtube-api.js';
|
import YoutubeAPI from './youtube-api.js';
|
||||||
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
|
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
|
||||||
|
import {URL} from 'node:url';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export default class {
|
export default class {
|
||||||
private readonly youtubeAPI: YoutubeAPI;
|
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.youtubeAPI = youtubeAPI;
|
||||||
this.spotifyAPI = spotifyAPI;
|
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);
|
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);
|
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);
|
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);
|
const parsed = spotifyURI.parse(url);
|
||||||
|
|
||||||
switch (parsed.type) {
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
ffmpeg(url).ffprobe((err, _) => {
|
ffmpeg(url).ffprobe((err, _) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import {VoiceChannel, Snowflake} from 'discord.js';
|
import {VoiceChannel, Snowflake} from 'discord.js';
|
||||||
import {Readable} from 'stream';
|
import {Readable} from 'stream';
|
||||||
import hasha from 'hasha';
|
import hasha from 'hasha';
|
||||||
import ytdl, {videoFormat} from 'ytdl-core';
|
import ytdl, {videoFormat} from '@distube/ytdl-core';
|
||||||
import {WriteStream} from 'fs-capacitor';
|
import {WriteStream} from 'fs-capacitor';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import shuffle from 'array-shuffle';
|
import shuffle from 'array-shuffle';
|
||||||
import {
|
import {
|
||||||
AudioPlayer,
|
AudioPlayer,
|
||||||
AudioPlayerState,
|
AudioPlayerState,
|
||||||
AudioPlayerStatus,
|
AudioPlayerStatus, AudioResource,
|
||||||
createAudioPlayer,
|
createAudioPlayer,
|
||||||
createAudioResource, DiscordGatewayAdapterCreator,
|
createAudioResource, DiscordGatewayAdapterCreator,
|
||||||
joinVoiceChannel,
|
joinVoiceChannel,
|
||||||
|
@ -19,6 +19,8 @@ import {
|
||||||
import FileCacheProvider from './file-cache.js';
|
import FileCacheProvider from './file-cache.js';
|
||||||
import debug from '../utils/debug.js';
|
import debug from '../utils/debug.js';
|
||||||
import {getGuildSettings} from '../utils/get-guild-settings.js';
|
import {getGuildSettings} from '../utils/get-guild-settings.js';
|
||||||
|
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
|
||||||
|
import {Setting} from '@prisma/client';
|
||||||
|
|
||||||
export enum MediaSource {
|
export enum MediaSource {
|
||||||
Youtube,
|
Youtube,
|
||||||
|
@ -33,7 +35,7 @@ export interface QueuedPlaylist {
|
||||||
export interface SongMetadata {
|
export interface SongMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
url: string;
|
url: string; // For YT, it's the video ID (not the full URI)
|
||||||
length: number;
|
length: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
playlist: QueuedPlaylist | null;
|
playlist: QueuedPlaylist | null;
|
||||||
|
@ -58,16 +60,21 @@ export interface PlayerEvents {
|
||||||
|
|
||||||
type YTDLVideoFormat = videoFormat & {loudnessDb?: number};
|
type YTDLVideoFormat = videoFormat & {loudnessDb?: number};
|
||||||
|
|
||||||
|
export const DEFAULT_VOLUME = 100;
|
||||||
|
|
||||||
export default class {
|
export default class {
|
||||||
public voiceConnection: VoiceConnection | null = null;
|
public voiceConnection: VoiceConnection | null = null;
|
||||||
public status = STATUS.PAUSED;
|
public status = STATUS.PAUSED;
|
||||||
public guildId: string;
|
public guildId: string;
|
||||||
public loopCurrentSong = false;
|
public loopCurrentSong = false;
|
||||||
public loopCurrentQueue = false;
|
public loopCurrentQueue = false;
|
||||||
|
private currentChannel: VoiceChannel | undefined;
|
||||||
private queue: QueuedSong[] = [];
|
private queue: QueuedSong[] = [];
|
||||||
private queuePosition = 0;
|
private queuePosition = 0;
|
||||||
private audioPlayer: AudioPlayer | null = null;
|
private audioPlayer: AudioPlayer | null = null;
|
||||||
|
private audioResource: AudioResource | null = null;
|
||||||
|
private volume?: number;
|
||||||
|
private defaultVolume: number = DEFAULT_VOLUME;
|
||||||
private nowPlaying: QueuedSong | null = null;
|
private nowPlaying: QueuedSong | null = null;
|
||||||
private playPositionInterval: NodeJS.Timeout | undefined;
|
private playPositionInterval: NodeJS.Timeout | undefined;
|
||||||
private lastSongURL = '';
|
private lastSongURL = '';
|
||||||
|
@ -76,18 +83,28 @@ export default class {
|
||||||
private readonly fileCache: FileCacheProvider;
|
private readonly fileCache: FileCacheProvider;
|
||||||
private disconnectTimer: NodeJS.Timeout | null = null;
|
private disconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
private readonly channelToSpeakingUsers: Map<string, Set<string>> = new Map();
|
||||||
|
|
||||||
constructor(fileCache: FileCacheProvider, guildId: string) {
|
constructor(fileCache: FileCacheProvider, guildId: string) {
|
||||||
this.fileCache = fileCache;
|
this.fileCache = fileCache;
|
||||||
this.guildId = guildId;
|
this.guildId = guildId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(channel: VoiceChannel): Promise<void> {
|
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({
|
this.voiceConnection = joinVoiceChannel({
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
guildId: channel.guild.id,
|
guildId: channel.guild.id,
|
||||||
|
selfDeaf: false,
|
||||||
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator,
|
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const guildSettings = await getGuildSettings(this.guildId);
|
||||||
|
|
||||||
// Workaround to disable keepAlive
|
// Workaround to disable keepAlive
|
||||||
this.voiceConnection.on('stateChange', (oldState, newState) => {
|
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 */
|
/* 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);
|
oldNetworking?.off('stateChange', networkStateChangeHandler);
|
||||||
newNetworking?.on('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 */
|
/* 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.loopCurrentSong = false;
|
||||||
this.voiceConnection.destroy();
|
this.voiceConnection.destroy();
|
||||||
this.audioPlayer?.stop();
|
this.audioPlayer?.stop(true);
|
||||||
|
|
||||||
this.voiceConnection = null;
|
this.voiceConnection = null;
|
||||||
this.audioPlayer = null;
|
this.audioPlayer = null;
|
||||||
|
this.audioResource = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,9 +175,7 @@ export default class {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.voiceConnection.subscribe(this.audioPlayer);
|
this.voiceConnection.subscribe(this.audioPlayer);
|
||||||
this.audioPlayer.play(createAudioResource(stream, {
|
this.playAudioPlayerResource(this.createAudioStream(stream));
|
||||||
inputType: StreamType.WebmOpus,
|
|
||||||
}));
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
this.startTrackingPosition(positionSeconds);
|
this.startTrackingPosition(positionSeconds);
|
||||||
|
|
||||||
|
@ -217,11 +238,7 @@ export default class {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.voiceConnection.subscribe(this.audioPlayer);
|
this.voiceConnection.subscribe(this.audioPlayer);
|
||||||
const resource = createAudioResource(stream, {
|
this.playAudioPlayerResource(this.createAudioStream(stream));
|
||||||
inputType: StreamType.WebmOpus,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.audioPlayer.play(resource);
|
|
||||||
|
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
|
|
||||||
|
@ -272,8 +289,8 @@ export default class {
|
||||||
if (this.getCurrent() && this.status !== STATUS.PAUSED) {
|
if (this.getCurrent() && this.status !== STATUS.PAUSED) {
|
||||||
await this.play();
|
await this.play();
|
||||||
} else {
|
} else {
|
||||||
this.audioPlayer?.stop();
|
|
||||||
this.status = STATUS.IDLE;
|
this.status = STATUS.IDLE;
|
||||||
|
this.audioPlayer?.stop(true);
|
||||||
|
|
||||||
const settings = await getGuildSettings(this.guildId);
|
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) {
|
canGoForward(skip: number) {
|
||||||
return (this.queuePosition + skip - 1) < this.queue.length;
|
return (this.queuePosition + skip - 1) < this.queue.length;
|
||||||
}
|
}
|
||||||
|
@ -405,11 +479,28 @@ export default class {
|
||||||
return this.queue[this.queuePosition + to];
|
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 {
|
private getHashForCache(url: string): string {
|
||||||
return hasha(url);
|
return hasha(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStream(song: QueuedSong, options: {seek?: number; to?: number} = {}): Promise<Readable> {
|
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) {
|
if (song.source === MediaSource.HLS) {
|
||||||
return this.createReadStream({url: song.url, cacheKey: song.url});
|
return this.createReadStream({url: song.url, cacheKey: song.url});
|
||||||
}
|
}
|
||||||
|
@ -433,6 +524,10 @@ export default class {
|
||||||
format = formats.find(filter);
|
format = formats.find(filter);
|
||||||
|
|
||||||
const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat | undefined => {
|
const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat | undefined => {
|
||||||
|
if (formats.length < 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (formats[0].isLive) {
|
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
|
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) {
|
if (newState.status === AudioPlayerStatus.Idle && this.status === STATUS.PLAYING) {
|
||||||
await this.forward(1);
|
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 => {
|
.on('start', command => {
|
||||||
debug(`Spawned ffmpeg with ${command as string}`);
|
debug(`Spawned ffmpeg with ${command}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.pipe(capacitor);
|
stream.pipe(capacitor);
|
||||||
|
|
||||||
returnedStream.on('close', () => {
|
returnedStream.on('close', () => {
|
||||||
stream.kill('SIGKILL');
|
if (!options.cache) {
|
||||||
|
stream.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
|
||||||
hasReturnedStreamClosed = true;
|
hasReturnedStreamClosed = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve(returnedStream);
|
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 {inject, injectable} from 'inversify';
|
||||||
import {toSeconds, parse} from 'iso8601-duration';
|
import {toSeconds, parse} from 'iso8601-duration';
|
||||||
import got, {Got} from 'got';
|
import got, {Got} from 'got';
|
||||||
import ytsr, {Video} from 'ytsr';
|
import ytsr, {Video} from '@distube/ytsr';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
|
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
|
||||||
import {TYPES} from '../types.js';
|
import {TYPES} from '../types.js';
|
||||||
|
@ -74,7 +74,7 @@ export default class {
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
|
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,
|
ytsr,
|
||||||
query,
|
query,
|
||||||
{
|
{
|
||||||
|
@ -85,9 +85,13 @@ export default class {
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if (result === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
let firstVideo: Video | undefined;
|
let firstVideo: Video | undefined;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of result.items) {
|
||||||
if (item.type === 'video') {
|
if (item.type === 'video') {
|
||||||
firstVideo = item;
|
firstVideo = item;
|
||||||
break;
|
break;
|
||||||
|
@ -95,7 +99,7 @@ export default class {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firstVideo) {
|
if (!firstVideo) {
|
||||||
throw new Error('No video found.');
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getVideo(firstVideo.url, shouldSplitChapters);
|
return this.getVideo(firstVideo.url, shouldSplitChapters);
|
||||||
|
|
|
@ -5,8 +5,6 @@ import getProgressBar from './get-progress-bar.js';
|
||||||
import {prettyTime} from './time.js';
|
import {prettyTime} from './time.js';
|
||||||
import {truncate} from './string.js';
|
import {truncate} from './string.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
const getMaxSongTitleLength = (title: string) => {
|
const getMaxSongTitleLength = (title: string) => {
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const nonASCII = /[^\x00-\x7F]+/;
|
const nonASCII = /[^\x00-\x7F]+/;
|
||||||
|
@ -44,10 +42,11 @@ const getPlayerUI = (player: Player) => {
|
||||||
|
|
||||||
const position = player.getPosition();
|
const position = player.getPosition();
|
||||||
const button = player.status === STATUS.PLAYING ? '⏹️' : '▶️';
|
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 elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`;
|
||||||
const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : '';
|
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 => {
|
export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
|
||||||
|
@ -76,7 +75,7 @@ export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {
|
||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildQueueEmbed = (player: Player, page: number): EmbedBuilder => {
|
export const buildQueueEmbed = (player: Player, page: number, pageSize: number): EmbedBuilder => {
|
||||||
const currentlyPlaying = player.getCurrent();
|
const currentlyPlaying = player.getCurrent();
|
||||||
|
|
||||||
if (!currentlyPlaying) {
|
if (!currentlyPlaying) {
|
||||||
|
@ -84,14 +83,14 @@ export const buildQueueEmbed = (player: Player, page: number): EmbedBuilder => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueSize = player.queueSize();
|
const queueSize = player.queueSize();
|
||||||
const maxQueuePage = Math.ceil((queueSize + 1) / PAGE_SIZE);
|
const maxQueuePage = Math.ceil((queueSize + 1) / pageSize);
|
||||||
|
|
||||||
if (page > maxQueuePage) {
|
if (page > maxQueuePage) {
|
||||||
throw new Error('the queue isn\'t that big');
|
throw new Error('the queue isn\'t that big');
|
||||||
}
|
}
|
||||||
|
|
||||||
const queuePageBegin = (page - 1) * PAGE_SIZE;
|
const queuePageBegin = (page - 1) * pageSize;
|
||||||
const queuePageEnd = queuePageBegin + PAGE_SIZE;
|
const queuePageEnd = queuePageBegin + pageSize;
|
||||||
const queuedSongs = player
|
const queuedSongs = player
|
||||||
.getQueue()
|
.getQueue()
|
||||||
.slice(queuePageBegin, queuePageEnd)
|
.slice(queuePageBegin, queuePageEnd)
|
||||||
|
|
|
@ -14,28 +14,18 @@ const filterDuplicates = <T extends {name: string}>(items: T[]) => {
|
||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: SpotifyWebApi, limit = 10): Promise<APIApplicationCommandOptionChoice[]> => {
|
const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify?: SpotifyWebApi, limit = 10): Promise<APIApplicationCommandOptionChoice[]> => {
|
||||||
const [youtubeSuggestions, spotifyResults] = await Promise.all([
|
// Only search Spotify if enabled
|
||||||
getYouTubeSuggestionsFor(query),
|
const spotifySuggestionPromise = spotify === undefined
|
||||||
spotify.search(query, ['track', 'album'], {limit: 5}),
|
? undefined
|
||||||
]);
|
: spotify.search(query, ['album', 'track'], {limit});
|
||||||
|
|
||||||
|
const youtubeSuggestions = await getYouTubeSuggestionsFor(query);
|
||||||
|
|
||||||
const totalYouTubeResults = youtubeSuggestions.length;
|
const totalYouTubeResults = youtubeSuggestions.length;
|
||||||
|
const numOfYouTubeSuggestions = Math.min(limit, totalYouTubeResults);
|
||||||
|
|
||||||
const spotifyAlbums = filterDuplicates(spotifyResults.body.albums?.items ?? []);
|
let suggestions: APIApplicationCommandOptionChoice[] = [];
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
suggestions.push(
|
suggestions.push(
|
||||||
...youtubeSuggestions
|
...youtubeSuggestions
|
||||||
|
@ -46,23 +36,40 @@ const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: Spotif
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
const maxSpotifyAlbums = Math.floor(numOfSpotifySuggestions / 2);
|
if (spotify !== undefined && spotifySuggestionPromise !== undefined) {
|
||||||
const numOfSpotifyAlbums = Math.min(maxSpotifyAlbums, spotifyResults.body.albums?.items.length ?? 0);
|
const spotifyResponse = (await spotifySuggestionPromise).body;
|
||||||
const maxSpotifyTracks = numOfSpotifySuggestions - numOfSpotifyAlbums;
|
const spotifyAlbums = filterDuplicates(spotifyResponse.albums?.items ?? []);
|
||||||
|
const spotifyTracks = filterDuplicates(spotifyResponse.tracks?.items ?? []);
|
||||||
|
|
||||||
suggestions.push(
|
const totalSpotifyResults = spotifyAlbums.length + spotifyTracks.length;
|
||||||
...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(
|
// Number of results for each source should be roughly the same.
|
||||||
...spotifyTracks.slice(0, maxSpotifyTracks).map(track => ({
|
// If we don't have enough Spotify suggestions, prioritize YouTube results.
|
||||||
name: `Spotify: 🎵 ${track.name}${track.artists.length > 0 ? ` - ${track.artists[0].name}` : ''}`,
|
const maxSpotifySuggestions = Math.floor(limit / 2);
|
||||||
value: `spotify:track:${track.id}`,
|
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;
|
return suggestions;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import {REST} from '@discordjs/rest';
|
import {REST} from '@discordjs/rest';
|
||||||
import {Routes} from 'discord-api-types/v10';
|
import {Routes} from 'discord-api-types/v10';
|
||||||
import Command from '../commands';
|
import Command from '../commands/index.js';
|
||||||
|
|
||||||
interface RegisterCommandsOnGuildOptions {
|
interface RegisterCommandsOnGuildOptions {
|
||||||
rest: REST;
|
rest: REST;
|
||||||
|
|
|
@ -9,7 +9,8 @@
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue