Initial commit
Based on JMusicBot by jagrosh (https://github.com/jagrosh/MusicBot), via the maintained fork by arif-banai (https://github.com/arif-banai/MusicBot). See NOTICE for attribution. Licensed under Apache 2.0. YouTube-side changes had broken playback entirely. Fixes: - Apply the remote signature cipher independently of OAuth. Deciphering and authentication are unrelated, but the cipher was only configured when OAuth was enabled, so the default install fell back to the bundled extractor and failed with "Must find sig function from script: /s/player/<id>/...". Now always applied, and configurable via playback.youtube.remoteCipher.url. - Add the ANDROID client and order the client list by reliability. ANDROID is currently the only client YouTube reliably streams to anonymously. WEB moves last, as YouTube answers it with SABR-only responses carrying no playable format ("No supported audio streams available, available types:"). - Detect sign-in failures across the whole exception graph. The existing hint could never fire: it matched the aggregate AllClientsFailedException message rather than the per-client reasons, and compared against an ASCII apostrophe while YouTube sends U+2019. - Track youtube-source past 1.18.2, which predates the TV client User-Agent fix and the contentLength recovery fix. Measured on previously failing tracks: 5/5 playing, stable across repeat runs (was 1/5). No OAuth required. Adds 17 tests; 676 unit + 48 integration pass.
This commit is contained in:
commit
0941987234
295 files changed
+58207
No files matched your search
@@ -0,0 +1,56 @@
|
||||
version: 2.1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
- image: cimg/openjdk:25.0
|
||||
working_directory: ~/repo
|
||||
environment:
|
||||
MAVEN_OPTS: -Xmx3200m
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
# Restore Maven cache
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v2-m2-{{ checksum "pom.xml" }}
|
||||
- v2-m2-
|
||||
|
||||
# Download deps (good for warm cache; safe to keep)
|
||||
- run:
|
||||
name: Maven go-offline
|
||||
command: mvn -B -ntp dependency:go-offline
|
||||
|
||||
# Build + run unit tests + integration tests (Failsafe) if configured
|
||||
- run:
|
||||
name: Maven verify
|
||||
command: mvn -B -ntp verify
|
||||
|
||||
# Save Maven cache
|
||||
- save_cache:
|
||||
key: v2-m2-{{ checksum "pom.xml" }}
|
||||
paths:
|
||||
- ~/.m2
|
||||
|
||||
# Collect test reports if present (doesn't fail if empty)
|
||||
# Note: failsafe-reports contains failsafe-summary.xml which is not JUnit format,
|
||||
# so we copy only the actual test result files (TEST-*.xml) to a clean directory
|
||||
- run:
|
||||
name: Uploading test results
|
||||
when: always
|
||||
command: |
|
||||
mkdir -p target/test-results/surefire target/test-results/failsafe
|
||||
cp target/surefire-reports/TEST-*.xml target/test-results/surefire/ 2>/dev/null || true
|
||||
cp target/failsafe-reports/TEST-*.xml target/test-results/failsafe/ 2>/dev/null || true
|
||||
- store_test_results:
|
||||
path: target/test-results
|
||||
|
||||
workflows:
|
||||
build:
|
||||
jobs:
|
||||
- build:
|
||||
filters:
|
||||
branches:
|
||||
ignore:
|
||||
- gh-pages
|
||||
- gh-pages-generated
|
||||
@@ -0,0 +1,70 @@
|
||||
# Build artifacts (Maven will rebuild these)
|
||||
target/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
.circleci/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.cursor/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
|
||||
# Test files (not needed for build)
|
||||
src/test/
|
||||
|
||||
# Documentation (not needed in image)
|
||||
docs/
|
||||
README.md
|
||||
LICENSE
|
||||
*.md
|
||||
|
||||
# Docker files (not needed in image)
|
||||
docker-compose*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# User-specific config files
|
||||
config.txt
|
||||
serversettings.json
|
||||
*.json
|
||||
|
||||
# Playlists and user data
|
||||
Playlists/
|
||||
playlists/
|
||||
|
||||
# Scripts (not needed in image, except entrypoint)
|
||||
scripts/
|
||||
*.sh
|
||||
!docker/entrypoint.sh
|
||||
|
||||
# Maven files
|
||||
dependency-reduced-pom.xml
|
||||
.mvn/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Misc
|
||||
CNAME
|
||||
@@ -0,0 +1,13 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Shell scripts must use LF (Unix line endings)
|
||||
*.sh text eol=lf
|
||||
|
||||
# Batch files must use CRLF (Windows line endings)
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Docker files should use LF
|
||||
Dockerfile text eol=lf
|
||||
.dockerignore text eol=lf
|
||||
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at j@gro.sh. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: arif-banai
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug Report] Title Here "
|
||||
labels: []
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: Please provide a clear and concise description of what the bug is.
|
||||
placeholder: The bug is...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Provide steps you can take to reliably reproduce this bug
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Result
|
||||
description: What did you expect to happen when this bug occurred?
|
||||
placeholder: I expected...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: debug
|
||||
attributes:
|
||||
label: Debug Output
|
||||
description: Please run the `debug` command and paste it here or upload it as an attachment. If you cannot run the command, please provide information about your system such as operating system version, JMusicBot version, etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Info
|
||||
description: Any additional info that you think might be useful or relevant to this bug
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Before submitting your issue, please affirm the following
|
||||
options:
|
||||
- label: I have looked for information about this [within the documentation](https://jmusicbot.com/)
|
||||
required: true
|
||||
- label: I have searched for similar issues [on the issues page](https://github.com/arif-banai/MusicBot/issues?q=is%3Aissue)
|
||||
required: true
|
||||
- label: I am using an official release from [the releases page](https://github.com/arif-banai/MusicBot/releases)
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: JMusicBot Version
|
||||
description: Which version of JMusicBot are you running?
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question
|
||||
url: https://github.com/arif-banai/MusicBot/discussions/categories/general
|
||||
about: For general questions, please check the Discussions
|
||||
- name: Troubleshooting
|
||||
url: https://github.com/arif-banai/MusicBot/discussions/categories/help
|
||||
about: For troubleshooting help, please check the Discussions
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature Request] Title Here"
|
||||
labels: []
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: Please provide a clear and concise description of what the problem is.
|
||||
placeholder: A common problem users face is...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: What is your ideal solution to the problem?
|
||||
description: Describe a clear and concise description of what you want to happen.
|
||||
placeholder: My solution to the above problem is...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: procedure
|
||||
attributes:
|
||||
label: How would this feature be used?
|
||||
description: Describe the basic flow/steps of using this feature.
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Info
|
||||
description: Any additional info that you think might be useful or relevant to this feature request
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Before submitting your issue, please affirm the following
|
||||
options:
|
||||
- label: I have checked [the documentation](https://jmusicbot.com/) to make sure this feature doesn't already exist
|
||||
required: true
|
||||
- label: I have searched for [similar feature requests](https://github.com/arif-banai/MusicBot/labels/enhancement)
|
||||
required: true
|
||||
- label: I have confirmed that my requests is **not** on the [list of things that will not be added](https://jmusicbot.com/things-that-wont-be-added)
|
||||
required: true
|
||||
- label: "I am running the latest version of the bot: "
|
||||
required: true
|
||||
@@ -0,0 +1,14 @@
|
||||
### This pull request...
|
||||
- [ ] Fixes a bug
|
||||
- [ ] Introduces a new feature
|
||||
- [ ] Improves an existing feature
|
||||
- [ ] Boosts code quality or performance
|
||||
|
||||
### Description
|
||||
Replace this with a description of the pull request
|
||||
|
||||
### Purpose
|
||||
Replace this with a description of the problem or use-case
|
||||
|
||||
### Relevant Issue(s)
|
||||
This PR closes issue #...
|
||||
@@ -0,0 +1,71 @@
|
||||
name: 'Docker Build and Push'
|
||||
description: 'Builds and pushes a multi-arch Docker image to GHCR with caching'
|
||||
|
||||
inputs:
|
||||
tags:
|
||||
description: 'Docker image tags (from metadata-action)'
|
||||
required: true
|
||||
annotations:
|
||||
description: 'OCI annotations from metadata-action (for multi-arch images)'
|
||||
required: true
|
||||
timestamp:
|
||||
description: 'Build timestamp in ISO 8601 format'
|
||||
required: true
|
||||
registry:
|
||||
description: 'Container registry (e.g., ghcr.io)'
|
||||
required: false
|
||||
default: 'ghcr.io'
|
||||
image-name:
|
||||
description: 'Image name (e.g., arif-banai/musicbot)'
|
||||
required: false
|
||||
default: 'arif-banai/musicbot'
|
||||
token:
|
||||
description: 'GitHub token for authentication'
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
digest:
|
||||
description: 'Docker image digest'
|
||||
value: ${{ steps.build.outputs.digest }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ inputs.registry }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.token }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ inputs.tags }}
|
||||
annotations: ${{ inputs.annotations }}
|
||||
cache-from: |
|
||||
type=gha
|
||||
type=registry,ref=${{ inputs.registry }}/${{ inputs.image-name }}:buildcache
|
||||
cache-to: |
|
||||
type=gha,mode=max
|
||||
type=registry,ref=${{ inputs.registry }}/${{ inputs.image-name }}:buildcache,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
BUILD_TIMESTAMP=${{ inputs.timestamp }}
|
||||
|
||||
- name: Image digest
|
||||
shell: bash
|
||||
run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}"
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Build and Test
|
||||
|
||||
# Build and test on PRs and master pushes
|
||||
# This workflow does NOT publish Docker images - that's handled by docker-publish.yml
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master # PRs targeting trunk
|
||||
push:
|
||||
branches:
|
||||
- master # Direct pushes to master
|
||||
paths-ignore:
|
||||
- '.github/**' # Ignore GitHub config changes (workflows, actions, templates, etc.)
|
||||
- 'README.md'
|
||||
- 'LICENSE'
|
||||
- 'CNAME'
|
||||
- 'docs/**'
|
||||
- '*.example.yml'
|
||||
- '*.example.yaml'
|
||||
- '.circleci/**' # If not using CircleCI, ignore its config
|
||||
|
||||
# Cancel in-progress runs for the same workflow and branch
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read # Only need to checkout repository
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Java Environment
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Build and Test with Coverage
|
||||
run: |
|
||||
COMMIT_TIME=$(git log -1 --format=%cI)
|
||||
mvn --batch-mode verify -Pcoverage -Dproject.build.outputTimestamp=$COMMIT_TIME
|
||||
|
||||
- name: Upload Coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
report_type: coverage
|
||||
files: target/site/jacoco/jacoco.xml,target/site/jacoco-it/jacoco.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
report_type: test_results
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
# Docker publishing workflow - gated by successful build-and-test completion
|
||||
# This ensures images are only published after tests pass on master
|
||||
on:
|
||||
# Triggered after successful build-and-test on master
|
||||
# Note: The branches filter checks workflow_run.head_branch, which is the source branch
|
||||
# of the triggering workflow. For PRs targeting master, head_branch is the PR source
|
||||
# branch (not master), so this filter already excludes PR-triggered builds. Only direct
|
||||
# pushes to master will have head_branch == 'master' and trigger this workflow.
|
||||
workflow_run:
|
||||
workflows: ["Build and Test"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
# Also trigger on version tags (v*) for releases
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Cancel in-progress runs for the same workflow and ref
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: arif-banai/musicbot
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
# Only run if the triggering workflow succeeded (for workflow_run events)
|
||||
# For tag pushes, always run
|
||||
if: |
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Required for pushing to GHCR
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
# For workflow_run events, checkout the commit that triggered the original workflow
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
fetch-depth: 0 # Full history needed for git commands
|
||||
|
||||
- name: Extract metadata
|
||||
id: metadata
|
||||
run: |
|
||||
# Extract project version from pom.xml
|
||||
POM_VERSION=$(grep -m 1 '<version>' pom.xml | sed 's/.*<version>\([^<]*\)<\/version>.*/\1/' | tr -d ' ')
|
||||
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||
TIMESTAMP=$(git log -1 --format=%cI)
|
||||
|
||||
# Determine if this is a tag build or master branch build
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then
|
||||
# For tag builds, use the git tag version (strip 'v' prefix)
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
IS_TAG_BUILD="true"
|
||||
else
|
||||
# For master builds, use pom.xml version
|
||||
VERSION="$POM_VERSION"
|
||||
IS_TAG_BUILD="false"
|
||||
fi
|
||||
|
||||
# Check if version is a SNAPSHOT
|
||||
if [[ "$VERSION" =~ -SNAPSHOT$ ]]; then
|
||||
IS_SNAPSHOT="true"
|
||||
else
|
||||
IS_SNAPSHOT="false"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "pom_version=$POM_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$SHA_SHORT" >> "$GITHUB_OUTPUT"
|
||||
echo "timestamp=$TIMESTAMP" >> "$GITHUB_OUTPUT"
|
||||
echo "is_tag_build=$IS_TAG_BUILD" >> "$GITHUB_OUTPUT"
|
||||
echo "is_snapshot=$IS_SNAPSHOT" >> "$GITHUB_OUTPUT"
|
||||
echo "Extracted version: $VERSION (pom: $POM_VERSION), SHA: $SHA_SHORT, is_tag: $IS_TAG_BUILD, is_snapshot: $IS_SNAPSHOT"
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
# For multi-arch images, annotations must be attached to the image index
|
||||
# GitHub Packages reads annotations from the index level for multi-arch images
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# Tag rules:
|
||||
# - For tag builds: version tag (from git tag) + sha tag
|
||||
# - For master builds: latest + sha + version (if not SNAPSHOT)
|
||||
tags: |
|
||||
type=raw,value=${{ steps.metadata.outputs.version }},enable=${{ steps.metadata.outputs.is_tag_build == 'true' }}
|
||||
type=raw,value=latest,enable=${{ steps.metadata.outputs.is_tag_build == 'false' }}
|
||||
type=raw,value=${{ steps.metadata.outputs.version }},enable=${{ steps.metadata.outputs.is_tag_build == 'false' && steps.metadata.outputs.is_snapshot == 'false' }}
|
||||
type=raw,value=sha-${{ steps.metadata.outputs.sha }}
|
||||
# Labels are automatically generated from git context by metadata-action
|
||||
# No need to specify them - metadata-action will create standard OCI labels
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: ./.github/actions/docker-build
|
||||
with:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
timestamp: ${{ steps.metadata.outputs.timestamp }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Image digest
|
||||
run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}"
|
||||
@@ -0,0 +1,153 @@
|
||||
name: Make Release
|
||||
|
||||
# Manual workflow to create a GitHub release
|
||||
# This sets the version, builds the JAR, and creates a release with tag
|
||||
# The tag will trigger docker-publish.yml to build and push the Docker image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_number:
|
||||
description: 'Version Number (e.g., 0.6.3)'
|
||||
required: true
|
||||
type: string
|
||||
info:
|
||||
description: 'Description of this Release'
|
||||
required: true
|
||||
type: string
|
||||
is_prerelease:
|
||||
description: 'Mark this as a pre-release (for testing)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
ref:
|
||||
description: 'Branch, tag, or SHA to build from (defaults to the branch this workflow is run from)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
# Prevent concurrent releases
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build_jar:
|
||||
name: Build JAR
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write # Required to push version commit
|
||||
outputs:
|
||||
release_sha: ${{ steps.set-release-sha.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
fetch-depth: 0 # Full history needed for git commands
|
||||
|
||||
- name: Validate branch for regular releases
|
||||
if: ${{ github.event.inputs.is_prerelease != 'true' }}
|
||||
run: |
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$BRANCH" != "master" ]; then
|
||||
echo "❌ Error: Regular releases can only be created from the 'master' branch."
|
||||
echo " Current branch: $BRANCH"
|
||||
echo " To create a pre-release from this branch, check the 'Mark this as a pre-release' option."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Branch validation passed: $BRANCH"
|
||||
|
||||
- name: Set up Java Environment
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Get initial SHA (for pre-releases)
|
||||
id: get-initial-sha
|
||||
if: ${{ github.event.inputs.is_prerelease == 'true' }}
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set Version
|
||||
run: |
|
||||
mvn -B versions:set -DnewVersion=${{ github.event.inputs.version_number }}
|
||||
mvn -B versions:commit
|
||||
|
||||
- name: Commit version bump
|
||||
if: ${{ github.event.inputs.is_prerelease != 'true' }}
|
||||
id: commit-version
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Stage whatever the version plugin changed
|
||||
git add pom.xml
|
||||
|
||||
# If nothing is staged, don't fail the workflow
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes to commit (pom.xml already at ${{ github.event.inputs.version_number }})."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: bump version to ${{ github.event.inputs.version_number }}"
|
||||
git push
|
||||
|
||||
|
||||
- name: Set release SHA output
|
||||
id: set-release-sha
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.is_prerelease }}" == "true" ]; then
|
||||
echo "sha=${{ steps.get-initial-sha.outputs.sha }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build with Maven
|
||||
run: |
|
||||
COMMIT_TIME=$(git log -1 --format=%cI)
|
||||
mvn --batch-mode verify -Dproject.build.outputTimestamp=$COMMIT_TIME
|
||||
|
||||
- name: Rename JAR
|
||||
run: mv target/*-All.jar JMusicBot-${{ github.event.inputs.version_number }}.jar
|
||||
|
||||
- name: Upload JAR artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: jar
|
||||
path: JMusicBot-${{ github.event.inputs.version_number }}.jar
|
||||
if-no-files-found: error
|
||||
|
||||
create_release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: build_jar
|
||||
permissions:
|
||||
contents: write # Required to create releases and tags
|
||||
steps:
|
||||
- name: Download JAR artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: jar
|
||||
path: .
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: v${{ github.event.inputs.version_number }}
|
||||
commit: ${{ needs.build_jar.outputs.release_sha }}
|
||||
name: v${{ github.event.inputs.version_number }}
|
||||
draft: true
|
||||
prerelease: ${{ github.event.inputs.is_prerelease }}
|
||||
artifacts: "*.jar"
|
||||
body: |
|
||||
${{ github.event.inputs.info }}
|
||||
|
||||
---
|
||||
### Setup
|
||||
https://jmusicbot.com/setup
|
||||
https://jmusicbot.com/config
|
||||
# Download: [JMusicBot-${{ github.event.inputs.version_number }}.jar](https://github.com/arif-banai/MusicBot/releases/download/v${{ github.event.inputs.version_number }}/JMusicBot-${{ github.event.inputs.version_number }}.jar)
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Publish Preview Image
|
||||
|
||||
# Optional workflow for manually publishing preview Docker images from any branch/tag/SHA
|
||||
# This is separate from the main CI/CD pipeline and allows explicit preview builds
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to build (branch name, tag, or SHA)'
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: 'Optional tag suffix override (if empty, derived from ref)'
|
||||
required: false
|
||||
type: string
|
||||
run_tests:
|
||||
description: 'Run Maven tests before building Docker image'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# Cancel in-progress runs for the same workflow and ref
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.inputs.ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: arif-banai/musicbot
|
||||
|
||||
jobs:
|
||||
publish-preview:
|
||||
name: Publish Preview Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Required for pushing to GHCR
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
fetch-depth: 0 # Full history needed for git commands
|
||||
|
||||
- name: Extract metadata
|
||||
id: metadata
|
||||
run: |
|
||||
# Extract project version from pom.xml
|
||||
VERSION=$(grep -m 1 '<version>' pom.xml | sed 's/.*<version>\([^<]*\)<\/version>.*/\1/' | tr -d ' ')
|
||||
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||
TIMESTAMP=$(git log -1 --format=%cI)
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$SHA_SHORT" >> "$GITHUB_OUTPUT"
|
||||
echo "timestamp=$TIMESTAMP" >> "$GITHUB_OUTPUT"
|
||||
echo "Extracted version: $VERSION, SHA: $SHA_SHORT"
|
||||
|
||||
- name: Set up Java Environment
|
||||
# Run if run_tests is true (default) or explicitly set to true
|
||||
if: ${{ github.event.inputs.run_tests != 'false' }}
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Run Maven tests
|
||||
# Run if run_tests is true (default) or explicitly set to true
|
||||
if: ${{ github.event.inputs.run_tests != 'false' }}
|
||||
run: |
|
||||
COMMIT_TIME=$(git log -1 --format=%cI)
|
||||
mvn --batch-mode verify -Pdocker -Dproject.build.outputTimestamp=$COMMIT_TIME
|
||||
|
||||
- name: Sanitize tag suffix
|
||||
id: sanitize
|
||||
run: |
|
||||
# Determine the tag suffix: use input.tag if provided, otherwise derive from ref
|
||||
if [[ -n "${{ github.event.inputs.tag }}" ]]; then
|
||||
TAG_SUFFIX="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
# Derive from ref: strip refs/heads/, refs/tags/, refs/ prefixes
|
||||
REF="${{ github.event.inputs.ref }}"
|
||||
REF="${REF#refs/heads/}"
|
||||
REF="${REF#refs/tags/}"
|
||||
REF="${REF#refs/}"
|
||||
TAG_SUFFIX="$REF"
|
||||
fi
|
||||
|
||||
# Sanitize: lowercase, replace '/' with '-', strip invalid chars to [a-z0-9._-]
|
||||
# Truncate to 50 characters to avoid tag length issues
|
||||
SANITIZED=$(echo "$TAG_SUFFIX" | tr '[:upper:]' '[:lower:]' | tr '/' '-' | tr -cd '[:alnum:]._-' | cut -c1-50)
|
||||
|
||||
echo "suffix=$SANITIZED" >> "$GITHUB_OUTPUT"
|
||||
echo "Sanitized tag suffix: $SANITIZED"
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
# For multi-arch images, annotations must be attached to the image index
|
||||
# GitHub Packages reads annotations from the index level for multi-arch images
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# Tag rules for preview images:
|
||||
# - preview-<sanitized-ref> tag
|
||||
# - sha-<short> immutable tag
|
||||
tags: |
|
||||
type=raw,value=preview-${{ steps.sanitize.outputs.suffix }}
|
||||
type=raw,value=sha-${{ steps.metadata.outputs.sha }}
|
||||
# Labels are automatically generated from git context by metadata-action
|
||||
# No need to specify them - metadata-action will create standard OCI labels
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: ./.github/actions/docker-build
|
||||
with:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
timestamp: ${{ steps.metadata.outputs.timestamp }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Print final tags
|
||||
run: |
|
||||
echo "Successfully published preview Docker image"
|
||||
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
||||
echo "Image digest: ${{ steps.build.outputs.digest }}"
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Validate Branch Naming
|
||||
|
||||
# Validates branch naming conventions for feature/fix/chore/deps/release branches
|
||||
# Runs only on pull requests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-branch-name:
|
||||
name: Validate Branch Naming Convention
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Validate branch name
|
||||
run: |
|
||||
# Extract branch name from pull request head ref
|
||||
BRANCH_NAME="${{ github.head_ref }}"
|
||||
|
||||
echo "Validating branch: $BRANCH_NAME"
|
||||
|
||||
# Check each allowed pattern
|
||||
VALID=false
|
||||
|
||||
# feature/<slug>
|
||||
if [[ "$BRANCH_NAME" =~ ^feature/[a-z0-9-]+$ ]]; then
|
||||
VALID=true
|
||||
echo "✓ Branch name matches pattern: feature/<slug>"
|
||||
# fix/<slug>
|
||||
elif [[ "$BRANCH_NAME" =~ ^fix/[a-z0-9-]+$ ]]; then
|
||||
VALID=true
|
||||
echo "✓ Branch name matches pattern: fix/<slug>"
|
||||
# chore/<slug>
|
||||
elif [[ "$BRANCH_NAME" =~ ^chore/[a-z0-9-]+$ ]]; then
|
||||
VALID=true
|
||||
echo "✓ Branch name matches pattern: chore/<slug>"
|
||||
# deps/<slug>
|
||||
elif [[ "$BRANCH_NAME" =~ ^deps/[a-z0-9-]+$ ]]; then
|
||||
VALID=true
|
||||
echo "✓ Branch name matches pattern: deps/<slug>"
|
||||
# release/<version> (e.g., release/0.6.3 or release/0.6.3-rc1)
|
||||
elif [[ "$BRANCH_NAME" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9-]+)?$ ]]; then
|
||||
VALID=true
|
||||
echo "✓ Branch name matches pattern: release/<version>"
|
||||
fi
|
||||
|
||||
if [[ "$VALID" == "false" ]]; then
|
||||
echo "❌ Branch name '$BRANCH_NAME' does not match allowed patterns:"
|
||||
echo ""
|
||||
echo "Allowed branch patterns:"
|
||||
echo " - feature/<slug> (e.g., feature/new-player-ui)"
|
||||
echo " - fix/<slug> (e.g., fix/youtube-oauth)"
|
||||
echo " - chore/<slug> (e.g., chore/update-dependencies)"
|
||||
echo " - deps/<slug> (e.g., deps/youtube-source-pr195)"
|
||||
echo " - release/<version> (e.g., release/0.6.3 or release/0.6.3-rc1)"
|
||||
echo ""
|
||||
echo "Branch names should:"
|
||||
echo " - Use lowercase letters, numbers, and hyphens only"
|
||||
echo " - Follow the pattern: <type>/<descriptive-slug>"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Branch name is valid!"
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/nbproject/
|
||||
/target/
|
||||
/Playlists/
|
||||
/test/
|
||||
*.json
|
||||
*.txt
|
||||
nb*.xml
|
||||
|
||||
# IDEs
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/.cursor/
|
||||
*.iml
|
||||
*.iws
|
||||
|
||||
# Maven
|
||||
dependency-reduced-pom.xml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Other
|
||||
*.lock
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# syntax=docker/dockerfile:1.12
|
||||
|
||||
# Multi-stage build for JMusicBot
|
||||
# Stage 1: Build the application
|
||||
FROM maven:3.9.12-eclipse-temurin-25-alpine AS builder
|
||||
|
||||
ARG BUILD_TIMESTAMP
|
||||
ENV BUILD_TIMESTAMP=$BUILD_TIMESTAMP
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy pom.xml first for better layer caching
|
||||
COPY --link pom.xml .
|
||||
|
||||
# Download dependencies with BuildKit cache mount for Maven repository
|
||||
# This significantly speeds up builds by persisting dependencies between builds
|
||||
RUN --mount=type=cache,target=/root/.m2/repository \
|
||||
mvn dependency:go-offline -B -Pdocker
|
||||
|
||||
# Copy source code
|
||||
COPY --link src ./src
|
||||
|
||||
# Build the application with BuildKit cache mount
|
||||
RUN --mount=type=cache,target=/root/.m2/repository \
|
||||
if [ -n "$BUILD_TIMESTAMP" ]; then \
|
||||
mvn clean package -DskipTests -B -Pdocker -Dproject.build.outputTimestamp="$BUILD_TIMESTAMP"; \
|
||||
else \
|
||||
mvn clean package -DskipTests -B -Pdocker; \
|
||||
fi
|
||||
|
||||
|
||||
# Stage 2: Create custom minimal JRE using jlink
|
||||
# Using noble (Ubuntu 24.04) for glibc 2.39 compatibility with native libraries
|
||||
FROM eclipse-temurin:25-jdk-noble AS jre-builder
|
||||
|
||||
# Create a minimal JRE with only the modules JMusicBot needs
|
||||
# Modules determined by: jdeps --print-module-deps --ignore-missing-deps <jar>
|
||||
# Plus runtime-loaded modules that jdeps can't detect:
|
||||
# java.base - Core Java classes
|
||||
# java.compiler - Annotation processing (used by some libraries)
|
||||
# java.desktop - GUI support (Swing/AWT, even for headless mode)
|
||||
# java.logging - SLF4J/Logback logging
|
||||
# java.naming - JNDI (required by Logback)
|
||||
# java.net.http - HTTP client API
|
||||
# java.scripting - ScriptEngine for EvalCmd (Rhino)
|
||||
# java.security.jgss - Kerberos/GSS-API security
|
||||
# java.sql - JDBC (used by some dependencies)
|
||||
# jdk.crypto.ec - Elliptic curve crypto for TLS/SSL (Discord API) - runtime loaded
|
||||
# jdk.management - JMX management extensions
|
||||
# jdk.unsupported - Native library access (jdave, udpqueue)
|
||||
RUN $JAVA_HOME/bin/jlink \
|
||||
--add-modules java.base,java.compiler,java.desktop,java.logging,java.naming,java.net.http,java.scripting,java.security.jgss,java.sql,jdk.crypto.ec,jdk.management,jdk.unsupported \
|
||||
--strip-debug \
|
||||
--no-man-pages \
|
||||
--no-header-files \
|
||||
--compress=zip-6 \
|
||||
--output /javaruntime
|
||||
|
||||
|
||||
# Stage 3: Runtime image
|
||||
# Using Bitnami minideb:trixie (Debian 13) for minimal size with glibc 2.41
|
||||
# Required for jdave/udpqueue native libraries which need glibc >= 2.38
|
||||
FROM bitnami/minideb:trixie
|
||||
|
||||
# OCI image labels for better traceability and management
|
||||
LABEL org.opencontainers.image.title="JMusicBot" \
|
||||
org.opencontainers.image.description="A cross-platform Discord music bot with a clean interface" \
|
||||
org.opencontainers.image.url="https://jmusicbot.com" \
|
||||
org.opencontainers.image.source="https://github.com/arif-banai/MusicBot" \
|
||||
org.opencontainers.image.vendor="JMusicBot" \
|
||||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
|
||||
# Copy custom JRE from jre-builder stage
|
||||
ENV JAVA_HOME=/opt/java/openjdk
|
||||
ENV PATH="${JAVA_HOME}/bin:${PATH}"
|
||||
COPY --from=jre-builder /javaruntime $JAVA_HOME
|
||||
|
||||
# Create non-root user and application directories in single layer
|
||||
RUN groupadd --gid 10001 jmusicbot && \
|
||||
useradd --uid 10001 --gid 10001 --shell /bin/false jmusicbot && \
|
||||
mkdir -p /app /musicbot && \
|
||||
chown -R jmusicbot:jmusicbot /app /musicbot
|
||||
|
||||
# Copy the built JAR from builder stage
|
||||
COPY --from=builder --chown=jmusicbot:jmusicbot /build/target/JMusicBot-*-All.jar /app/app.jar
|
||||
|
||||
# Copy and set permissions for entrypoint script
|
||||
COPY --chown=jmusicbot:jmusicbot --chmod=755 docker/entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
WORKDIR /musicbot
|
||||
|
||||
# Switch to non-root user for security
|
||||
USER jmusicbot
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2017 John Grosh
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,32 @@
|
||||
JMusicBot
|
||||
Licensed under the Apache License, Version 2.0. See the LICENSE file for terms.
|
||||
|
||||
This project builds on the work of two upstream repositories:
|
||||
|
||||
1. JMusicBot — original work
|
||||
Copyright 2016-2021 John Grosh (jagrosh) <[email protected]>
|
||||
https://github.com/jagrosh/MusicBot
|
||||
|
||||
2. JMusicBot — maintained fork, the direct ancestor of this repository
|
||||
Copyright 2024-2026 Arif Banai (arif-banai)
|
||||
https://github.com/arif-banai/MusicBot
|
||||
Picked up and modernised the project after the original became unmaintained:
|
||||
updated dependencies, Java 25 support, config migration, and much more.
|
||||
|
||||
This repository is a fork of (2), and therefore also of (1). Nearly all of the
|
||||
code is their work; the changes made here are limited to restoring YouTube
|
||||
playback after YouTube-side changes broke it.
|
||||
|
||||
Modified files carry a notice describing what changed, as required by section
|
||||
4(b) of the Apache License. The modifications are:
|
||||
|
||||
- Apply the YouTube remote signature cipher independently of OAuth, and make
|
||||
the cipher server configurable.
|
||||
- Add the ANDROID client and order the YouTube client list by reliability.
|
||||
- Detect YouTube sign-in failures across the whole exception graph so the
|
||||
operator gets an actionable message instead of a bare stack trace.
|
||||
- Track youtube-source at a commit that post-dates the 1.18.2 release, which
|
||||
cannot play most tracks.
|
||||
|
||||
Third-party components retain their own licenses and copyright notices; see the
|
||||
dependency declarations in pom.xml.
|
||||
@@ -0,0 +1,336 @@
|
||||
<img align="right" src="https://i.imgur.com/zrE80HY.png" height="200" width="200">
|
||||
|
||||
# JMusicBot
|
||||
|
||||
> [!NOTE]
|
||||
> **This is a fork of a fork.** Credit for the bot belongs to the two people below — this
|
||||
> repository only fixes YouTube playback.
|
||||
>
|
||||
> | Author | Repository | Role |
|
||||
> | --- | --- | --- |
|
||||
> | **[jagrosh](https://github.com/jagrosh)** | [jagrosh/MusicBot](https://github.com/jagrosh/MusicBot) | Original author of JMusicBot |
|
||||
> | **[arif-banai](https://github.com/arif-banai)** | [arif-banai/MusicBot](https://github.com/arif-banai/MusicBot) | Revived and maintains it — dependency updates, Java 25, config migration. **Direct parent of this fork.** |
|
||||
>
|
||||
> arif-banai's note on their own fork: *"This is a fork of JMusicBot from jagrosh. I started this
|
||||
> because it was not maintained and was not working anymore."*
|
||||
>
|
||||
> Licensed under Apache 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
|
||||
## What's different in this fork
|
||||
|
||||
YouTube changed how it serves streams, which broke playback for everyone on the upstream
|
||||
release. This fork fixes that:
|
||||
|
||||
- **Signature cipher is no longer tied to OAuth.** Deciphering and authentication are unrelated
|
||||
concerns, but the cipher was only applied when OAuth was on — so the default install fell back
|
||||
to the bundled extractor and died with `Must find sig function from script: /s/player/…`.
|
||||
It is now always applied and configurable via `playback.youtube.remoteCipher.url`.
|
||||
- **Added the ANDROID client and reordered the client list by reliability.** ANDROID is currently
|
||||
the only client YouTube reliably streams to anonymously; WEB now sits last because YouTube
|
||||
answers it with SABR-only responses that contain no playable format.
|
||||
- **Sign-in failures are now reported usefully.** The existing hint could never fire: it matched
|
||||
on the aggregate exception message rather than the per-client reasons, and compared against an
|
||||
ASCII apostrophe while YouTube sends `’` (U+2019).
|
||||
- **youtube-source pinned past 1.18.2**, which predates the fixes for the TV client User-Agent
|
||||
and for formats that omit `contentLength`.
|
||||
|
||||
Measured on the tracks that were failing: **5/5 playing, stable across repeat runs** (previously
|
||||
1/5). No OAuth or Google account required.
|
||||
|
||||
[](https://github.com/arif-banai/MusicBot/releases/latest)
|
||||
[](https://github.com/arif-banai/MusicBot/stargazers)
|
||||
[](https://github.com/arif-banai/MusicBot/releases/latest)
|
||||
[](https://github.com/arif-banai/MusicBot/blob/master/LICENSE)
|
||||
[](https://discord.gg/cyyUxNmmx6) <br>
|
||||
[](https://dl.circleci.com/status-badge/redirect/gh/arif-banai/MusicBot/tree/master)
|
||||
[](https://github.com/arif-banai/MusicBot/actions/workflows/build-and-test.yml)
|
||||
[](https://www.codefactor.io/repository/github/arif-banai/musicbot)
|
||||
|
||||
A cross-platform Discord music bot with a clean interface, and that is easy to set up and run yourself!
|
||||
|
||||
## ⚠️ Check your Java Version and other requirements
|
||||
|
||||
This version of JMusicBot changes/updates various dependencies. To ensure your bot continues to function correctly, please note the following mandatory changes:
|
||||
|
||||
* **Java 25 Minimum:** The bot now requires **Java 25 or higher**.
|
||||
|
||||
* **LibDave/udpqueue:** You **must** have **glibc >= 2.38**. *If you are using Docker, this is already handled for you.*
|
||||
* **Privileged Gateway Intents:** You **must** enable the **Message Content Intent** in your [Discord Developer Portal](https://discord.com/developers/applications).
|
||||
* *Navigate to: Your Application > Bot > Privileged Gateway Intents > Toggle "Message Content Intent" to ON.*
|
||||
* *Without this, the bot will not see your commands.*
|
||||
|
||||
|
||||
[](https://jmusicbot.com/setup)
|
||||
|
||||
## Features
|
||||
* Easy to run (just make sure Java is installed, and run!)
|
||||
* Fast loading of songs
|
||||
* No external keys needed (besides a Discord Bot token)
|
||||
* Smooth playback
|
||||
* Server-specific setup for the "DJ" role that can moderate the music
|
||||
* Clean and beautiful menus
|
||||
* Supports many sites, including Youtube, Soundcloud, and more
|
||||
* Supports many online radio/streams
|
||||
* Supports local files
|
||||
* Playlist support (both web/youtube, and local)
|
||||
|
||||
## Supported sources and formats
|
||||
JMusicBot supports all sources and formats supported by [lavaplayer](https://github.com/lavalink-devs/lavaplayer#supported-formats):
|
||||
### Sources
|
||||
* YouTube
|
||||
* SoundCloud
|
||||
* Bandcamp
|
||||
* Vimeo
|
||||
* Twitch streams
|
||||
* Local files
|
||||
* HTTP URLs
|
||||
### Formats
|
||||
* MP3
|
||||
* FLAC
|
||||
* WAV
|
||||
* Matroska/WebM (AAC, Opus or Vorbis codecs)
|
||||
* MP4/M4A (AAC codec)
|
||||
* OGG streams (Opus, Vorbis and FLAC codecs)
|
||||
* AAC streams
|
||||
* Stream playlists (M3U and PLS)
|
||||
|
||||
## Example
|
||||

|
||||
|
||||
## Setup
|
||||
Please see the [Setup Page](https://jmusicbot.com/setup) to run this bot yourself!
|
||||
|
||||
## Running Directly (Without Docker)
|
||||
|
||||
When running JMusicBot directly (not in Docker), make sure to pass these JVM flags:
|
||||
|
||||
**Linux / macOS / Windows (CMD):**
|
||||
```bash
|
||||
java -Dfile.encoding=UTF-8 -Dnogui=true --enable-native-access=ALL-UNNAMED -jar JMusicBot-0.6.2-All.jar
|
||||
```
|
||||
|
||||
**Windows (PowerShell):** PowerShell treats `-D` as its own parameter. Quote each JVM option so they are passed to `java` correctly:
|
||||
```powershell
|
||||
java "-Dfile.encoding=UTF-8" "-Dnogui=true" "--enable-native-access=ALL-UNNAMED" "-jar" ".\JMusicBot-0.6.2-All.jar"
|
||||
```
|
||||
Alternatively, use the stop-parsing token so the rest of the line is passed literally: `java --% -Dfile.encoding=UTF-8 -Dnogui=true ...`
|
||||
|
||||
`-Dfile.encoding=UTF-8` ensures non-English characters (Cyrillic, Japanese, etc.) display correctly in Discord. On Windows or older JDKs, omitting it can cause mojibake in slash-command autocomplete and embeds.
|
||||
|
||||
### Linux System Requirements
|
||||
|
||||
**Important:** Your system **must have glibc version 2.38 or higher**. Failure to meet this requirement will result in errors when using JDave and udpqueue.
|
||||
|
||||
> **Note:** Ubuntu 24.04 "Noble" and Debian 13 "Trixie" already include a compatible glibc version out of the box.
|
||||
|
||||
You can check your glibc version with:
|
||||
```bash
|
||||
ldd --version
|
||||
```
|
||||
|
||||
On Debian/Ubuntu-based systems, you may also need to install the following native audio library dependencies:
|
||||
|
||||
```bash
|
||||
# Install required native library dependencies
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libopus0 libsodium23
|
||||
```
|
||||
|
||||
|
||||
If your version is below 2.38, you will need to upgrade your system or use a compatible runtime. (You can also use Docker, which is recommended.)
|
||||
|
||||
## Docker
|
||||
|
||||
JMusicBot can be run using Docker for easy deployment and management. Pre-built images are available from the GitHub Container Registry. The container is configured to run headless and automatically generate a default `config.txt` on first run.
|
||||
|
||||
### Quick Start
|
||||
|
||||
#### For Existing Users (Migrating from JAR)
|
||||
|
||||
If you already have a directory with your `config.txt`, `Playlists/` folder, and other bot files, either provide the path to that directory or run the container from within it:
|
||||
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
--name jmusicbot \
|
||||
-v "$(pwd):/musicbot" \
|
||||
ghcr.io/arif-banai/musicbot:latest
|
||||
```
|
||||
|
||||
This mounts your current directory as the musicbot volume, so the bot will use your existing configuration and playlists.
|
||||
|
||||
#### For New Users
|
||||
|
||||
1. **Create a directory for your bot and run the container:**
|
||||
```bash
|
||||
mkdir -p /path/to/jmusicbot
|
||||
|
||||
docker run --rm -it \
|
||||
--name jmusicbot \
|
||||
-v "/path/to/musicbot:/musicbot" \
|
||||
ghcr.io/arif-banai/musicbot:latest
|
||||
```
|
||||
|
||||
2. **First Run:**
|
||||
- On first run, if the mounted directory is empty, the bot will automatically generate a default `config.txt` file.
|
||||
- Edit `/path/to/musicbot/config.txt` on your host and add your Discord bot token.
|
||||
- Run the container again.
|
||||
|
||||
#### Using Docker Compose (Optional)
|
||||
|
||||
If you prefer docker-compose, copy the example compose file and update the volume path:
|
||||
|
||||
```bash
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
# Edit docker-compose.yml and update the volume path
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Example `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jmusicbot:
|
||||
image: ghcr.io/arif-banai/musicbot:latest
|
||||
container_name: jmusicbot
|
||||
volumes:
|
||||
- /path/to/musicbot:/musicbot
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Check the [Docker Compose Example](docker-compose.example.yml) for more details.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Config Persistence:** The `/musicbot` volume **must** be mounted for your configuration to persist. The bot reads and writes `config.txt` from `/musicbot` (the container's working directory).
|
||||
- **First Run:** If `config.txt` doesn't exist, the bot will generate a default one automatically. You'll need to edit it with your bot token before the bot can start.
|
||||
- **Image Tags:**
|
||||
- Use `ghcr.io/arif-banai/musicbot:latest` for the latest build from the master branch
|
||||
- Use `ghcr.io/arif-banai/musicbot:0.6.1` (replace with actual version) to pin a specific release version
|
||||
- **Recommendation:** For production, pin your image tag rather than using `latest`
|
||||
- **JAVA_OPTS:** The container uses ZGC and AlwaysPreTouch by default. Set `JAVA_OPTS` to add heap limits (e.g. `-Xms256m -Xmx512m`) or other flags. See [Performance Tuning](#performance-tuning) for details.
|
||||
|
||||
|
||||
To view published images, visit: `https://github.com/arif-banai/MusicBot/pkgs/container/musicbot`
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
For optimal audio quality with minimal stuttering, the following JVM and configuration options are recommended.
|
||||
|
||||
### JVM Flags (Recommended)
|
||||
|
||||
The bot works best with ZGC (Z Garbage Collector) which provides sub-millisecond pause times:
|
||||
|
||||
```bash
|
||||
java -Dfile.encoding=UTF-8 \
|
||||
-XX:+UseZGC \
|
||||
-XX:+AlwaysPreTouch \
|
||||
-Dnogui=true \
|
||||
--enable-native-access=ALL-UNNAMED \
|
||||
-jar JMusicBot-*.jar
|
||||
```
|
||||
|
||||
**Flag explanations:**
|
||||
- `-Dfile.encoding=UTF-8`: Ensures non-English characters display correctly in Discord (required on Windows or older JDKs)
|
||||
- `-XX:+UseZGC`: Sub-millisecond GC pauses (generational mode is default in JDK 24+)
|
||||
- `-XX:+AlwaysPreTouch`: Pre-allocates memory at startup to avoid page faults
|
||||
- `-Xms` / `-Xmx`: Optional; set heap size if you want to limit or fix memory (e.g. `-Xms256m -Xmx512m`)
|
||||
|
||||
The Docker image uses ZGC and AlwaysPreTouch by default. Set `JAVA_OPTS` to add heap limits or override:
|
||||
```yaml
|
||||
environment:
|
||||
- JAVA_OPTS=-Xms256m -Xmx512m -XX:+UseZGC -XX:+AlwaysPreTouch
|
||||
```
|
||||
|
||||
### Audio Buffer Configuration
|
||||
|
||||
The bot includes configurable audio buffers that protect against GC pauses. These can be tuned in `config.txt`:
|
||||
|
||||
```hocon
|
||||
performance {
|
||||
# NAS buffer duration in ms (protects against JVM pauses up to this duration)
|
||||
nasBufferMs = 800
|
||||
|
||||
# Lavaplayer frame buffer duration in ms (amount of decoded audio to buffer)
|
||||
frameBufferMs = 2000
|
||||
}
|
||||
```
|
||||
|
||||
Higher values provide more protection against stuttering but add latency. The defaults (800ms NAS, 2000ms frame buffer) should work well for most setups.
|
||||
|
||||
## Proxy Configuration
|
||||
|
||||
JMusicBot supports granular proxy configuration, allowing you to route specific components through a proxy while letting others connect directly. This is useful when you need to proxy audio traffic (e.g., to bypass regional restrictions) without affecting Discord API communication.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add the following to your `config.txt`:
|
||||
|
||||
```hocon
|
||||
proxy {
|
||||
# Proxy server hostname and port
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
|
||||
# Enable proxy for specific components
|
||||
lavaplayer = true # Audio source requests (YouTube, SoundCloud, etc.)
|
||||
jda = false # Discord API traffic
|
||||
github = false # Version check requests
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
|
||||
**Route only audio traffic through proxy** (most common):
|
||||
```hocon
|
||||
proxy {
|
||||
host = "127.0.0.1"
|
||||
port = 18080
|
||||
lavaplayer = true
|
||||
jda = false
|
||||
github = false
|
||||
}
|
||||
```
|
||||
|
||||
**Route all traffic through proxy**:
|
||||
```hocon
|
||||
proxy {
|
||||
host = "proxy.example.com"
|
||||
port = 8080
|
||||
lavaplayer = true
|
||||
jda = true
|
||||
github = true
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Leave `host` empty or `port` as 0 to disable proxy entirely
|
||||
- Each component can be independently enabled/disabled
|
||||
- HTTP proxies are supported (SOCKS proxies are not currently supported)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
This project follows a **trunk-based development** workflow. The `master` branch is always releasable, and all work happens in short-lived branches:
|
||||
|
||||
- **`feature/<slug>`** - New features (e.g., `feature/new-player-ui`)
|
||||
- **`fix/<slug>`** - Bug fixes (e.g., `fix/youtube-oauth`)
|
||||
- **`chore/<slug>`** - Maintenance tasks (e.g., `chore/update-deps`)
|
||||
- **`deps/<slug>`** - Dependency experiments (e.g., `deps/youtube-source-pr195`)
|
||||
- **`release/<version>`** - Release stabilization (optional, e.g., `release/0.6.3`)
|
||||
|
||||
Branch names are automatically validated by CI to ensure consistency. For detailed information about the development workflow, branch naming rules, and best practices, see [DEVELOPMENT_WORKFLOW.md](docs/DEVELOPMENT_WORKFLOW.md).
|
||||
|
||||
## Questions/Suggestions/Bug Reports
|
||||
**Please read the [Issues List](https://github.com/arif-banai/MusicBot/issues) before suggesting a feature**.
|
||||
|
||||
If you have a question, need troubleshooting help, or want to brainstorm a new feature, please start a [Discussion](https://github.com/arif-banai/MusicBot/discussions).
|
||||
|
||||
The Discord server is also available for questions and suggestions. [Click here to join](https://discord.gg/cyyUxNmmx6).
|
||||
|
||||
If you'd like to suggest a feature or report a reproducible bug, please open an [Issue](https://github.com/arif-banai/MusicBot/issues) on this repository. If you like this bot, be sure to add a star to the libraries that make this possible:
|
||||
- [**JDA**](https://github.com/DV8FromTheWorld/JDA)
|
||||
- [**lavaplayer**](https://github.com/lavalink-devs/lavaplayer)
|
||||
- [**youtube-source**](https://github.com/lavalink-devs/youtube-source)
|
||||
|
||||
## Editing
|
||||
This bot (and the source code here) might not be easy to edit for inexperienced programmers. The main purpose of having the source public is to show the capabilities of the libraries, to allow others to understand how the bot works, and to allow those knowledgeable about java, JDA, and Discord bot development to contribute. There are many requirements and dependencies required to edit and compile it, and there will not be support provided for people looking to make changes on their own. Instead, consider making a feature request (see the above section). If you choose to make edits, please do so in accordance with the Apache 2.0 License.
|
||||
@@ -0,0 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
jmusicbot:
|
||||
image: ghcr.io/arif-banai/musicbot:latest # or ghcr.io/arif-banai/musicbot:0.6.1 for a specific version
|
||||
container_name: jmusicbot
|
||||
# The container runs as non-root user 'jmusicbot' (UID 10001) for security.
|
||||
# Ensure the mounted volume directory is readable/writable by UID 10001,
|
||||
# or uncomment the user line below to run as your host user.
|
||||
# user: "${UID:-10001}:${GID:-10001}"
|
||||
environment:
|
||||
# JVM options. Image defaults to ZGC + AlwaysPreTouch; set JAVA_OPTS for heap or other flags:
|
||||
# - JAVA_OPTS=-Xms256m -Xmx512m -XX:+UseZGC -XX:+AlwaysPreTouch
|
||||
{}
|
||||
volumes:
|
||||
# Mount your config directory. Ensure UID 10001 has read/write access:
|
||||
# mkdir -p /your/path/to/musicbot && chown 10001:10001 /your/path/to/musicbot
|
||||
- /your/path/to/musicbot:/musicbot
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
JAR="/app/app.jar"
|
||||
|
||||
if [ ! -f "$JAR" ]; then
|
||||
echo "[ERROR] JAR file not found at /app/app.jar"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] ========================================"
|
||||
echo "[INFO] JMusicBot Containerized"
|
||||
echo "[INFO] ========================================"
|
||||
echo "[INFO] Selected jar: $JAR"
|
||||
echo "[INFO] Working directory: $(pwd)"
|
||||
if [ -f "config.txt" ]; then
|
||||
echo "[INFO] config.txt: Found (existing)"
|
||||
else
|
||||
echo "[INFO] config.txt: Not found (will be generated on first run)"
|
||||
fi
|
||||
echo "[INFO] ========================================"
|
||||
|
||||
# Default JVM options for optimal audio performance (ZGC with sub-ms pauses)
|
||||
# Set JAVA_OPTS to override; add -Xms/-Xmx for heap limits if desired
|
||||
: "${JAVA_OPTS:=-XX:+UseZGC -XX:+AlwaysPreTouch}"
|
||||
|
||||
# Build argv
|
||||
set -- java -Dnogui=true --enable-native-access=ALL-UNNAMED
|
||||
|
||||
# Append JAVA_OPTS (either default or user-provided)
|
||||
if [ -n "${JAVA_OPTS:-}" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
set -- "$@" $JAVA_OPTS
|
||||
fi
|
||||
|
||||
set -- "$@" -jar "$JAR"
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,495 @@
|
||||
# JMusicBot Architectural Analysis
|
||||
|
||||
> **Document Metadata**
|
||||
> | Field | Value |
|
||||
> |-------|-------|
|
||||
> | Generated | 2026-01-25 |
|
||||
> | Branch | `slash-commands-v1` |
|
||||
> | Commit | `c0f0a21` ([c0f0a214a50121a5b66ba28917a57013d9cb6a5f](https://github.com/arif-banai/MusicBot/commit/c0f0a214a50121a5b66ba28917a57013d9cb6a5f)) |
|
||||
> | Version | `v0.6.1-21-gc0f0a21` (0.6.2-slash-commands) |
|
||||
> | Base Commit Message | "Refactor music service and command structure" |
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
JMusicBot is a cross-platform Discord music bot built with Java 25, using JDA (Java Discord API) for Discord integration and Lavaplayer for audio streaming. The codebase follows a **layered architecture** with clear separation between presentation (commands), business logic (services), and infrastructure (audio/settings).
|
||||
|
||||
---
|
||||
|
||||
## High-Level Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph presentation [Presentation Layer]
|
||||
TextCmd[Text Commands v1]
|
||||
SlashCmd[Slash Commands v2]
|
||||
GUI[GUI Panel]
|
||||
end
|
||||
|
||||
subgraph business [Business Logic Layer]
|
||||
MusicSvc[MusicService]
|
||||
SearchSvc[SearchService]
|
||||
Validator[MusicCommandValidator]
|
||||
end
|
||||
|
||||
subgraph infrastructure [Infrastructure Layer]
|
||||
subgraph audio [Audio Subsystem]
|
||||
PlayerMgr[PlayerManager]
|
||||
AudioHandler[AudioHandler]
|
||||
AudioSrc[AudioSource Managers]
|
||||
end
|
||||
subgraph persistence [Persistence]
|
||||
SettingsMgr[SettingsManager]
|
||||
PlaylistLoader[PlaylistLoader]
|
||||
ConfigLoader[ConfigLoader]
|
||||
end
|
||||
subgraph queue [Queue System]
|
||||
AbstractQueue[AbstractQueue]
|
||||
LinearQueue[LinearQueue]
|
||||
FairQueue[FairQueue]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph external [External Dependencies]
|
||||
JDA[JDA Discord API]
|
||||
Lavaplayer[Lavaplayer]
|
||||
YouTube[YouTube/SoundCloud/etc]
|
||||
Discord[Discord Gateway]
|
||||
end
|
||||
|
||||
TextCmd --> Validator
|
||||
SlashCmd --> Validator
|
||||
Validator --> MusicSvc
|
||||
Validator --> SearchSvc
|
||||
|
||||
MusicSvc --> AudioHandler
|
||||
SearchSvc --> PlayerMgr
|
||||
|
||||
AudioHandler --> AbstractQueue
|
||||
AbstractQueue --> LinearQueue
|
||||
AbstractQueue --> FairQueue
|
||||
|
||||
AudioHandler --> PlayerMgr
|
||||
PlayerMgr --> AudioSrc
|
||||
AudioSrc --> Lavaplayer
|
||||
|
||||
AudioHandler --> JDA
|
||||
JDA --> Discord
|
||||
Lavaplayer --> YouTube
|
||||
|
||||
MusicSvc --> SettingsMgr
|
||||
GUI --> MusicSvc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package Overview
|
||||
|
||||
### 1. Root Package: `com.jagrosh.jmusicbot`
|
||||
|
||||
| Class | Role |
|
||||
|-------|------|
|
||||
| `JMusicBot` | Application entry point, bootstraps all components |
|
||||
| `Bot` | Central container holding references to all subsystems |
|
||||
| `BotConfig` | Configuration loading and access facade |
|
||||
| `DiscordService` | JDA initialization and Discord connection setup |
|
||||
| `Listener` | Discord event handler (ready, voice updates, buttons) |
|
||||
|
||||
### 2. Commands Package: `commands.v1` and `commands.v2`
|
||||
|
||||
**Purpose**: Presentation layer handling user input via text commands (v1) or slash commands (v2).
|
||||
|
||||
**Hierarchy**:
|
||||
```
|
||||
Command (JDA-Utils) SlashCommand (JDA-Utils)
|
||||
├── MusicCommand ├── MusicSlashCommand
|
||||
│ └── DJCommand │ └── DJSlashCommand
|
||||
├── AdminCommand └── AdminSlashCommand
|
||||
└── OwnerCommand
|
||||
```
|
||||
|
||||
**Key Classes**:
|
||||
- `MusicCommand` / `MusicSlashCommand`: Base for music playback commands
|
||||
- `DJCommand` / `DJSlashCommand`: Requires DJ role or permissions
|
||||
- `AdminCommand` / `AdminSlashCommand`: Requires MANAGE_SERVER
|
||||
- `CommandFactory`: Creates and registers all commands
|
||||
- `SlashCommandRegistry`: Handles Discord slash command upsert with hash-based change detection
|
||||
|
||||
**Adapters**: `TextOutputAdapters` and `SlashOutputAdapters` implement `OutputAdapter` interface for command-agnostic responses.
|
||||
|
||||
### 3. Service Package: `service`
|
||||
|
||||
**Purpose**: Business logic layer encapsulating music operations.
|
||||
|
||||
| Service | Responsibilities |
|
||||
|---------|------------------|
|
||||
| `MusicService` | Player control, queue management, volume, repeat, seek, skip |
|
||||
| `SearchService` | Track/playlist search with callback handling |
|
||||
| `AudioLoadResultHandlers` | Async handlers for Lavaplayer load results |
|
||||
|
||||
**Key Design**: Services accept `OutputAdapter` to remain command-type agnostic.
|
||||
|
||||
### 4. Audio Package: `audio`
|
||||
|
||||
**Purpose**: Audio playback and Discord voice integration.
|
||||
|
||||
| Class | Role |
|
||||
|-------|------|
|
||||
| `PlayerManager` | Extends Lavaplayer's `DefaultAudioPlayerManager`, registers sources |
|
||||
| `AudioHandler` | Per-guild audio handler, implements JDA's `AudioSendHandler` |
|
||||
| `AudioSource` | Enum of audio sources (YouTube, SoundCloud, etc.) with registration logic |
|
||||
| `QueuedTrack` | Wraps `AudioTrack` with request metadata |
|
||||
| `RequestMetadata` | Stores requester info (user, channel, query) |
|
||||
| `NowPlayingHandler` | Manages "now playing" message updates |
|
||||
| `AloneInVoiceHandler` | Auto-disconnect when alone in voice channel |
|
||||
| `TransformativeAudioSourceManager` | URL transformation via regex/CSS selectors |
|
||||
|
||||
### 5. Queue Package: `queue`
|
||||
|
||||
**Purpose**: Pluggable queue implementations.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Queueable {
|
||||
<<interface>>
|
||||
+getIdentifier() long
|
||||
}
|
||||
|
||||
class AbstractQueue~T~ {
|
||||
<<abstract>>
|
||||
-list: List~T~
|
||||
-history: PlaybackHistory~T~
|
||||
+add(T item)* int
|
||||
+pull() T
|
||||
+shuffle(int startIndex)
|
||||
+moveItem(int from, int to)
|
||||
}
|
||||
|
||||
class LinearQueue~T~ {
|
||||
+add(T item) int
|
||||
}
|
||||
|
||||
class FairQueue~T~ {
|
||||
+add(T item) int
|
||||
}
|
||||
|
||||
class PlaybackHistory~T~ {
|
||||
-history: ArrayDeque~T~
|
||||
-maxSize: int
|
||||
+add(T item)
|
||||
+removeFirst() T
|
||||
}
|
||||
|
||||
class QueuedTrack {
|
||||
-track: AudioTrack
|
||||
-metadata: RequestMetadata
|
||||
+getIdentifier() long
|
||||
}
|
||||
|
||||
Queueable <|.. QueuedTrack
|
||||
AbstractQueue <|-- LinearQueue
|
||||
AbstractQueue <|-- FairQueue
|
||||
AbstractQueue o-- PlaybackHistory
|
||||
AbstractQueue o-- Queueable
|
||||
```
|
||||
|
||||
**Strategy Pattern**: `QueueType` enum holds `QueueSupplier` references for runtime strategy selection.
|
||||
|
||||
### 6. Settings Package: `settings`
|
||||
|
||||
**Purpose**: Per-guild configuration persistence.
|
||||
|
||||
| Class | Role |
|
||||
|-------|------|
|
||||
| `Settings` | Per-guild settings POJO (volume, repeat, queue type, prefix, etc.) |
|
||||
| `SettingsManager` | CRUD operations, JSON persistence (`serversettings.json`) |
|
||||
| `QueueType` | Enum: LINEAR, FAIR with queue factory |
|
||||
| `RepeatMode` | Enum: OFF, ALL, SINGLE |
|
||||
|
||||
**Persistence**: Jackson JSON serialization, auto-save on mutation.
|
||||
|
||||
### 7. Config Package: `config`
|
||||
|
||||
**Purpose**: Application configuration with versioned migration support.
|
||||
|
||||
| Subpackage | Responsibility |
|
||||
|------------|----------------|
|
||||
| `loader` | Loads and merges configs |
|
||||
| `io` | File I/O operations |
|
||||
| `migration` | Version migrations (0→1→...) |
|
||||
| `model` | `ConfigOption` enum with type-safe accessors |
|
||||
| `diagnostics` | Detects missing/deprecated keys |
|
||||
| `validation` | Validates required fields (token, owner) |
|
||||
| `render` | Generates updated config preserving template |
|
||||
| `update` | Manages config file updates with backups |
|
||||
|
||||
---
|
||||
|
||||
## UML Class Diagram: Core Classes
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Bot {
|
||||
-jda: JDA
|
||||
-config: BotConfig
|
||||
-settingsManager: SettingsManager
|
||||
-playerManager: PlayerManager
|
||||
-musicService: MusicService
|
||||
-searchService: SearchService
|
||||
+shutdown()
|
||||
}
|
||||
|
||||
class MusicService {
|
||||
-bot: Bot
|
||||
+play(Guild, Member, String, TextChannel, OutputAdapter)
|
||||
+skip(Guild, Member, OutputAdapter)
|
||||
+pause(Guild, Member, OutputAdapter)
|
||||
+stop(Guild, Member, OutputAdapter)
|
||||
+setVolume(Guild, int)
|
||||
+addTrackToQueue(Guild, Member, AudioTrack, String, TextChannel)
|
||||
}
|
||||
|
||||
class AudioHandler {
|
||||
-audioPlayer: AudioPlayer
|
||||
-queue: AbstractQueue
|
||||
-manager: PlayerManager
|
||||
+addTrack(QueuedTrack)
|
||||
+addTrackToFront(QueuedTrack)
|
||||
+stopAndClear()
|
||||
+provide20MsAudio() ByteBuffer
|
||||
}
|
||||
|
||||
class PlayerManager {
|
||||
-bot: Bot
|
||||
+init()
|
||||
+setUpHandler(Guild) AudioHandler
|
||||
+loadItemOrdered(Object, String, AudioLoadResultHandler)
|
||||
}
|
||||
|
||||
class MusicCommand {
|
||||
<<abstract>>
|
||||
#musicService: MusicService
|
||||
#bot: Bot
|
||||
+execute(CommandEvent)
|
||||
#doCommand(CommandEvent)*
|
||||
}
|
||||
|
||||
class MusicSlashCommand {
|
||||
<<abstract>>
|
||||
#musicService: MusicService
|
||||
#bot: Bot
|
||||
+execute(SlashCommandEvent)
|
||||
#doCommand(SlashCommandEvent)*
|
||||
}
|
||||
|
||||
class OutputAdapter {
|
||||
<<interface>>
|
||||
+reply(String)
|
||||
+replyWarning(String)
|
||||
+replyError(String)
|
||||
+replySuccess(String)
|
||||
}
|
||||
|
||||
Bot --> MusicService
|
||||
Bot --> PlayerManager
|
||||
Bot --> SettingsManager
|
||||
MusicService --> AudioHandler
|
||||
MusicService --> OutputAdapter
|
||||
AudioHandler --> PlayerManager
|
||||
MusicCommand --> MusicService
|
||||
MusicSlashCommand --> MusicService
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Patterns Identified
|
||||
|
||||
| Pattern | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
| **Strategy** | `QueueType` + `AbstractQueue` | Pluggable queue algorithms (Linear/Fair) |
|
||||
| **Template Method** | `MusicCommand.execute()` → `doCommand()` | Common validation, subclass-specific logic |
|
||||
| **Adapter** | `OutputAdapter` implementations | Unified output interface for text/slash commands |
|
||||
| **Factory** | `CommandFactory`, `QueueSupplier` | Object creation encapsulation |
|
||||
| **Observer** | `AudioEventAdapter` in `AudioHandler` | React to Lavaplayer track events |
|
||||
| **Singleton-like** | `Bot`, `SettingsManager` | Single instance per application |
|
||||
| **Registry** | `ConfigMigration`, `SlashCommandRegistry` | Centralized registration |
|
||||
| **Facade** | `BotConfig`, `MusicService` | Simplified interface to complex subsystems |
|
||||
| **Command** | JDA-Utils command framework | Encapsulated user requests |
|
||||
| **Builder** | JDA's message builders | Fluent message construction |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: Playing a Track
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant SlashCmd as PlaySlashCmd
|
||||
participant Validator as MusicCommandValidator
|
||||
participant MusicSvc as MusicService
|
||||
participant PlayerMgr as PlayerManager
|
||||
participant AudioSrc as AudioSourceManager
|
||||
participant Handler as AudioHandler
|
||||
participant JDA
|
||||
participant Discord
|
||||
|
||||
User->>SlashCmd: /play "song name"
|
||||
SlashCmd->>Validator: validate(event)
|
||||
Validator-->>SlashCmd: valid
|
||||
SlashCmd->>MusicSvc: play(guild, member, query, channel, adapter)
|
||||
MusicSvc->>PlayerMgr: loadItemOrdered(guild, query, handler)
|
||||
PlayerMgr->>AudioSrc: loadItem(query)
|
||||
AudioSrc-->>PlayerMgr: AudioTrack
|
||||
PlayerMgr-->>MusicSvc: trackLoaded callback
|
||||
MusicSvc->>Handler: addTrack(QueuedTrack)
|
||||
Handler->>Handler: audioPlayer.playTrack()
|
||||
loop Every 20ms
|
||||
JDA->>Handler: provide20MsAudio()
|
||||
Handler-->>JDA: ByteBuffer (Opus)
|
||||
JDA->>Discord: Send audio packet
|
||||
end
|
||||
MusicSvc-->>SlashCmd: success message
|
||||
SlashCmd-->>User: "Added song to queue"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implicit Architectural Decisions
|
||||
|
||||
### 1. Layered Architecture
|
||||
- **Presentation**: Commands (v1/v2) handle user input
|
||||
- **Business Logic**: Services contain domain operations
|
||||
- **Infrastructure**: Audio, persistence, Discord integration
|
||||
|
||||
### 2. Command Version Separation
|
||||
- `v1` = Text commands (legacy, prefix-based)
|
||||
- `v2` = Slash commands (modern Discord UI)
|
||||
- Both share `MusicService` and `OutputAdapter` abstraction
|
||||
|
||||
### 3. Per-Guild Isolation
|
||||
- Each guild has its own `AudioHandler`, `Settings`, and queue
|
||||
- No cross-guild state leakage
|
||||
|
||||
### 4. Configuration Versioning
|
||||
- `meta.configVersion` enables forward-compatible migrations
|
||||
- Backups created before updates
|
||||
|
||||
### 5. Event-Driven Audio
|
||||
- Lavaplayer events drive track lifecycle
|
||||
- `AudioHandler` extends `AudioEventAdapter` for hooks
|
||||
|
||||
### 6. Deferred Initialization
|
||||
- `PlayerManager.init()` called after GUI to ensure logging is ready
|
||||
- Audio sources registered at runtime based on config
|
||||
|
||||
---
|
||||
|
||||
## Improvement Opportunities
|
||||
|
||||
### 1. Reduce Service Coupling
|
||||
**Issue**: `MusicService` (1192 lines) has too many responsibilities.
|
||||
**Solution**: Extract sub-services:
|
||||
- `QueueService` for queue operations
|
||||
- `PlayerControlService` for play/pause/stop/seek
|
||||
- `VolumeService` for volume management
|
||||
|
||||
### 2. Complete v2 Command Migration
|
||||
**Issue**: v2 (slash commands) lacks Owner and General command categories.
|
||||
**Solution**: Migrate remaining commands to slash versions for consistent UX.
|
||||
|
||||
### 3. Standardize Result Objects
|
||||
**Issue**: Some methods return raw values, others return result objects.
|
||||
**Solution**: Consistently use result objects (e.g., `OperationResult<T>`) for all service methods.
|
||||
|
||||
### 4. Extract Audio Source Registration
|
||||
**Issue**: `AudioSource` enum contains complex registration logic.
|
||||
**Solution**: Use a dedicated `AudioSourceRegistry` class with pluggable source providers.
|
||||
|
||||
### 5. Add Dependency Injection Framework
|
||||
**Issue**: Manual dependency wiring in `Bot` constructor.
|
||||
**Solution**: Consider lightweight DI (Guice, Dagger) for cleaner component management and testing.
|
||||
|
||||
### 6. Improve Test Coverage
|
||||
**Issue**: Limited unit test infrastructure.
|
||||
**Solution**: Add mocking for Discord/audio layers, increase coverage of service layer.
|
||||
|
||||
### 7. Configuration Hot-Reload
|
||||
**Issue**: Config changes require restart.
|
||||
**Solution**: Add file watcher for hot-reloading non-critical settings.
|
||||
|
||||
### 8. Unified Error Handling
|
||||
**Issue**: Error handling varies across commands.
|
||||
**Solution**: Create `CommandException` hierarchy with consistent handling in base classes.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Language | Java 25 |
|
||||
| Build | Maven 3.8+ |
|
||||
| Discord API | JDA 6.3.0 |
|
||||
| Command Framework | JDA-Chewtils 2.2.1 |
|
||||
| Audio Playback | Lavaplayer 2.2.6 |
|
||||
| YouTube Support | Lavalink YouTube Source 1.16.0 |
|
||||
| Voice Protocol | JDave (DAVE protocol) |
|
||||
| Native Audio | udpqueue (UDP audio queuing) |
|
||||
| Configuration | Typesafe Config (HOCON) |
|
||||
| Persistence | Jackson JSON |
|
||||
| Logging | Logback + SLF4J |
|
||||
| HTML Parsing | JSoup |
|
||||
| Testing | JUnit 5, Mockito, Hamcrest |
|
||||
|
||||
---
|
||||
|
||||
## Startup Flow
|
||||
|
||||
```
|
||||
main()
|
||||
└─> startBot()
|
||||
├─> Create UserInteraction (Prompt)
|
||||
├─> Redirect console streams (if GUI)
|
||||
├─> Acquire instance lock
|
||||
├─> Check versions
|
||||
├─> Load BotConfig
|
||||
│ └─> Validate token & owner
|
||||
├─> Set log level
|
||||
├─> Create EventWaiter
|
||||
├─> Create SettingsManager
|
||||
├─> Create Bot instance
|
||||
│ ├─> Initialize PlaylistLoader
|
||||
│ ├─> Initialize PlayerManager (not init() yet)
|
||||
│ ├─> Initialize NowPlayingHandler
|
||||
│ ├─> Initialize AloneInVoiceHandler
|
||||
│ ├─> Initialize MusicService
|
||||
│ ├─> Initialize SearchService
|
||||
│ └─> Initialize YoutubeOauth2TokenHandler
|
||||
├─> Initialize GUI (if enabled)
|
||||
├─> Create CommandClient (via CommandFactory)
|
||||
├─> Initialize PlayerManager (register audio sources)
|
||||
└─> Create JDA & connect to Discord
|
||||
└─> Register Listener for events
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Document History
|
||||
|
||||
This architectural analysis was generated using AI-assisted code analysis. To update this document after significant changes:
|
||||
|
||||
1. Review the current codebase structure for new packages/classes
|
||||
2. Update diagrams to reflect new components or data flows
|
||||
3. Add new design patterns if introduced
|
||||
4. Update the metadata table at the top with the new commit info
|
||||
|
||||
**Regeneration command** (for reference):
|
||||
```bash
|
||||
git log -1 --format="%H %h %s %ai"
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last generated: 2026-01-25 | Commit: c0f0a21 | Branch: slash-commands-v1*
|
||||
@@ -0,0 +1,268 @@
|
||||
# Development Workflow
|
||||
|
||||
This project follows a **trunk-based development** workflow. This document outlines the branch strategy, development practices, and CI/CD processes.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
### Trunk Branch: `master`
|
||||
|
||||
- **Always releasable** - The `master` branch should always be in a deployable state
|
||||
- **Protected** - Direct pushes should be avoided; use pull requests
|
||||
- **Main integration point** - All changes merge into `master`
|
||||
- **Production-ready** - Code on `master` can be released at any time
|
||||
|
||||
### Short-Lived Branches
|
||||
|
||||
All feature work happens in short-lived branches that are merged back to `master` quickly (typically within days, not weeks).
|
||||
|
||||
#### Branch Types
|
||||
|
||||
1. **`feature/<slug>`** - New features and enhancements
|
||||
- Examples: `feature/new-player-ui`, `feature/playlist-import`
|
||||
- Use for: Adding new functionality, UI improvements, major enhancements
|
||||
|
||||
2. **`fix/<slug>`** - Bug fixes
|
||||
- Examples: `fix/youtube-oauth`, `fix/memory-leak`
|
||||
- Use for: Fixing bugs, resolving issues, patching vulnerabilities
|
||||
|
||||
3. **`chore/<slug>`** - Maintenance tasks
|
||||
- Examples: `chore/update-dependencies`, `chore/refactor-audio-handler`
|
||||
- Use for: Code cleanup, refactoring, dependency updates, documentation
|
||||
|
||||
4. **`deps/<slug>`** - Dependency experiments
|
||||
- Examples: `deps/youtube-source-pr195`, `deps/test-jda-6.4`
|
||||
- Use for: Testing dependency updates, experimenting with forks, evaluating new libraries
|
||||
|
||||
5. **`release/<version>`** - Release stabilization (optional)
|
||||
- Examples: `release/0.6.3`, `release/0.6.3-rc1`
|
||||
- Use for: Stabilizing a release, release candidates, hotfixes for specific versions
|
||||
- **Note**: Only create when you need to stabilize a release. Most releases can go directly from `master`
|
||||
|
||||
### Branch Naming Rules
|
||||
|
||||
- **Format**: `<type>/<descriptive-slug>`
|
||||
- **Slug requirements**:
|
||||
- Lowercase letters, numbers, and hyphens only
|
||||
- Descriptive and concise (e.g., `new-player-ui`, not `ui` or `new-feature`)
|
||||
- No underscores or special characters
|
||||
- **Examples**:
|
||||
- ✅ `feature/new-player-ui`
|
||||
- ✅ `fix/youtube-oauth-error`
|
||||
- ✅ `chore/update-maven-plugins`
|
||||
- ✅ `deps/test-lavaplayer-2.3`
|
||||
- ✅ `release/0.6.3`
|
||||
- ❌ `feature/newFeature` (uppercase)
|
||||
- ❌ `fix/bug_123` (underscore)
|
||||
- ❌ `new-feature` (missing type prefix)
|
||||
- ❌ `feature/new feature` (spaces)
|
||||
|
||||
## Development Process
|
||||
|
||||
### 1. Starting Work
|
||||
|
||||
```bash
|
||||
# Create a branch from master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git checkout -b feature/my-new-feature
|
||||
|
||||
# Or for a bug fix
|
||||
git checkout -b fix/bug-description
|
||||
```
|
||||
|
||||
### 2. Making Changes
|
||||
|
||||
- Make small, focused commits
|
||||
- Write clear commit messages
|
||||
- Keep the branch up-to-date with `master`:
|
||||
```bash
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git checkout feature/my-new-feature
|
||||
git rebase master # or git merge master
|
||||
```
|
||||
|
||||
### 3. Testing Locally
|
||||
|
||||
- Run tests: `mvn verify`
|
||||
- Test Docker build: `docker build -t jmusicbot:test .`
|
||||
- Verify the bot works as expected
|
||||
|
||||
### 4. Creating a Pull Request
|
||||
|
||||
- Push your branch: `git push origin feature/my-new-feature`
|
||||
- Create a PR targeting `master`
|
||||
- The CI will:
|
||||
- ✅ Validate branch naming
|
||||
- ✅ Run tests and build
|
||||
- ✅ Build Docker image (tagged with branch name)
|
||||
- Wait for CI to pass and code review
|
||||
|
||||
### 5. Merging
|
||||
|
||||
- Once approved and CI passes, merge the PR
|
||||
- **Prefer squash merge** to keep history clean
|
||||
- Delete the branch after merging (GitHub can do this automatically)
|
||||
|
||||
## CI/CD Workflows
|
||||
|
||||
### Branch Validation
|
||||
|
||||
The `validate-branch-naming.yml` workflow automatically validates branch names on:
|
||||
- Pull requests (when opened, updated, or edited)
|
||||
- Direct pushes to non-master branches
|
||||
|
||||
**What it checks:**
|
||||
- Branch name matches allowed patterns
|
||||
- Slug uses only lowercase, numbers, and hyphens
|
||||
- Proper type prefix is used
|
||||
|
||||
### Build and Test
|
||||
|
||||
The `build-and-test.yml` workflow runs on:
|
||||
- Pushes to `master` and all short-lived branches
|
||||
- Pull requests targeting `master`
|
||||
|
||||
**What it does:**
|
||||
- Compiles the project
|
||||
- Runs unit and integration tests
|
||||
- Generates code coverage reports
|
||||
- Uploads coverage to Codecov
|
||||
|
||||
### Docker Build
|
||||
|
||||
The `docker-build.yml` workflow builds and publishes Docker images on:
|
||||
- Pushes to `master` → tags as `:latest` and version (if not SNAPSHOT)
|
||||
- Pushes to short-lived branches → tags as `:<branch-name>` (e.g., `:feature-new-player-ui`)
|
||||
- Version tags (e.g., `v0.6.3`) → tags as `:0.6.3`
|
||||
|
||||
**Image tags:**
|
||||
- `master`: `ghcr.io/arif-banai/musicbot:latest` (+ version tag if applicable)
|
||||
- Feature branch: `ghcr.io/arif-banai/musicbot:feature-new-player-ui`
|
||||
- Version tag: `ghcr.io/arif-banai/musicbot:0.6.3`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Keep Branches Short-Lived
|
||||
|
||||
- **Goal**: Merge within days, not weeks
|
||||
- **Why**: Reduces merge conflicts, keeps code fresh, enables faster feedback
|
||||
- **If stuck**: Break work into smaller PRs
|
||||
|
||||
### Keep `master` Releasable
|
||||
|
||||
- **Never** push broken code to `master`
|
||||
- **Always** ensure tests pass before merging
|
||||
- **Use** feature flags if needed for incomplete features
|
||||
- **Consider** draft PRs for work-in-progress
|
||||
|
||||
### Small, Focused PRs
|
||||
|
||||
- **One feature/fix per PR** when possible
|
||||
- **Easier to review** and understand
|
||||
- **Faster to merge** and deploy
|
||||
- **Less risk** of conflicts
|
||||
|
||||
### Regular Integration
|
||||
|
||||
- **Rebase or merge** `master` into your branch regularly
|
||||
- **Run tests** locally before pushing
|
||||
- **Fix CI failures** promptly
|
||||
|
||||
### Clear Commit Messages
|
||||
|
||||
- **Format**: `<type>: <description>`
|
||||
- **Types**: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`
|
||||
- **Example**: `feat: add playlist import from YouTube`
|
||||
- **Why**: Makes history readable and enables automated changelogs
|
||||
|
||||
## Release Process
|
||||
|
||||
### Standard Release (from master)
|
||||
|
||||
1. **Ensure `master` is stable**
|
||||
- All tests passing
|
||||
- No known critical bugs
|
||||
- Documentation updated
|
||||
|
||||
2. **Update version in `pom.xml`**
|
||||
- Set the release version (e.g., `0.6.3`)
|
||||
- Commit: `git commit -m "chore: bump version to 0.6.3"`
|
||||
|
||||
3. **Create release tag**
|
||||
```bash
|
||||
git tag v0.6.3
|
||||
git push origin v0.6.3
|
||||
```
|
||||
|
||||
4. **Use "Make Release" workflow** (optional)
|
||||
- Or manually create GitHub release
|
||||
- Attach JAR file from workflow artifacts
|
||||
|
||||
5. **Docker image is automatically built** from the tag
|
||||
- Tagged as `ghcr.io/arif-banai/musicbot:0.6.3`
|
||||
|
||||
### Release Branch (for stabilization)
|
||||
|
||||
Only use if you need to stabilize a release while continuing development:
|
||||
|
||||
1. **Create release branch**
|
||||
```bash
|
||||
git checkout -b release/0.6.3
|
||||
git push origin release/0.6.3
|
||||
```
|
||||
|
||||
2. **Stabilize on release branch**
|
||||
- Fix critical bugs
|
||||
- Run extensive testing
|
||||
- Cherry-pick fixes from `master` if needed
|
||||
|
||||
3. **Tag from release branch**
|
||||
```bash
|
||||
git tag v0.6.3
|
||||
git push origin v0.6.3
|
||||
```
|
||||
|
||||
4. **Merge back to master** (if needed)
|
||||
- Merge any fixes back to `master`
|
||||
- Delete release branch after release
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: Can I push directly to master?
|
||||
|
||||
**A**: Not recommended. Use pull requests for all changes to ensure:
|
||||
- Code review
|
||||
- CI validation
|
||||
- Better history tracking
|
||||
|
||||
### Q: How long should branches live?
|
||||
|
||||
**A**: Ideally less than a week. If work takes longer, consider:
|
||||
- Breaking into smaller PRs
|
||||
- Using feature flags
|
||||
- Creating a release branch if needed
|
||||
|
||||
### Q: What if I need to experiment?
|
||||
|
||||
**A**: Use `deps/<slug>` branches for dependency experiments, or create a personal fork for major experiments.
|
||||
|
||||
### Q: Can I use different branch names?
|
||||
|
||||
**A**: The CI will reject branches that don't match the allowed patterns. This ensures consistency and makes it easier to understand what each branch is for.
|
||||
|
||||
### Q: What about hotfixes?
|
||||
|
||||
**A**: For urgent production fixes:
|
||||
1. Create `fix/<description>` branch from `master`
|
||||
2. Fix the issue
|
||||
3. Create PR and merge quickly
|
||||
4. Tag a new patch version (e.g., `v0.6.3` → `v0.6.4`)
|
||||
|
||||
## Summary
|
||||
|
||||
- **Trunk**: `master` is always releasable
|
||||
- **Branches**: Short-lived, type-prefixed (`feature/`, `fix/`, `chore/`, `deps/`, `release/`)
|
||||
- **Process**: Branch → Develop → Test → PR → Merge → Release
|
||||
- **CI**: Automatic validation, testing, and Docker builds
|
||||
- **Goal**: Fast, safe, continuous integration and deployment
|
||||
@@ -0,0 +1,862 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.arifbanai</groupId>
|
||||
<artifactId>JMusicBot</artifactId>
|
||||
<version>0.7.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>JMusicBot</name>
|
||||
<description>A cross-platform Discord music bot with a clean interface</description>
|
||||
<url>https://jmusicbot.com</url>
|
||||
<inceptionYear>2017</inceptionYear>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<organization>
|
||||
<name>JMusicBot</name>
|
||||
<url>https://jmusicbot.com</url>
|
||||
</organization>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>jagrosh</id>
|
||||
<name>John Grosh</name>
|
||||
<url>https://github.com/jagrosh</url>
|
||||
<roles>
|
||||
<role>original author</role>
|
||||
</roles>
|
||||
</developer>
|
||||
<developer>
|
||||
<id>arif-banai</id>
|
||||
<name>Arif Banai</name>
|
||||
<url>https://github.com/arif-banai</url>
|
||||
<roles>
|
||||
<role>maintainer</role>
|
||||
</roles>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:https://github.com/arif-banai/MusicBot.git</connection>
|
||||
<developerConnection>scm:git:[email protected]:arif-banai/MusicBot.git</developerConnection>
|
||||
<url>https://github.com/arif-banai/MusicBot</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<issueManagement>
|
||||
<system>GitHub Issues</system>
|
||||
<url>https://github.com/arif-banai/MusicBot/issues</url>
|
||||
</issueManagement>
|
||||
|
||||
<ciManagement>
|
||||
<system>GitHub Actions</system>
|
||||
<url>https://github.com/arif-banai/MusicBot/actions</url>
|
||||
</ciManagement>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>dv8tion</id>
|
||||
<name>m2-dv8tion</name>
|
||||
<url>https://m2.dv8tion.net/releases</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>jitpack.io</id>
|
||||
<url>https://jitpack.io</url>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
<updatePolicy>always</updatePolicy>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>arbjergDev</id>
|
||||
<name>Lavalink Repository</name>
|
||||
<url>https://maven.lavalink.dev/releases</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>lavalinkSnapshots</id>
|
||||
<name>Lavalink Snapshot Repository</name>
|
||||
<url>https://maven.lavalink.dev/snapshots</url>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>chew</id>
|
||||
<name>m2-chew</name>
|
||||
<url>https://m2.chew.pro/releases</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- Jackson BOM to align all Jackson modules -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson</groupId>
|
||||
<artifactId>jackson-bom</artifactId>
|
||||
<version>${jackson-bom.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- JUnit BOM to align all JUnit modules -->
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>${junit-bom.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- SLF4J API - resolves 1.7.36/2.0.7 vs 2.0.17 conflicts -->
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
<!-- Kotlin stdlib - resolves 1.4.x/2.2.20 vs 2.2.21 conflicts -->
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<version>${kotlin-stdlib.version}</version>
|
||||
</dependency>
|
||||
<!-- JetBrains annotations - resolves 13.0 vs 24.0.0 -->
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
<version>${jetbrains-annotations.version}</version>
|
||||
</dependency>
|
||||
<!-- org.json - resolves 20220320 vs 20240303 -->
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>${org-json.version}</version>
|
||||
</dependency>
|
||||
<!-- jsoup - resolves 1.16.1 vs 1.22.1 -->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>${jsoup.version}</version>
|
||||
</dependency>
|
||||
<!-- Rhino engine - resolves 1.7.14 vs 1.7.15 -->
|
||||
<dependency>
|
||||
<groupId>org.mozilla</groupId>
|
||||
<artifactId>rhino-engine</artifactId>
|
||||
<version>${rhino-engine.version}</version>
|
||||
</dependency>
|
||||
<!-- lava-common - align to lavaplayer 2.2.7 -->
|
||||
<dependency>
|
||||
<groupId>dev.arbjerg</groupId>
|
||||
<artifactId>lava-common</artifactId>
|
||||
<version>${lava-common.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- Discord Dependencies -->
|
||||
<dependency>
|
||||
<groupId>net.dv8tion</groupId>
|
||||
<artifactId>JDA</artifactId>
|
||||
<version>${jda.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>pw.chew</groupId>
|
||||
<artifactId>jda-chewtils</artifactId>
|
||||
<version>${jda-chewtils.version}</version>
|
||||
<scope>compile</scope>
|
||||
<type>pom</type>
|
||||
<exclusions>
|
||||
<!-- Exclude ancient slf4j 1.7.x - we use 2.0.17 via logback -->
|
||||
<exclusion>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</exclusion>
|
||||
<!-- Exclude ancient kotlin 1.4.x - incompatible with JDA's kotlin 2.x -->
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib-common</artifactId>
|
||||
</exclusion>
|
||||
<!-- Exclude old okhttp 4.x - JDA uses okhttp 5.x -->
|
||||
<exclusion>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
</exclusion>
|
||||
<!-- Exclude old okio 2.x - JDA uses okio 3.x -->
|
||||
<exclusion>
|
||||
<groupId>com.squareup.okio</groupId>
|
||||
<artifactId>okio</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- DAVE Protocol Dependencies -->
|
||||
<!-- https://github.com/MinnDevelopment/jdave -->
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>jdave-api</artifactId>
|
||||
<version>${jdave.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>jdave-native-linux-x86-64</artifactId>
|
||||
<version>${jdave.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>jdave-native-linux-aarch64</artifactId>
|
||||
<version>${jdave.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>jdave-native-win-x86-64</artifactId>
|
||||
<version>${jdave.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>jdave-native-darwin</artifactId>
|
||||
<version>${jdave.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Native Audio Send (udpqueue) Dependencies -->
|
||||
<!-- https://github.com/MinnDevelopment/udpqueue.rs -->
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-linux-x86-64</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-linux-x86</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-linux-aarch64</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-linux-arm</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-linux-musl-x86-64</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-linux-musl-aarch64</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-win-x86-64</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-win-x86</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-win-aarch64</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>club.minnced</groupId>
|
||||
<artifactId>udpqueue-native-darwin</artifactId>
|
||||
<version>${udpqueue.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Music Dependencies -->
|
||||
<dependency>
|
||||
<groupId>dev.arbjerg</groupId>
|
||||
<artifactId>lavaplayer</artifactId>
|
||||
<version>${lavaplayer.version}</version>
|
||||
</dependency>
|
||||
<!-- YouTube Source (lavalink-devs) - v2 module provides lavaplayer 2.x thumbnail clients -->
|
||||
<dependency>
|
||||
<groupId>dev.lavalink.youtube</groupId>
|
||||
<artifactId>v2</artifactId>
|
||||
<version>${youtube-source.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Dropping JLyrics since it is not being maintained / not working anymore -->
|
||||
<!-- <dependency>
|
||||
<groupId>com.github.jagrosh</groupId>
|
||||
<artifactId>JLyrics</artifactId>
|
||||
<version>master-SNAPSHOT</version>
|
||||
</dependency> -->
|
||||
|
||||
<!-- Misc Internal Dependencies -->
|
||||
<!-- Source: https://mvnrepository.com/artifact/ch.qos.logback/logback-classic -->
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
<!-- Source: https://mvnrepository.com/artifact/com.typesafe/config -->
|
||||
<dependency>
|
||||
<groupId>com.typesafe</groupId>
|
||||
<artifactId>config</artifactId>
|
||||
<version>${typesafe-config.version}</version>
|
||||
</dependency>
|
||||
<!-- Source: https://mvnrepository.com/artifact/org.jsoup/jsoup -->
|
||||
<!-- Version managed by dependencyManagement -->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
</dependency>
|
||||
<!-- Using Jackson (already included via JDA transitive dependency) to reduce shaded jar size -->
|
||||
<!-- Source: https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
|
||||
<!-- Version managed by jackson-bom in dependencyManagement -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- GUI Dependencies -->
|
||||
<!-- FlatLaf Look and Feel - Modern flat Swing L&F -->
|
||||
<!-- Source: https://github.com/JFormDesigner/FlatLaf -->
|
||||
<dependency>
|
||||
<groupId>com.formdev</groupId>
|
||||
<artifactId>flatlaf</artifactId>
|
||||
<version>${flatlaf.version}</version>
|
||||
</dependency>
|
||||
<!-- FlatLaf Extras - SVG icons, UI inspector, and more -->
|
||||
<dependency>
|
||||
<groupId>com.formdev</groupId>
|
||||
<artifactId>flatlaf-extras</artifactId>
|
||||
<version>${flatlaf.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing Dependencies -->
|
||||
<!-- Version managed by junit-bom in dependencyManagement -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hamcrest</groupId>
|
||||
<artifactId>hamcrest-core</artifactId>
|
||||
<version>${hamcrest.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>5.3.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven-compiler-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>${maven-jar-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>${maven-shade-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>${maven-surefire-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>${maven-surefire-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>${maven-enforcer-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>${maven-dependency-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>versions-maven-plugin</artifactId>
|
||||
<version>${versions-maven-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>${checkstyle-maven-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>${spotbugs-maven-plugin.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco-maven-plugin.version}</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<shadedArtifactAttached>true</shadedArtifactAttached>
|
||||
<shadedClassifierName>All</shadedClassifierName>
|
||||
<artifactSet>
|
||||
<includes>
|
||||
<include>*:*</include>
|
||||
</includes>
|
||||
</artifactSet>
|
||||
<filters>
|
||||
<!-- Keep native libraries intact -->
|
||||
<filter>
|
||||
<artifact>club.minnced:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<!-- Keep classes that may be loaded via reflection -->
|
||||
<filter>
|
||||
<artifact>ch.qos.logback:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.slf4j:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<!-- Keep FlatLaf resources intact (themes, properties, native libs) -->
|
||||
<filter>
|
||||
<artifact>com.formdev:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
|
||||
<resource>reference.conf</resource>
|
||||
</transformer>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Main-Class>com.jagrosh.jmusicbot.JMusicBot</Main-Class>
|
||||
<Specification-Title>${project.artifactId}</Specification-Title>
|
||||
<Specification-Version>${project.version}</Specification-Version>
|
||||
<Implementation-Title>${project.artifactId}</Implementation-Title>
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Vendor-Id>${project.groupId}</Implementation-Vendor-Id>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/unit/**/*Test.java</include>
|
||||
<include>**/unit/**/*Tests.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/integration/**/*Test.java</include>
|
||||
<include>**/integration/**/*Tests.java</include>
|
||||
<include>**/integration/**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<release>${maven.compiler.release}</release>
|
||||
<showWarnings>true</showWarnings>
|
||||
<showDeprecation>true</showDeprecation>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
|
||||
<addDefaultSpecificationEntries>true</addDefaultSpecificationEntries>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>enforce-versions</id>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireMavenVersion>
|
||||
<version>[3.8.0,)</version>
|
||||
</requireMavenVersion>
|
||||
<requireJavaVersion>
|
||||
<version>[25,)</version>
|
||||
</requireJavaVersion>
|
||||
<banDuplicatePomDependencyVersions/>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<properties>
|
||||
<!-- Build settings -->
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
<maven.compiler.target>25</maven.compiler.target>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
|
||||
<!-- Reproducible builds -->
|
||||
<project.build.outputTimestamp>2026-07-14T05:29:10Z</project.build.outputTimestamp>
|
||||
|
||||
<!-- Dependency versions -->
|
||||
<jda.version>6.4.2</jda.version>
|
||||
<jda-chewtils.version>2.2.1</jda-chewtils.version>
|
||||
<jdave.version>0.1.7</jdave.version>
|
||||
<udpqueue.version>0.2.12</udpqueue.version>
|
||||
<lavaplayer.version>2.2.7</lavaplayer.version>
|
||||
<!-- Pinned to the current youtube-source main HEAD. 1.18.2 predates the fixes for
|
||||
the TV client User-Agent ("The page needs to be reloaded") and for formats
|
||||
that omit contentLength; no 1.18.3 has been released yet. -->
|
||||
<youtube-source.version>f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT</youtube-source.version>
|
||||
<logback.version>1.5.25</logback.version>
|
||||
<typesafe-config.version>1.4.3</typesafe-config.version>
|
||||
<jsoup.version>1.22.1</jsoup.version>
|
||||
<mockito.version>5.21.0</mockito.version>
|
||||
<hamcrest.version>3.0</hamcrest.version>
|
||||
<flatlaf.version>3.7</flatlaf.version>
|
||||
|
||||
<!-- BOM versions -->
|
||||
<jackson-bom.version>2.20.1</jackson-bom.version>
|
||||
<junit-bom.version>5.14.2</junit-bom.version>
|
||||
|
||||
<!-- Managed dependency versions -->
|
||||
<slf4j.version>2.0.17</slf4j.version>
|
||||
<kotlin-stdlib.version>2.2.21</kotlin-stdlib.version>
|
||||
<jetbrains-annotations.version>24.0.0</jetbrains-annotations.version>
|
||||
<org-json.version>20240303</org-json.version>
|
||||
<rhino-engine.version>1.7.15</rhino-engine.version>
|
||||
<lava-common.version>2.2.7</lava-common.version>
|
||||
|
||||
<!-- Plugin versions -->
|
||||
<maven-compiler-plugin.version>3.13.0</maven-compiler-plugin.version>
|
||||
<maven-shade-plugin.version>3.6.1</maven-shade-plugin.version>
|
||||
<maven-surefire-plugin.version>3.5.4</maven-surefire-plugin.version>
|
||||
<maven-jar-plugin.version>3.4.2</maven-jar-plugin.version>
|
||||
<maven-enforcer-plugin.version>3.5.0</maven-enforcer-plugin.version>
|
||||
<maven-dependency-plugin.version>3.8.1</maven-dependency-plugin.version>
|
||||
<versions-maven-plugin.version>2.18.0</versions-maven-plugin.version>
|
||||
<spotbugs-maven-plugin.version>4.9.3.0</spotbugs-maven-plugin.version>
|
||||
<checkstyle-maven-plugin.version>3.6.0</checkstyle-maven-plugin.version>
|
||||
<jacoco-maven-plugin.version>0.8.13</jacoco-maven-plugin.version>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<!-- Code style enforcement profile -->
|
||||
<profile>
|
||||
<id>checkstyle</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<configuration>
|
||||
<configLocation>google_checks.xml</configLocation>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>true</failsOnError>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>checkstyle-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<!-- Static analysis profile -->
|
||||
<profile>
|
||||
<id>spotbugs</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>spotbugs-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<!-- Test coverage profile -->
|
||||
<profile>
|
||||
<id>coverage</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>prepare-agent-integration</id>
|
||||
<goals>
|
||||
<goal>prepare-agent-integration</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report-integration</id>
|
||||
<phase>post-integration-test</phase>
|
||||
<goals>
|
||||
<goal>report-integration</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<!-- Dependency analysis profile -->
|
||||
<profile>
|
||||
<id>dependency-check</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>versions-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<!-- E2E test profile -->
|
||||
<profile>
|
||||
<id>e2e</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>e2e-tests</id>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/e2e/**/*Test.java</include>
|
||||
<include>**/e2e/**/*Tests.java</include>
|
||||
<include>**/e2e/**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<!-- Docker build profile - excludes unnecessary platform-specific dependencies -->
|
||||
<!-- Docker only needs linux-x86-64 and linux-aarch64 for amd64/arm64 platforms -->
|
||||
<profile>
|
||||
<id>docker</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<shadedArtifactAttached>true</shadedArtifactAttached>
|
||||
<shadedClassifierName>All</shadedClassifierName>
|
||||
<artifactSet>
|
||||
<includes>
|
||||
<include>*:*</include>
|
||||
</includes>
|
||||
<!-- Exclude platform-specific dependencies not needed for Docker -->
|
||||
<excludes>
|
||||
<!-- jdave: exclude Windows and macOS -->
|
||||
<exclude>club.minnced:jdave-native-win-x86-64</exclude>
|
||||
<exclude>club.minnced:jdave-native-darwin</exclude>
|
||||
<!-- udpqueue: exclude Windows, macOS, and unnecessary Linux variants -->
|
||||
<exclude>club.minnced:udpqueue-native-win-x86-64</exclude>
|
||||
<exclude>club.minnced:udpqueue-native-win-x86</exclude>
|
||||
<exclude>club.minnced:udpqueue-native-win-aarch64</exclude>
|
||||
<exclude>club.minnced:udpqueue-native-darwin</exclude>
|
||||
<exclude>club.minnced:udpqueue-native-linux-x86</exclude>
|
||||
<exclude>club.minnced:udpqueue-native-linux-arm</exclude>
|
||||
<exclude>club.minnced:udpqueue-native-linux-musl-x86-64</exclude>
|
||||
<exclude>club.minnced:udpqueue-native-linux-musl-aarch64</exclude>
|
||||
</excludes>
|
||||
</artifactSet>
|
||||
<filters>
|
||||
<!-- Keep native libraries intact -->
|
||||
<filter>
|
||||
<artifact>club.minnced:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<!-- Keep classes that may be loaded via reflection -->
|
||||
<filter>
|
||||
<artifact>ch.qos.logback:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.slf4j:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<!-- Keep FlatLaf resources intact (themes, properties, native libs) -->
|
||||
<filter>
|
||||
<artifact>com.formdev:*</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
|
||||
<resource>reference.conf</resource>
|
||||
</transformer>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Main-Class>com.jagrosh.jmusicbot.JMusicBot</Main-Class>
|
||||
<Specification-Title>${project.artifactId}</Specification-Title>
|
||||
<Specification-Version>${project.version}</Specification-Version>
|
||||
<Implementation-Title>${project.artifactId}</Implementation-Title>
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Vendor-Id>${project.groupId}</Implementation-Vendor-Id>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/sh
|
||||
|
||||
# This will have this script check for a new version of JMusicBot every
|
||||
# startup (and download it if the latest version isn't currently downloaded)
|
||||
DOWNLOAD=true
|
||||
|
||||
# This will cause the script to run in a loop so that the bot auto-restarts
|
||||
# when you use the shutdown command
|
||||
LOOP=true
|
||||
|
||||
# JVM options for running JMusicBot
|
||||
# --enable-native-access=ALL-UNNAMED: Required for Java 22+ to load native libraries (jdave, udpqueue)
|
||||
JAVA_OPTS="${JAVA_OPTS:---enable-native-access=ALL-UNNAMED}"
|
||||
|
||||
download() {
|
||||
if [ $DOWNLOAD = true ]; then
|
||||
# First, check if the latest release is a pre-release
|
||||
LATEST_RELEASE=$(curl -s https://api.github.com/repos/arif-banai/MusicBot/releases/latest)
|
||||
IS_PRERELEASE=$(echo "$LATEST_RELEASE" | grep -o '"prerelease":[^,}]*' | head -1 | grep -o 'true')
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
# Latest is a pre-release, so get all releases and find the first non-prerelease
|
||||
ALL_RELEASES=$(curl -s https://api.github.com/repos/arif-banai/MusicBot/releases?per_page=10)
|
||||
# Find the first release block that has "prerelease": false, then extract JAR URL from it
|
||||
# This handles the JSON structure where assets are nested
|
||||
URL=$(echo "$ALL_RELEASES" | awk '
|
||||
BEGIN { in_non_prerelease = 0; found = 0 }
|
||||
/"prerelease":\s*false/ { in_non_prerelease = 1 }
|
||||
/"prerelease":\s*true/ { in_non_prerelease = 0 }
|
||||
in_non_prerelease && /"browser_download_url".*\.jar/ && !found {
|
||||
match($0, /"browser_download_url":\s*"([^"]+\.jar)"/, arr)
|
||||
if (arr[1] != "") {
|
||||
print arr[1]
|
||||
found = 1
|
||||
exit
|
||||
}
|
||||
}
|
||||
')
|
||||
else
|
||||
# Latest is not a pre-release, use it
|
||||
URL=$(echo "$LATEST_RELEASE" | grep -i "browser_download_url.*\.jar" | sed 's/.*\(http.*\)"/\1/')
|
||||
fi
|
||||
|
||||
FILENAME=$(echo $URL | sed 's/.*\/\([^\/]*\)/\1/')
|
||||
if [ -f $FILENAME ]; then
|
||||
echo "Latest version already downloaded (${FILENAME})"
|
||||
else
|
||||
curl -L $URL -o $FILENAME
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run() {
|
||||
# JVM flags for optimal audio performance (set -Xms/-Xmx via JAVA_OPTS if desired):
|
||||
# -XX:+UseZGC: Sub-millisecond GC pauses (generational mode is default in JDK 24+)
|
||||
# -XX:+AlwaysPreTouch: Pre-allocate memory at startup to avoid page faults
|
||||
# shellcheck disable=SC2086
|
||||
java -XX:+UseZGC \
|
||||
-XX:+AlwaysPreTouch \
|
||||
-Dnogui=true \
|
||||
$JAVA_OPTS \
|
||||
-jar $(ls -t JMusicBot* | head -1)
|
||||
}
|
||||
|
||||
while
|
||||
download
|
||||
run
|
||||
$LOOP
|
||||
do
|
||||
continue
|
||||
done
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandClient;
|
||||
import com.jagrosh.jdautilities.commons.waiter.EventWaiter;
|
||||
import com.jagrosh.jmusicbot.audio.AloneInVoiceHandler;
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.audio.AudioLoadWrapper;
|
||||
import com.jagrosh.jmusicbot.audio.NowPlayingHandler;
|
||||
import com.jagrosh.jmusicbot.audio.PlayerManager;
|
||||
import com.jagrosh.jmusicbot.audio.TrackLoadingMonitor;
|
||||
import com.jagrosh.jmusicbot.entities.UserInteraction;
|
||||
import javax.swing.JFrame;
|
||||
import com.jagrosh.jmusicbot.playlist.PlaylistLoader;
|
||||
import com.jagrosh.jmusicbot.settings.SettingsManager;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import com.jagrosh.jmusicbot.service.SearchService;
|
||||
import com.jagrosh.jmusicbot.utils.InstanceLock;
|
||||
import com.jagrosh.jmusicbot.utils.YoutubeOauth2TokenHandler;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.entities.Activity;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class Bot
|
||||
{
|
||||
private final EventWaiter waiter;
|
||||
private final ScheduledExecutorService threadpool;
|
||||
private final BotConfig config;
|
||||
private final SettingsManager settings;
|
||||
private final PlayerManager players;
|
||||
private final PlaylistLoader playlists;
|
||||
private final NowPlayingHandler nowplaying;
|
||||
private final AloneInVoiceHandler aloneInVoiceHandler;
|
||||
private final MusicService musicService;
|
||||
private final SearchService searchService;
|
||||
private final YoutubeOauth2TokenHandler youTubeOauth2TokenHandler;
|
||||
private final UserInteraction userInteraction;
|
||||
private final Instant startTime;
|
||||
private final AudioLoadWrapper audioLoadWrapper;
|
||||
|
||||
private boolean shuttingDown = false;
|
||||
private JDA jda;
|
||||
private JFrame gui;
|
||||
private CommandClient commandClient;
|
||||
|
||||
public Bot(EventWaiter waiter, BotConfig config, SettingsManager settings, UserInteraction userInteraction)
|
||||
{
|
||||
this.waiter = waiter;
|
||||
this.config = config;
|
||||
this.settings = settings;
|
||||
this.userInteraction = userInteraction;
|
||||
this.playlists = new PlaylistLoader(config);
|
||||
this.threadpool = Executors.newSingleThreadScheduledExecutor();
|
||||
this.startTime = Instant.now();
|
||||
this.youTubeOauth2TokenHandler = new YoutubeOauth2TokenHandler();
|
||||
this.youTubeOauth2TokenHandler.init();
|
||||
this.players = new PlayerManager(this);
|
||||
// Delay init of the PlayerManager until the GUI has started
|
||||
// this.players.init();
|
||||
this.nowplaying = new NowPlayingHandler(this);
|
||||
this.nowplaying.init();
|
||||
this.aloneInVoiceHandler = new AloneInVoiceHandler(this);
|
||||
this.aloneInVoiceHandler.init();
|
||||
this.musicService = new MusicService(this);
|
||||
this.searchService = new SearchService(this);
|
||||
|
||||
// Initialize audio load wrapper - use NO_OP when GUI is disabled to avoid monitoring overhead
|
||||
this.audioLoadWrapper = isNoGUI()
|
||||
? AudioLoadWrapper.NO_OP
|
||||
: TrackLoadingMonitor.getInstance();
|
||||
}
|
||||
|
||||
public BotConfig getConfig()
|
||||
{
|
||||
return config;
|
||||
}
|
||||
|
||||
public SettingsManager getSettingsManager()
|
||||
{
|
||||
return settings;
|
||||
}
|
||||
|
||||
public EventWaiter getWaiter()
|
||||
{
|
||||
return waiter;
|
||||
}
|
||||
|
||||
public ScheduledExecutorService getThreadpool()
|
||||
{
|
||||
return threadpool;
|
||||
}
|
||||
|
||||
public PlayerManager getPlayerManager()
|
||||
{
|
||||
return players;
|
||||
}
|
||||
|
||||
public PlaylistLoader getPlaylistLoader()
|
||||
{
|
||||
return playlists;
|
||||
}
|
||||
|
||||
public NowPlayingHandler getNowplayingHandler()
|
||||
{
|
||||
return nowplaying;
|
||||
}
|
||||
|
||||
public AloneInVoiceHandler getAloneInVoiceHandler()
|
||||
{
|
||||
return aloneInVoiceHandler;
|
||||
}
|
||||
|
||||
public MusicService getMusicService()
|
||||
{
|
||||
return musicService;
|
||||
}
|
||||
|
||||
public SearchService getSearchService()
|
||||
{
|
||||
return searchService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the audio load wrapper for wrapping track load handlers.
|
||||
* Returns {@link AudioLoadWrapper#NO_OP} when GUI is disabled,
|
||||
* or {@link TrackLoadingMonitor} when GUI is enabled.
|
||||
*
|
||||
* @return the audio load wrapper
|
||||
*/
|
||||
public AudioLoadWrapper getAudioLoadWrapper()
|
||||
{
|
||||
return audioLoadWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TrackLoadingMonitor instance if monitoring is enabled.
|
||||
* Returns null when GUI is disabled (audioLoadWrapper is NO_OP).
|
||||
*
|
||||
* @return the TrackLoadingMonitor, or null if monitoring is disabled
|
||||
*/
|
||||
public TrackLoadingMonitor getTrackLoadingMonitor()
|
||||
{
|
||||
return audioLoadWrapper instanceof TrackLoadingMonitor
|
||||
? (TrackLoadingMonitor) audioLoadWrapper
|
||||
: null;
|
||||
}
|
||||
|
||||
public UserInteraction getUserInteraction()
|
||||
{
|
||||
return userInteraction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if running in no-GUI mode.
|
||||
* GUI is disabled when either the -Dnogui=true property is set,
|
||||
* or gui.enabled is set to false in the config.
|
||||
*
|
||||
* @return true if GUI is disabled, false if GUI is enabled
|
||||
*/
|
||||
public boolean isNoGUI()
|
||||
{
|
||||
return userInteraction.isNoGUI() || !config.getGuiEnabled();
|
||||
}
|
||||
|
||||
public JDA getJDA()
|
||||
{
|
||||
return jda;
|
||||
}
|
||||
|
||||
public void closeAudioConnection(long guildId)
|
||||
{
|
||||
Guild guild = jda.getGuildById(guildId);
|
||||
if(guild!=null)
|
||||
threadpool.submit(() -> guild.getAudioManager().closeAudioConnection());
|
||||
}
|
||||
|
||||
public void resetGame()
|
||||
{
|
||||
Activity game = config.getGame()==null || config.getGame().getName().equalsIgnoreCase("none") ? null : config.getGame();
|
||||
if(!Objects.equals(jda.getPresence().getActivity(), game))
|
||||
jda.getPresence().setActivity(game);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a full graceful shutdown with complete cleanup.
|
||||
* Use this for normal shutdowns (GUI close, /shutdown command).
|
||||
*/
|
||||
public void shutdown()
|
||||
{
|
||||
if (shuttingDown)
|
||||
return;
|
||||
shuttingDown = true;
|
||||
|
||||
// Clean up audio connections first (before shutting down thread pool, as these may trigger events that use it)
|
||||
if (jda != null && jda.getStatus() != JDA.Status.SHUTTING_DOWN)
|
||||
{
|
||||
jda.getGuilds().stream().forEach(g ->
|
||||
{
|
||||
AudioHandler ah = (AudioHandler) g.getAudioManager().getSendingHandler();
|
||||
if (ah != null)
|
||||
{
|
||||
ah.stopAndClear();
|
||||
ah.getPlayer().destroy();
|
||||
}
|
||||
g.getAudioManager().closeAudioConnection();
|
||||
});
|
||||
jda.shutdown();
|
||||
}
|
||||
|
||||
// Shut down thread pool after audio cleanup to avoid RejectedExecutionException
|
||||
threadpool.shutdownNow();
|
||||
|
||||
if (gui != null)
|
||||
gui.dispose();
|
||||
InstanceLock.release();
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
public void setJDA(JDA jda)
|
||||
{
|
||||
this.jda = jda;
|
||||
}
|
||||
|
||||
public void setGUI(JFrame gui)
|
||||
{
|
||||
this.gui = gui;
|
||||
}
|
||||
|
||||
public void setCommandClient(CommandClient commandClient)
|
||||
{
|
||||
this.commandClient = commandClient;
|
||||
}
|
||||
|
||||
public CommandClient getCommandClient()
|
||||
{
|
||||
return commandClient;
|
||||
}
|
||||
|
||||
public YoutubeOauth2TokenHandler getYouTubeOauth2Handler() {
|
||||
return youTubeOauth2TokenHandler;
|
||||
}
|
||||
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh (jagrosh)
|
||||
*
|
||||
* Modified 2026: added the YouTube remote cipher settings.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot;
|
||||
|
||||
import static com.jagrosh.jmusicbot.config.model.ConfigOption.*;
|
||||
import static com.jagrosh.jmusicbot.config.model.ConfigOption.GUI_ENABLED;
|
||||
import static com.jagrosh.jmusicbot.config.model.ConfigOption.GUI_THEME;
|
||||
import static com.jagrosh.jmusicbot.config.model.ConfigOption.GUI_FONT_SIZE;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import com.typesafe.config.Config;
|
||||
import com.typesafe.config.ConfigException;
|
||||
import net.dv8tion.jda.api.OnlineStatus;
|
||||
import net.dv8tion.jda.api.entities.Activity;
|
||||
|
||||
import com.jagrosh.jmusicbot.audio.AudioSource;
|
||||
import com.jagrosh.jmusicbot.config.diagnostics.ConfigDiagnostics;
|
||||
import com.jagrosh.jmusicbot.config.io.ConfigIO;
|
||||
import com.jagrosh.jmusicbot.config.loader.ConfigLoader;
|
||||
import com.jagrosh.jmusicbot.config.update.ConfigUpdater;
|
||||
import com.jagrosh.jmusicbot.config.validation.ConfigValidator;
|
||||
import com.jagrosh.jmusicbot.config.validation.ConfigValidator.ValidationResult;
|
||||
import com.jagrosh.jmusicbot.config.migration.ConfigMigration;
|
||||
import com.jagrosh.jmusicbot.config.migration.ConfigMigrationException;
|
||||
import com.jagrosh.jmusicbot.config.model.ConfigUpdateType;
|
||||
import com.jagrosh.jmusicbot.entities.UserInteraction;
|
||||
import com.jagrosh.jmusicbot.entities.UserInteraction.Level;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
import com.jagrosh.jmusicbot.utils.TimeUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @author John Grosh (jagrosh)
|
||||
*/
|
||||
public class BotConfig {
|
||||
private final static Logger LOGGER = LoggerFactory.getLogger(BotConfig.class);
|
||||
|
||||
private final UserInteraction userInteraction;
|
||||
|
||||
private Path path = null;
|
||||
private String token, prefix, altprefix, helpWord, playlistsFolder, logLevel,
|
||||
successEmoji, warningEmoji, errorEmoji, loadingEmoji, searchingEmoji,
|
||||
evalEngine, guiTheme;
|
||||
private boolean stayInChannel, songInGame, npImages, npMinimalMessage, npShowButtons, showNpProgressBar, updatealerts, useEval, dbots, useYouTubeOauth, guiEnabled;
|
||||
private long owner, maxSeconds, aloneTimeUntilStop;
|
||||
private long clearChannelAgeDays;
|
||||
private int maxYTPlaylistPages, maxHistorySize, guiFontSize, nasBufferMs, frameBufferMs, proxyPort, clearChannelDeleteLimit;
|
||||
private String proxyHost;
|
||||
private String youtubeRemoteCipherUrl, youtubeRemoteCipherPassword;
|
||||
private boolean proxyLavaplayer, proxyJda, proxyGithub;
|
||||
private double skipratio;
|
||||
private OnlineStatus status;
|
||||
private Activity game;
|
||||
private Config aliases, transforms;
|
||||
private Set<AudioSource> enabledAudioSources;
|
||||
|
||||
private boolean valid = false;
|
||||
|
||||
public BotConfig(UserInteraction userInteraction) {
|
||||
this.userInteraction = userInteraction;
|
||||
}
|
||||
|
||||
public void load() {
|
||||
valid = false;
|
||||
|
||||
try {
|
||||
path = ConfigIO.getConfigPath();
|
||||
|
||||
// Load and migrate config
|
||||
ConfigLoadResult loadResult = loadAndMigrateConfig();
|
||||
|
||||
// Run diagnostics and update config file if needed
|
||||
ConfigLoadResult updatedResult = runDiagnosticsAndUpdate(loadResult);
|
||||
|
||||
// Load all config values
|
||||
loadConfigValues(updatedResult.mergedConfig, updatedResult.migratedUserConfig);
|
||||
|
||||
// Validate required fields and write if needed
|
||||
if (!validateRequiredFields()) {
|
||||
return;
|
||||
}
|
||||
|
||||
valid = true;
|
||||
} catch (ConfigException ex) {
|
||||
userInteraction.alert(Level.ERROR, "Config",
|
||||
ex + ": " + ex.getMessage() + "\n\nConfig Location: " + path.toAbsolutePath().toString());
|
||||
} catch (ConfigMigrationException ex) {
|
||||
LOGGER.error("Config migration failed: {}", ex.getMessage());
|
||||
userInteraction.alert(Level.ERROR, "Config Migration",
|
||||
"Failed to migrate configuration: " + ex.getMessage() + "\n\nConfig Location: " + path.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads raw config, detects migration need, and returns migrated + merged configs.
|
||||
* Parses each resource only once to avoid redundant I/O.
|
||||
*/
|
||||
private ConfigLoadResult loadAndMigrateConfig() {
|
||||
// Parse each resource exactly once
|
||||
Config rawUserConfig = ConfigLoader.loadRawUserConfig(path);
|
||||
Config defaults = ConfigIO.loadDefaults();
|
||||
|
||||
// Detect versions for migration check
|
||||
int userVersion = ConfigMigration.detectVersion(rawUserConfig);
|
||||
int latestVersion = ConfigMigration.getLatestVersion(defaults);
|
||||
|
||||
// Use overloads that accept already-parsed configs to avoid re-parsing
|
||||
Config migratedUserConfig = ConfigLoader.loadMigratedUserConfig(rawUserConfig, defaults);
|
||||
Config mergedConfig = ConfigLoader.mergeWithDefaults(migratedUserConfig, defaults);
|
||||
|
||||
return new ConfigLoadResult(migratedUserConfig, mergedConfig, defaults, userVersion, latestVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs diagnostics and updates config file if migration occurred or issues detected.
|
||||
* Returns updated configs if file was modified.
|
||||
*/
|
||||
private ConfigLoadResult runDiagnosticsAndUpdate(ConfigLoadResult loadResult) {
|
||||
// Fresh install - skip diagnostics since user will be prompted for required fields
|
||||
if (loadResult.migratedUserConfig.isEmpty()) {
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(
|
||||
loadResult.migratedUserConfig, loadResult.mergedConfig, loadResult.defaults);
|
||||
|
||||
logDiagnostics(diagnostics);
|
||||
|
||||
if (loadResult.migrationOccurred() || diagnostics.hasIssues()) {
|
||||
// Determine update type based on original version and diagnostics
|
||||
boolean hasMissingKeys = !diagnostics.getMissingRequired().isEmpty()
|
||||
|| !diagnostics.getMissingOptional().isEmpty();
|
||||
ConfigUpdateType updateType = ConfigUpdateType.determine(
|
||||
loadResult.originalVersion, loadResult.latestVersion, hasMissingKeys);
|
||||
|
||||
Path updatedConfigPath = ConfigUpdater.generateUpdatedConfig(
|
||||
path, loadResult.migratedUserConfig, diagnostics, updateType);
|
||||
if (updatedConfigPath != null) {
|
||||
logConfigUpdate(updateType, updatedConfigPath);
|
||||
|
||||
// Reload configs from the updated file - reuse defaults since they haven't changed
|
||||
Config rawUserConfig = ConfigLoader.loadRawUserConfig(path);
|
||||
Config migratedUserConfig = ConfigLoader.loadMigratedUserConfig(rawUserConfig, loadResult.defaults);
|
||||
Config mergedConfig = ConfigLoader.mergeWithDefaults(migratedUserConfig, loadResult.defaults);
|
||||
return new ConfigLoadResult(migratedUserConfig, mergedConfig,
|
||||
loadResult.defaults, loadResult.originalVersion, loadResult.latestVersion);
|
||||
}
|
||||
}
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs diagnostic issues at appropriate level.
|
||||
*/
|
||||
private void logDiagnostics(ConfigDiagnostics.Report diagnostics) {
|
||||
if (diagnostics.hasIssues()) {
|
||||
if (diagnostics.hasErrors()) {
|
||||
LOGGER.error("Config diagnostics - {}", diagnostics.generateMessage());
|
||||
} else if (diagnostics.hasWarnings()) {
|
||||
LOGGER.warn("Config diagnostics - {}", diagnostics.generateMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs config update message with appropriate wording based on update type.
|
||||
*/
|
||||
private void logConfigUpdate(ConfigUpdateType updateType, Path updatedConfigPath) {
|
||||
LOGGER.info("Config file {} and updated: {}. Original backed up with .bak extension.",
|
||||
updateType.getPastTenseVerb(), updatedConfigPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates token and owner, prompting user if needed. Returns false if validation fails.
|
||||
*/
|
||||
private boolean validateRequiredFields() {
|
||||
ValidationResult tokenResult = ConfigValidator.validateToken(token, userInteraction, path);
|
||||
if (!tokenResult.isValid()) {
|
||||
return false;
|
||||
}
|
||||
token = tokenResult.getValue();
|
||||
boolean needsWrite = tokenResult.needsWrite();
|
||||
|
||||
ValidationResult ownerResult = ConfigValidator.validateOwner(owner, userInteraction, path);
|
||||
if (!ownerResult.isValid()) {
|
||||
return false;
|
||||
}
|
||||
owner = ownerResult.getValue();
|
||||
needsWrite = needsWrite || ownerResult.needsWrite();
|
||||
|
||||
if (needsWrite) {
|
||||
writeToFile();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the result of loading and migrating config.
|
||||
*/
|
||||
private static class ConfigLoadResult {
|
||||
final Config migratedUserConfig;
|
||||
final Config mergedConfig;
|
||||
final Config defaults;
|
||||
final int originalVersion;
|
||||
final int latestVersion;
|
||||
|
||||
ConfigLoadResult(Config migratedUserConfig, Config mergedConfig,
|
||||
Config defaults, int originalVersion, int latestVersion) {
|
||||
this.migratedUserConfig = migratedUserConfig;
|
||||
this.mergedConfig = mergedConfig;
|
||||
this.defaults = defaults;
|
||||
this.originalVersion = originalVersion;
|
||||
this.latestVersion = latestVersion;
|
||||
}
|
||||
|
||||
boolean migrationOccurred() {
|
||||
return originalVersion < latestVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all configuration values from the merged config.
|
||||
*/
|
||||
private void loadConfigValues(Config config, Config migratedUserConfig) {
|
||||
// set values using ConfigOption enum for type safety and standardization
|
||||
token = TOKEN.getString(config);
|
||||
prefix = PREFIX.getString(config);
|
||||
// Handle altPrefix null value by defaulting to "NONE"
|
||||
altprefix = ALTPREFIX.hasValue(config) ? ALTPREFIX.getString(config) : "NONE";
|
||||
helpWord = HELP_WORD.getString(config);
|
||||
owner = OWNER.getLong(config);
|
||||
successEmoji = SUCCESS_EMOJI.getString(config);
|
||||
warningEmoji = WARNING_EMOJI.getString(config);
|
||||
errorEmoji = ERROR_EMOJI.getString(config);
|
||||
loadingEmoji = LOADING_EMOJI.getString(config);
|
||||
searchingEmoji = SEARCHING_EMOJI.getString(config);
|
||||
game = OtherUtil.parseGame(GAME.getString(config));
|
||||
status = OtherUtil.parseStatus(STATUS.getString(config));
|
||||
stayInChannel = STAY_IN_CHANNEL.getBoolean(config);
|
||||
songInGame = SONG_IN_GAME.getBoolean(config);
|
||||
npImages = NP_IMAGES.getBoolean(config);
|
||||
npMinimalMessage = NP_MINIMAL_MESSAGE.getBoolean(config);
|
||||
npShowButtons = NP_SHOW_BUTTONS.getBoolean(config);
|
||||
showNpProgressBar = NP_SHOW_PROGRESS_BAR.getBoolean(config);
|
||||
updatealerts = UPDATE_ALERTS.getBoolean(config);
|
||||
logLevel = LOG_LEVEL.getString(config);
|
||||
useEval = USE_EVAL.getBoolean(config);
|
||||
evalEngine = EVAL_ENGINE.getString(config);
|
||||
maxSeconds = MAX_SECONDS.getLong(config);
|
||||
maxYTPlaylistPages = MAX_YT_PLAYLIST_PAGES.getInt(config);
|
||||
maxHistorySize = MAX_HISTORY_SIZE.getInt(config);
|
||||
useYouTubeOauth = USE_YOUTUBE_OAUTH.getBoolean(config);
|
||||
youtubeRemoteCipherUrl = YOUTUBE_REMOTE_CIPHER_URL.hasValue(config)
|
||||
? YOUTUBE_REMOTE_CIPHER_URL.getString(config) : "";
|
||||
youtubeRemoteCipherPassword = YOUTUBE_REMOTE_CIPHER_PASSWORD.hasValue(config)
|
||||
? YOUTUBE_REMOTE_CIPHER_PASSWORD.getString(config) : "";
|
||||
aloneTimeUntilStop = ALONE_TIME_UNTIL_STOP.getLong(config);
|
||||
playlistsFolder = PLAYLISTS_FOLDER.getString(config);
|
||||
aliases = ALIASES.getConfig(config);
|
||||
transforms = TRANSFORMS.getConfig(config);
|
||||
|
||||
// Handle audiosources - pass migrated user config to check which sources were explicitly set
|
||||
loadAudioSources(config, migratedUserConfig);
|
||||
|
||||
skipratio = SKIP_RATIO.getDouble(config);
|
||||
clearChannelDeleteLimit = Math.max(0, CLEAR_CHANNEL_DELETE_LIMIT.getInt(config));
|
||||
clearChannelAgeDays = Math.max(0L, CLEAR_CHANNEL_AGE_DAYS.getLong(config));
|
||||
dbots = owner == 113156185389092864L;
|
||||
|
||||
// GUI options
|
||||
guiEnabled = GUI_ENABLED.hasValue(config) ? GUI_ENABLED.getBoolean(config) : true;
|
||||
guiTheme = GUI_THEME.getString(config);
|
||||
guiFontSize = GUI_FONT_SIZE.getInt(config);
|
||||
|
||||
// Performance options
|
||||
nasBufferMs = NAS_BUFFER_MS.getInt(config);
|
||||
frameBufferMs = FRAME_BUFFER_MS.getInt(config);
|
||||
|
||||
// Proxy options
|
||||
proxyHost = PROXY_HOST.hasValue(config) ? PROXY_HOST.getString(config) : "";
|
||||
proxyPort = PROXY_PORT.hasValue(config) ? PROXY_PORT.getInt(config) : 0;
|
||||
proxyLavaplayer = PROXY_LAVAPLAYER.hasValue(config) && PROXY_LAVAPLAYER.getBoolean(config);
|
||||
proxyJda = PROXY_JDA.hasValue(config) && PROXY_JDA.getBoolean(config);
|
||||
proxyGithub = PROXY_GITHUB.hasValue(config) && PROXY_GITHUB.getBoolean(config);
|
||||
|
||||
// Log proxy configuration if enabled
|
||||
if (hasProxy()) {
|
||||
if (proxyLavaplayer || proxyJda || proxyGithub) {
|
||||
LOGGER.info("Proxy configured: {}:{} [lavaplayer={}, jda={}, github={}]",
|
||||
proxyHost, proxyPort, proxyLavaplayer, proxyJda, proxyGithub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads audio sources configuration.
|
||||
* All sources are enabled by default (from reference.conf).
|
||||
* Users can disable specific sources by setting them to false.
|
||||
*/
|
||||
private void loadAudioSources(Config config, Config migratedUserConfig) {
|
||||
if (AUDIO_SOURCES.hasValue(config)) {
|
||||
try {
|
||||
Config audioSourcesConfig = AUDIO_SOURCES.getConfig(config);
|
||||
Set<AudioSource> enabled = new java.util.LinkedHashSet<>();
|
||||
|
||||
// Iterate sources in priority order (platform-specific first, catch-alls last)
|
||||
// This ensures the LinkedHashSet maintains the correct registration order
|
||||
for (AudioSource source : AudioSource.valuesSortedByPriority()) {
|
||||
String sourceKey = source.getConfigName();
|
||||
if (audioSourcesConfig.hasPath(sourceKey) && audioSourcesConfig.getBoolean(sourceKey)) {
|
||||
enabled.add(source);
|
||||
}
|
||||
}
|
||||
|
||||
// If no sources ended up enabled (all set to false), enable all sources
|
||||
enabledAudioSources = enabled.isEmpty() ? allAudioSourcesInOrder() : enabled;
|
||||
} catch (ConfigException e) {
|
||||
LOGGER.warn("Failed to parse audioSources config, defaulting to all enabled: {}", e.getMessage());
|
||||
enabledAudioSources = allAudioSourcesInOrder();
|
||||
}
|
||||
} else {
|
||||
// Key not found, enable all sources
|
||||
enabledAudioSources = allAudioSourcesInOrder();
|
||||
}
|
||||
|
||||
LOGGER.info("Enabled audio sources: {}",
|
||||
enabledAudioSources.stream()
|
||||
.map(AudioSource::getConfigName)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all audio sources sorted by registration priority.
|
||||
* Platform-specific sources come first, catch-all sources (HTTP, LOCAL) come last.
|
||||
*/
|
||||
private static Set<AudioSource> allAudioSourcesInOrder() {
|
||||
return new java.util.LinkedHashSet<>(AudioSource.valuesSortedByPriority());
|
||||
}
|
||||
|
||||
private void writeToFile() {
|
||||
try {
|
||||
String content = ConfigIO.loadDefaultConfig()
|
||||
.replace("BOT_TOKEN_HERE", token)
|
||||
.replace("0 # OWNER ID", Long.toString(owner))
|
||||
.trim();
|
||||
ConfigIO.writeConfigFile(path, content);
|
||||
} catch (Exception ex) {
|
||||
userInteraction.alert(Level.WARNING, "Config", "Failed to write new config options to config.txt: " + ex
|
||||
+ "\nPlease make sure that the files are not on your desktop or some other restricted area.\n\nConfig Location: "
|
||||
+ path.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default configuration file.
|
||||
*
|
||||
* @param userInteraction The user interaction handler for displaying progress and errors
|
||||
*/
|
||||
public static void writeDefaultConfig(UserInteraction userInteraction) {
|
||||
userInteraction.alert(Level.INFO, "JMusicBot Config", "Generating default config file");
|
||||
Path path = ConfigIO.getConfigPath();
|
||||
try {
|
||||
userInteraction.alert(Level.INFO, "JMusicBot Config",
|
||||
"Writing default config file to " + path.toAbsolutePath().toString());
|
||||
ConfigIO.writeConfigFile(path, ConfigIO.loadDefaultConfig());
|
||||
} catch (Exception ex) {
|
||||
userInteraction.alert(Level.ERROR, "JMusicBot Config",
|
||||
"An error occurred writing the default config file: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
public String getConfigLocation() {
|
||||
return path.toFile().getAbsolutePath();
|
||||
}
|
||||
|
||||
public String getPrefix() {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
public String getAltPrefix() {
|
||||
return "NONE".equalsIgnoreCase(altprefix) ? null : altprefix;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public double getSkipRatio() {
|
||||
return skipratio;
|
||||
}
|
||||
|
||||
public int getClearChannelDeleteLimit() {
|
||||
return clearChannelDeleteLimit;
|
||||
}
|
||||
|
||||
public long getClearChannelAgeDays() {
|
||||
return clearChannelAgeDays;
|
||||
}
|
||||
|
||||
public long getOwnerId() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public String getSuccess() {
|
||||
return successEmoji;
|
||||
}
|
||||
|
||||
public String getWarning() {
|
||||
return warningEmoji;
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return errorEmoji;
|
||||
}
|
||||
|
||||
public String getLoading() {
|
||||
return loadingEmoji;
|
||||
}
|
||||
|
||||
public String getSearching() {
|
||||
return searchingEmoji;
|
||||
}
|
||||
|
||||
public Activity getGame() {
|
||||
return game;
|
||||
}
|
||||
|
||||
public boolean isGameNone() {
|
||||
return game != null && game.getName().equalsIgnoreCase("none");
|
||||
}
|
||||
|
||||
public OnlineStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getHelp() {
|
||||
return helpWord;
|
||||
}
|
||||
|
||||
public boolean getStay() {
|
||||
return stayInChannel;
|
||||
}
|
||||
|
||||
public boolean getSongInStatus() {
|
||||
return songInGame;
|
||||
}
|
||||
|
||||
public String getPlaylistsFolder() {
|
||||
return playlistsFolder;
|
||||
}
|
||||
|
||||
public boolean getDBots() {
|
||||
return dbots;
|
||||
}
|
||||
|
||||
public boolean useUpdateAlerts() {
|
||||
return updatealerts;
|
||||
}
|
||||
|
||||
public String getLogLevel() {
|
||||
return logLevel;
|
||||
}
|
||||
|
||||
public boolean useEval() {
|
||||
return useEval;
|
||||
}
|
||||
|
||||
public String getEvalEngine() {
|
||||
return evalEngine;
|
||||
}
|
||||
|
||||
public boolean useNPImages() {
|
||||
return npImages;
|
||||
}
|
||||
|
||||
public boolean useMinimalNowPlayingMessage() {
|
||||
return npMinimalMessage;
|
||||
}
|
||||
|
||||
public boolean showNowPlayingButtons() {
|
||||
return npShowButtons;
|
||||
}
|
||||
|
||||
public boolean showNpProgressBar() {
|
||||
return showNpProgressBar;
|
||||
}
|
||||
|
||||
public long getMaxSeconds() {
|
||||
return maxSeconds;
|
||||
}
|
||||
|
||||
public int getMaxYTPlaylistPages() {
|
||||
return maxYTPlaylistPages;
|
||||
}
|
||||
|
||||
public int getMaxHistorySize() {
|
||||
return maxHistorySize;
|
||||
}
|
||||
|
||||
public boolean useYouTubeOauth() {
|
||||
return useYouTubeOauth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the remote cipher server URL used to decipher YouTube stream signatures.
|
||||
*
|
||||
* <p>YouTube rotates its player script frequently, which repeatedly breaks the
|
||||
* signature extractor bundled with youtube-source ("Must find sig function from
|
||||
* script" failures). Offloading deciphering to a remote cipher server keeps
|
||||
* playback working without waiting for a bot update.
|
||||
*
|
||||
* @return the configured URL, or an empty string to use the local extractor
|
||||
*/
|
||||
public String getYoutubeRemoteCipherUrl() {
|
||||
return youtubeRemoteCipherUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the password for the remote cipher server, if it requires one.
|
||||
*
|
||||
* @return the configured password, or an empty string if none is set
|
||||
*/
|
||||
public String getYoutubeRemoteCipherPassword() {
|
||||
return youtubeRemoteCipherPassword;
|
||||
}
|
||||
|
||||
public String getMaxTime() {
|
||||
return TimeUtil.formatTime(maxSeconds * 1000);
|
||||
}
|
||||
|
||||
public long getAloneTimeUntilStop() {
|
||||
return aloneTimeUntilStop;
|
||||
}
|
||||
|
||||
public boolean isTooLong(AudioTrack track) {
|
||||
if (maxSeconds <= 0)
|
||||
return false;
|
||||
return Math.round(track.getDuration() / 1000.0) > maxSeconds;
|
||||
}
|
||||
|
||||
public String[] getAliases(String command) {
|
||||
try {
|
||||
return aliases.getStringList(command).toArray(new String[0]);
|
||||
} catch (NullPointerException | ConfigException.Missing e) {
|
||||
return new String[0];
|
||||
}
|
||||
}
|
||||
|
||||
public Config getTransforms() {
|
||||
return transforms;
|
||||
}
|
||||
|
||||
public Set<AudioSource> getEnabledAudioSources() {
|
||||
return enabledAudioSources;
|
||||
}
|
||||
|
||||
public boolean isAudioSourceEnabled(AudioSource source) {
|
||||
// If the set is empty, no sources are enabled
|
||||
if (enabledAudioSources.isEmpty())
|
||||
return false;
|
||||
return enabledAudioSources.contains(source);
|
||||
}
|
||||
|
||||
public boolean getGuiEnabled() {
|
||||
return guiEnabled;
|
||||
}
|
||||
|
||||
public String getGuiTheme() {
|
||||
return guiTheme;
|
||||
}
|
||||
|
||||
public int getGuiFontSize() {
|
||||
return guiFontSize;
|
||||
}
|
||||
|
||||
public int getNasBufferMs() {
|
||||
return nasBufferMs;
|
||||
}
|
||||
|
||||
public int getFrameBufferMs() {
|
||||
return frameBufferMs;
|
||||
}
|
||||
|
||||
// Proxy getters
|
||||
|
||||
/**
|
||||
* Returns true if a valid proxy is configured (non-empty host and port > 0).
|
||||
*/
|
||||
public boolean hasProxy() {
|
||||
return proxyHost != null && !proxyHost.isEmpty() && proxyPort > 0;
|
||||
}
|
||||
|
||||
public String getProxyHost() {
|
||||
return proxyHost;
|
||||
}
|
||||
|
||||
public int getProxyPort() {
|
||||
return proxyPort;
|
||||
}
|
||||
|
||||
public boolean proxyLavaplayer() {
|
||||
return proxyLavaplayer;
|
||||
}
|
||||
|
||||
public boolean proxyJda() {
|
||||
return proxyJda;
|
||||
}
|
||||
|
||||
public boolean proxyGithub() {
|
||||
return proxyGithub;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.jagrosh.jmusicbot;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandClient;
|
||||
import com.jagrosh.jdautilities.commons.waiter.EventWaiter;
|
||||
import com.jagrosh.jmusicbot.audio.VoiceConnectionMonitor;
|
||||
import com.jagrosh.jmusicbot.entities.UserInteraction.Level;
|
||||
import com.jagrosh.jmusicbot.entities.UserInteraction;
|
||||
import com.jagrosh.jmusicbot.listener.HistoryInteractionListener;
|
||||
import com.jagrosh.jmusicbot.listener.NowPlayingCleanupListener;
|
||||
import com.jagrosh.jmusicbot.listener.PlaylistsInteractionListener;
|
||||
import com.jagrosh.jmusicbot.listener.PlaybackControlsListener;
|
||||
import com.jagrosh.jmusicbot.listener.QueueInteractionListener;
|
||||
import com.jagrosh.jmusicbot.listener.StartupLifecycleListener;
|
||||
import com.jagrosh.jmusicbot.listener.SettingsInteractionListener;
|
||||
import com.jagrosh.jmusicbot.listener.VoiceStateListener;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
import com.jagrosh.jmusicbot.utils.ProxyUtil;
|
||||
import com.sedmelluq.discord.lavaplayer.jdaudp.NativeAudioSendFactory;
|
||||
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.JDABuilder;
|
||||
import net.dv8tion.jda.api.OnlineStatus;
|
||||
import net.dv8tion.jda.api.audio.AudioModuleConfig;
|
||||
import net.dv8tion.jda.api.entities.Activity;
|
||||
import net.dv8tion.jda.api.utils.cache.CacheFlag;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.Proxy;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class DiscordService {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DiscordService.class);
|
||||
|
||||
public static JDA createJDA(BotConfig config, Bot bot, EventWaiter waiter, CommandClient client, UserInteraction userInteraction) throws Exception {
|
||||
JDABuilder jdaBuilder = JDABuilder.create(config.getToken(), Arrays.asList(JMusicBot.INTENTS))
|
||||
.enableCache(CacheFlag.MEMBER_OVERRIDES, CacheFlag.VOICE_STATE)
|
||||
.disableCache(
|
||||
CacheFlag.ACTIVITY,
|
||||
CacheFlag.CLIENT_STATUS,
|
||||
CacheFlag.EMOJI,
|
||||
CacheFlag.ONLINE_STATUS,
|
||||
CacheFlag.STICKER,
|
||||
CacheFlag.SCHEDULED_EVENTS
|
||||
)
|
||||
.setActivity(config.isGameNone() ? null : Activity.playing("loading..."))
|
||||
.setStatus(config.getStatus() == OnlineStatus.INVISIBLE || config.getStatus() == OnlineStatus.OFFLINE
|
||||
? OnlineStatus.INVISIBLE
|
||||
: OnlineStatus.DO_NOT_DISTURB)
|
||||
.addEventListeners(client, waiter,
|
||||
new StartupLifecycleListener(bot),
|
||||
new NowPlayingCleanupListener(bot),
|
||||
new PlaybackControlsListener(bot),
|
||||
new QueueInteractionListener(bot),
|
||||
new HistoryInteractionListener(bot),
|
||||
new PlaylistsInteractionListener(bot),
|
||||
new SettingsInteractionListener(bot),
|
||||
new VoiceStateListener(bot))
|
||||
.setBulkDeleteSplittingEnabled(true)
|
||||
.setAudioModuleConfig(
|
||||
new AudioModuleConfig()
|
||||
.withDaveSessionFactory(new JDaveSessionFactory())
|
||||
// 800ms buffer provides protection against GC pauses up to 800ms
|
||||
.withAudioSendFactory(new NativeAudioSendFactory(config.getNasBufferMs()))
|
||||
)
|
||||
.setEnableShutdownHook(true);
|
||||
|
||||
// Configure proxy for JDA HTTP requests if enabled
|
||||
if (config.proxyJda() && config.hasProxy()) {
|
||||
Proxy proxy = ProxyUtil.createProxy(config);
|
||||
jdaBuilder.setHttpClientBuilder(new OkHttpClient.Builder().proxy(proxy));
|
||||
LOG.info("JDA configured to use proxy: {}:{}", config.getProxyHost(), config.getProxyPort());
|
||||
}
|
||||
|
||||
JDA jda = jdaBuilder.build();
|
||||
|
||||
// Perform post-startup validation
|
||||
String unsupportedReason = OtherUtil.getUnsupportedBotReason(jda);
|
||||
if (unsupportedReason != null) {
|
||||
userInteraction.alert(Level.ERROR, "JMusicBot", "JMusicBot cannot be run on this Discord bot: " + unsupportedReason);
|
||||
jda.shutdown();
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
if (!"@mention".equals(config.getPrefix())) {
|
||||
LOG.info("You currently have a custom prefix set. If it's not working, ensure 'MESSAGE CONTENT INTENT' is enabled.");
|
||||
}
|
||||
|
||||
// Register voice connection monitor for diagnostics
|
||||
VoiceConnectionMonitor.getInstance().register(jda);
|
||||
|
||||
return jda;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh (jagrosh).
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandClient;
|
||||
import com.jagrosh.jdautilities.commons.waiter.EventWaiter;
|
||||
import com.jagrosh.jmusicbot.audio.VoiceConnectionMonitor;
|
||||
import com.jagrosh.jmusicbot.commands.v1.CommandFactory;
|
||||
import com.jagrosh.jmusicbot.entities.Prompt;
|
||||
import com.jagrosh.jmusicbot.entities.UserInteraction;
|
||||
import com.jagrosh.jmusicbot.gui.MainFrame;
|
||||
import com.jagrosh.jmusicbot.gui.theme.ThemeManager;
|
||||
import com.jagrosh.jmusicbot.settings.SettingsManager;
|
||||
import com.jagrosh.jmusicbot.utils.ConsoleUtil;
|
||||
import com.jagrosh.jmusicbot.utils.InstanceLock;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
import com.jagrosh.jmusicbot.utils.TeeOutputStream;
|
||||
|
||||
import java.io.PrintStream;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.exceptions.ErrorResponseException;
|
||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh (jagrosh)
|
||||
*/
|
||||
public class JMusicBot
|
||||
{
|
||||
public final static Logger LOG = LoggerFactory.getLogger(JMusicBot.class);
|
||||
public final static Permission[] RECOMMENDED_PERMS = {
|
||||
Permission.VIEW_CHANNEL,
|
||||
Permission.MESSAGE_SEND,
|
||||
Permission.MESSAGE_HISTORY,
|
||||
Permission.MESSAGE_ADD_REACTION,
|
||||
Permission.MESSAGE_EMBED_LINKS,
|
||||
Permission.MESSAGE_ATTACH_FILES,
|
||||
Permission.MESSAGE_MANAGE,
|
||||
Permission.MESSAGE_EXT_EMOJI,
|
||||
Permission.VOICE_CONNECT,
|
||||
Permission.VOICE_SPEAK,
|
||||
Permission.NICKNAME_CHANGE
|
||||
};
|
||||
public final static GatewayIntent[] INTENTS = {
|
||||
GatewayIntent.DIRECT_MESSAGES,
|
||||
GatewayIntent.GUILD_MESSAGES,
|
||||
GatewayIntent.GUILD_MESSAGE_REACTIONS,
|
||||
GatewayIntent.GUILD_VOICE_STATES,
|
||||
GatewayIntent.MESSAGE_CONTENT
|
||||
};
|
||||
|
||||
/**
|
||||
* @param args the command line arguments
|
||||
*/
|
||||
public static void main(String[] args)
|
||||
{
|
||||
if(args.length > 0) {
|
||||
if (args[0].equalsIgnoreCase("generate-config")) {
|
||||
// Use headless prompt for config generation (nogui=true, noprompt=true)
|
||||
UserInteraction userInteraction = new Prompt(null, null, true, true);
|
||||
BotConfig.writeDefaultConfig(userInteraction);
|
||||
return;
|
||||
}
|
||||
}
|
||||
startBot();
|
||||
}
|
||||
|
||||
private static void startBot()
|
||||
{
|
||||
// Create user interaction handler for startup
|
||||
UserInteraction userInteraction = new Prompt("JMusicBot");
|
||||
|
||||
// Buffer early output when GUI might be used.
|
||||
// We don't know if config has gui.enabled=true yet, so buffer when -Dnogui is not set.
|
||||
// If GUI ends up disabled by config, we restore original streams; otherwise we replay buffer into GUI.
|
||||
PrintStream originalOut = System.out;
|
||||
PrintStream originalErr = System.err;
|
||||
TeeOutputStream teeOut = null;
|
||||
TeeOutputStream teeErr = null;
|
||||
|
||||
if (!userInteraction.isNoGUI())
|
||||
{
|
||||
teeOut = new TeeOutputStream(originalOut);
|
||||
teeErr = new TeeOutputStream(originalErr);
|
||||
System.setOut(new PrintStream(teeOut, true));
|
||||
System.setErr(new PrintStream(teeErr, true));
|
||||
}
|
||||
|
||||
// Check for another running instance
|
||||
if (!InstanceLock.tryAcquire()) {
|
||||
userInteraction.alert(UserInteraction.Level.ERROR, "JMusicBot",
|
||||
"Another instance of JMusicBot is already running.\n" +
|
||||
"Running multiple instances with the same configuration causes duplicate responses to commands.\n" +
|
||||
"Please close the other instance first.");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
// Startup checks
|
||||
OtherUtil.checkVersion(userInteraction);
|
||||
OtherUtil.checkJavaVersion(userInteraction);
|
||||
|
||||
// Load config
|
||||
BotConfig config = new BotConfig(userInteraction);
|
||||
config.load();
|
||||
if (!config.isValid())
|
||||
{
|
||||
// Restore original streams before exiting
|
||||
if (teeOut != null)
|
||||
{
|
||||
System.setOut(originalOut);
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
LOG.info("Loaded config from {}", config.getConfigLocation());
|
||||
|
||||
// Set log level from config
|
||||
((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel(
|
||||
ch.qos.logback.classic.Level.toLevel(config.getLogLevel(), ch.qos.logback.classic.Level.INFO));
|
||||
|
||||
// Single source of truth for GUI: combines -Dnogui and config gui.enabled.
|
||||
// This matches the logic in Bot.isNoGUI().
|
||||
boolean guiEnabled = !userInteraction.isNoGUI() && config.getGuiEnabled();
|
||||
|
||||
// Get buffered early output before we change streams again
|
||||
String earlyOutput = null;
|
||||
if (teeOut != null)
|
||||
{
|
||||
earlyOutput = teeOut.getBufferedContent();
|
||||
teeOut.clearBuffer();
|
||||
if (teeErr != null)
|
||||
{
|
||||
teeErr.clearBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
if (guiEnabled)
|
||||
{
|
||||
// Initialize theme before creating any Swing components
|
||||
try
|
||||
{
|
||||
ThemeManager.initialize(config.getGuiTheme(), config.getGuiFontSize());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.warn("Could not apply theme from config: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Redirect streams to GUI console, replaying buffered early output
|
||||
try
|
||||
{
|
||||
ConsoleUtil.redirectSystemStreamsWithReplay(earlyOutput);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.warn("Could not redirect console streams to GUI. Logs may not appear in GUI console.");
|
||||
// Restore original streams on failure
|
||||
System.setOut(originalOut);
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// GUI disabled: restore original streams (discard buffer)
|
||||
if (teeOut != null)
|
||||
{
|
||||
System.setOut(originalOut);
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the listener
|
||||
EventWaiter waiter = new EventWaiter();
|
||||
SettingsManager settings = new SettingsManager();
|
||||
Bot bot = new Bot(waiter, config, settings, userInteraction);
|
||||
|
||||
// TrackLoadingMonitor is configured via Bot.getAudioLoadWrapper() (DI pattern).
|
||||
VoiceConnectionMonitor.setEnabled(guiEnabled);
|
||||
|
||||
if (guiEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
MainFrame mainFrame = new MainFrame(bot);
|
||||
bot.setGUI(mainFrame);
|
||||
mainFrame.init();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.error("Could not start GUI. Use -Dnogui=true for server environments.", e);
|
||||
userInteraction.alert(UserInteraction.Level.ERROR, "JMusicBot",
|
||||
"Could not start GUI.\nUse -Dnogui=true for server environments.");
|
||||
}
|
||||
}
|
||||
|
||||
CommandClient client = CommandFactory.createCommandClient(config, settings, bot);
|
||||
bot.setCommandClient(client);
|
||||
|
||||
// Now that GUI/Logging is ready, initialize the player manager
|
||||
bot.getPlayerManager().init();
|
||||
|
||||
// attempt to log in and start
|
||||
try
|
||||
{
|
||||
JDA jda = DiscordService.createJDA(config, bot, waiter, client, userInteraction);
|
||||
bot.setJDA(jda);
|
||||
}
|
||||
catch(IllegalArgumentException ex)
|
||||
{
|
||||
userInteraction.alert(UserInteraction.Level.ERROR, "JMusicBot",
|
||||
"Invalid configuration. Check your token.\nConfig Location: " + config.getConfigLocation());
|
||||
System.exit(1);
|
||||
}
|
||||
catch(ErrorResponseException ex)
|
||||
{
|
||||
userInteraction.alert(UserInteraction.Level.ERROR, "JMusicBot", "Invalid response from Discord. Check your internet connection.");
|
||||
System.exit(1);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
LOG.error("An unexpected error occurred during startup", ex);
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2021 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Michaili K ([email protected])
|
||||
*/
|
||||
public class AloneInVoiceHandler
|
||||
{
|
||||
private final Bot bot;
|
||||
private final HashMap<Long, Instant> aloneSince = new HashMap<>();
|
||||
private long aloneTimeUntilStop = 0;
|
||||
|
||||
public AloneInVoiceHandler(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
}
|
||||
|
||||
public void init()
|
||||
{
|
||||
aloneTimeUntilStop = bot.getConfig().getAloneTimeUntilStop();
|
||||
if(aloneTimeUntilStop > 0)
|
||||
bot.getThreadpool().scheduleWithFixedDelay(() -> check(), 0, 5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void check()
|
||||
{
|
||||
Set<Long> toRemove = new HashSet<>();
|
||||
for(Map.Entry<Long, Instant> entrySet: aloneSince.entrySet())
|
||||
{
|
||||
if(entrySet.getValue().getEpochSecond() > Instant.now().getEpochSecond() - aloneTimeUntilStop) continue;
|
||||
|
||||
Guild guild = bot.getJDA().getGuildById(entrySet.getKey());
|
||||
|
||||
if(guild == null)
|
||||
{
|
||||
toRemove.add(entrySet.getKey());
|
||||
continue;
|
||||
}
|
||||
|
||||
((AudioHandler) guild.getAudioManager().getSendingHandler()).stopAndClear();
|
||||
guild.getAudioManager().closeAudioConnection();
|
||||
|
||||
toRemove.add(entrySet.getKey());
|
||||
}
|
||||
toRemove.forEach(id -> aloneSince.remove(id));
|
||||
}
|
||||
|
||||
public void onVoiceUpdate(GuildVoiceUpdateEvent event)
|
||||
{
|
||||
if(aloneTimeUntilStop <= 0) return;
|
||||
|
||||
Guild guild = event.getEntity().getGuild();
|
||||
if(!bot.getPlayerManager().hasHandler(guild)) return;
|
||||
|
||||
boolean alone = isAlone(guild);
|
||||
boolean inList = aloneSince.containsKey(guild.getIdLong());
|
||||
|
||||
if(!alone && inList)
|
||||
aloneSince.remove(guild.getIdLong());
|
||||
else if(alone && !inList)
|
||||
aloneSince.put(guild.getIdLong(), Instant.now());
|
||||
}
|
||||
|
||||
private boolean isAlone(Guild guild)
|
||||
{
|
||||
if(guild.getAudioManager().getConnectedChannel() == null) return false;
|
||||
return guild.getAudioManager().getConnectedChannel().getMembers().stream()
|
||||
.noneMatch(x ->
|
||||
!x.getVoiceState().isDeafened()
|
||||
&& !x.getUser().isBot());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Modified 2026: track-failure handling now detects YouTube sign-in failures across the
|
||||
* whole exception graph, including suppressed per-client errors and typographic
|
||||
* apostrophes.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist;
|
||||
import com.jagrosh.jmusicbot.queue.AbstractQueue;
|
||||
import com.jagrosh.jmusicbot.settings.QueueType;
|
||||
import com.jagrosh.jmusicbot.settings.RepeatMode;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import com.jagrosh.jmusicbot.utils.MessageFormatter;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.audio.AudioSendHandler;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
|
||||
{
|
||||
public final static String PLAY_EMOJI = "\u25B6"; // ▶
|
||||
public final static String PAUSE_EMOJI = "\u23F8"; // ⏸
|
||||
public final static String STOP_EMOJI = "\u23F9"; // ⏹
|
||||
|
||||
private final static Logger LOGGER = LoggerFactory.getLogger(AudioHandler.class);
|
||||
private final List<AudioTrack> defaultQueue = new LinkedList<>();
|
||||
private final Set<String> votes = new HashSet<>();
|
||||
|
||||
private final PlayerManager manager;
|
||||
private final AudioPlayer audioPlayer;
|
||||
private final long guildId;
|
||||
/** Listener for audio metrics events. Uses NO_OP implementation in no-GUI mode. */
|
||||
private final AudioMetricsListener metricsListener;
|
||||
|
||||
private AudioFrame lastFrame;
|
||||
private AbstractQueue<QueuedTrack> queue;
|
||||
private String lastReason = null;
|
||||
private volatile String favoritedTrackUri = null;
|
||||
|
||||
protected AudioHandler(PlayerManager manager, Guild guild, AudioPlayer player)
|
||||
{
|
||||
this.manager = manager;
|
||||
this.audioPlayer = player;
|
||||
this.guildId = guild.getIdLong();
|
||||
// Use NO_OP listener in no-GUI mode to avoid memory allocation
|
||||
this.metricsListener = manager.getBot().isNoGUI()
|
||||
? AudioMetricsListener.NO_OP
|
||||
: new PerformanceMetrics(guildId);
|
||||
|
||||
int maxHistorySize = manager.getBot().getConfig().getMaxHistorySize();
|
||||
QueueType queueType = manager.getBot().getSettingsManager().getSettings(guildId).getQueueType();
|
||||
this.queue = queueType.createInstance(null, maxHistorySize);
|
||||
}
|
||||
|
||||
public void setQueueType(QueueType type)
|
||||
{
|
||||
// History is preserved when switching queue types
|
||||
int maxHistorySize = manager.getBot().getConfig().getMaxHistorySize();
|
||||
queue = type.createInstance(queue, maxHistorySize);
|
||||
}
|
||||
|
||||
public void setLastReason(String reason)
|
||||
{
|
||||
this.lastReason = reason;
|
||||
}
|
||||
|
||||
public int addTrackToFront(QueuedTrack qtrack)
|
||||
{
|
||||
if(audioPlayer.getPlayingTrack()==null)
|
||||
{
|
||||
audioPlayer.playTrack(qtrack.getTrack());
|
||||
return -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
LOGGER.debug("Added track to front of queue: {}", qtrack.getTrack().getInfo().title);
|
||||
queue.addAt(0, qtrack);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public int addTrack(QueuedTrack qtrack)
|
||||
{
|
||||
if(audioPlayer.getPlayingTrack()==null)
|
||||
{
|
||||
audioPlayer.playTrack(qtrack.getTrack());
|
||||
return -1;
|
||||
}
|
||||
|
||||
LOGGER.debug("Added track to queue: {}", qtrack.getTrack().getInfo().title);
|
||||
return queue.add(qtrack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the playback history from the queue.
|
||||
* Most recent tracks are at index 0.
|
||||
*
|
||||
* @return A list of previously played tracks
|
||||
*/
|
||||
public List<QueuedTrack> getPreviousTracks()
|
||||
{
|
||||
return queue.getHistory().getList();
|
||||
}
|
||||
|
||||
public AbstractQueue<QueuedTrack> getQueue()
|
||||
{
|
||||
return queue;
|
||||
}
|
||||
|
||||
public void stopAndClear()
|
||||
{
|
||||
LOGGER.debug("Stopping and clearing queue");
|
||||
queue.clearAll();
|
||||
defaultQueue.clear();
|
||||
audioPlayer.stopTrack();
|
||||
//current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops playback and clears only active queues while preserving history.
|
||||
* Playback history is maintained from track start events.
|
||||
*/
|
||||
public void stopAndClearQueuePreserveHistory()
|
||||
{
|
||||
LOGGER.debug("Stopping playback and clearing queue (preserving history)");
|
||||
queue.clear();
|
||||
defaultQueue.clear();
|
||||
audioPlayer.stopTrack();
|
||||
}
|
||||
|
||||
public boolean isMusicPlaying(JDA jda)
|
||||
{
|
||||
// Check that the selfMember is connected to a channel where they can receive audio
|
||||
// Check that the audioPlayer has a playingTrack
|
||||
var isBotConnectedToVoice = jda.getGuildById(guildId).getSelfMember().getVoiceState().getChannel() != null;
|
||||
var isAudioPlaying = audioPlayer.getPlayingTrack() != null;
|
||||
return isBotConnectedToVoice && isAudioPlaying;
|
||||
}
|
||||
|
||||
public Set<String> getVotes()
|
||||
{
|
||||
return votes;
|
||||
}
|
||||
|
||||
public AudioPlayer getPlayer()
|
||||
{
|
||||
return audioPlayer;
|
||||
}
|
||||
|
||||
public RequestMetadata getRequestMetadata()
|
||||
{
|
||||
if(audioPlayer.getPlayingTrack() == null)
|
||||
return RequestMetadata.EMPTY;
|
||||
RequestMetadata rm = audioPlayer.getPlayingTrack().getUserData(RequestMetadata.class);
|
||||
return rm == null ? RequestMetadata.EMPTY : rm;
|
||||
}
|
||||
|
||||
public boolean playFromDefault()
|
||||
{
|
||||
if(!defaultQueue.isEmpty())
|
||||
{
|
||||
audioPlayer.playTrack(defaultQueue.remove(0));
|
||||
return true;
|
||||
}
|
||||
Settings settings = manager.getBot().getSettingsManager().getSettings(guildId);
|
||||
if(settings==null || settings.getDefaultPlaylist()==null)
|
||||
return false;
|
||||
|
||||
Playlist pl = manager.getBot().getPlaylistLoader().getPlaylist(settings.getDefaultPlaylist());
|
||||
if(pl==null || pl.getItems().isEmpty())
|
||||
return false;
|
||||
pl.loadTracks(manager, (at) ->
|
||||
{
|
||||
if(audioPlayer.getPlayingTrack()==null)
|
||||
audioPlayer.playTrack(at);
|
||||
else
|
||||
defaultQueue.add(at);
|
||||
}, () ->
|
||||
{
|
||||
if(pl.getTracks().isEmpty() && !manager.getBot().getConfig().getStay())
|
||||
manager.getBot().closeAudioConnection(guildId);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Audio Events
|
||||
@Override
|
||||
public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason)
|
||||
{
|
||||
// Record track end for timeline
|
||||
String trackTitle = null;
|
||||
String trackUri = null;
|
||||
if (track != null && track.getInfo() != null) {
|
||||
trackTitle = track.getInfo().title;
|
||||
trackUri = track.getInfo().uri;
|
||||
}
|
||||
metricsListener.onTrackEnd(trackTitle, trackUri);
|
||||
|
||||
// Log track end with details for debugging
|
||||
if (endReason != AudioTrackEndReason.FINISHED) {
|
||||
LOGGER.debug("Track {} ended with reason: {} (Track: {})",
|
||||
track != null ? track.getIdentifier() : "null",
|
||||
endReason.name(),
|
||||
trackTitle != null ? trackTitle : "N/A");
|
||||
}
|
||||
else if (track != null && track.getInfo() != null) {
|
||||
LOGGER.debug("Track ended: {} Reason: {}", track.getInfo().title, endReason);
|
||||
}
|
||||
|
||||
RepeatMode repeatMode = manager.getBot().getSettingsManager().getSettings(guildId).getRepeatMode();
|
||||
// if the track ended normally, and we're in repeat mode, re-add it to the queue
|
||||
if(endReason==AudioTrackEndReason.FINISHED && repeatMode != RepeatMode.OFF)
|
||||
{
|
||||
QueuedTrack clone = new QueuedTrack(track.makeClone(), track.getUserData(RequestMetadata.class));
|
||||
if(repeatMode == RepeatMode.ALL)
|
||||
{
|
||||
queue.add(clone);
|
||||
lastReason = "Repeating the queue.";
|
||||
}
|
||||
else
|
||||
{
|
||||
queue.addAt(0, clone);
|
||||
lastReason = "Repeating the song.";
|
||||
}
|
||||
}
|
||||
|
||||
if(queue.isEmpty())
|
||||
{
|
||||
if(!playFromDefault())
|
||||
{
|
||||
lastReason = null;
|
||||
manager.getBot().getNowplayingHandler().onTrackUpdate(guildId, null);
|
||||
if(!manager.getBot().getConfig().getStay())
|
||||
manager.getBot().closeAudioConnection(guildId);
|
||||
// unpause, in the case when the player was paused and the track has been skipped.
|
||||
// this is to prevent the player being paused next time it's being used.
|
||||
player.setPaused(false);
|
||||
}
|
||||
}
|
||||
else if (endReason != AudioTrackEndReason.REPLACED)
|
||||
{
|
||||
QueuedTrack qt = queue.pull();
|
||||
if (lastReason == null || (!lastReason.startsWith("Repeating") && !lastReason.startsWith("Skipped")))
|
||||
lastReason = "Playing next song.";
|
||||
player.playTrack(qt.getTrack());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyException exception) {
|
||||
// Record exception for timeline
|
||||
String trackTitle = null;
|
||||
String trackUri = null;
|
||||
if (track != null && track.getInfo() != null) {
|
||||
trackTitle = track.getInfo().title;
|
||||
trackUri = track.getInfo().uri;
|
||||
}
|
||||
metricsListener.onTrackException(trackTitle, trackUri);
|
||||
|
||||
// Build detailed error message with track information
|
||||
StringBuilder errorDetails = new StringBuilder();
|
||||
errorDetails.append("Track exception occurred:\n");
|
||||
errorDetails.append(" Track ID: ").append(track.getIdentifier()).append("\n");
|
||||
|
||||
AudioTrackInfo info = track.getInfo();
|
||||
if (info != null) {
|
||||
errorDetails.append(" Title: ").append(info.title != null ? info.title : "N/A").append("\n");
|
||||
errorDetails.append(" URI: ").append(info.uri != null ? info.uri : "N/A").append("\n");
|
||||
errorDetails.append(" Author: ").append(info.author != null ? info.author : "N/A").append("\n");
|
||||
errorDetails.append(" Duration: ").append(info.length > 0 ? info.length + "ms" : "Unknown").append("\n");
|
||||
errorDetails.append(" Source: ").append(track.getSourceManager() != null ? track.getSourceManager().getSourceName() : "Unknown").append("\n");
|
||||
}
|
||||
|
||||
errorDetails.append(" Exception Severity: ").append(exception.severity != null ? exception.severity.name() : "UNKNOWN").append("\n");
|
||||
errorDetails.append(" Exception Message: ").append(exception.getMessage() != null ? exception.getMessage() : "N/A").append("\n");
|
||||
|
||||
// Log request metadata if available
|
||||
RequestMetadata rm = track.getUserData(RequestMetadata.class);
|
||||
if (rm != null && rm.user != null) {
|
||||
errorDetails.append(" Requested by: ").append(rm.user.username).append(" (ID: ").append(rm.user.id).append(")\n");
|
||||
}
|
||||
if (rm != null && rm.requestInfo != null) {
|
||||
errorDetails.append(" Original query: ").append(rm.requestInfo.query != null ? rm.requestInfo.query : "N/A").append("\n");
|
||||
}
|
||||
|
||||
// Log root cause if available
|
||||
Throwable cause = exception.getCause();
|
||||
if (cause != null) {
|
||||
errorDetails.append(" Root Cause: ").append(cause.getClass().getSimpleName()).append(" - ").append(cause.getMessage()).append("\n");
|
||||
// Log specific error details for common issues
|
||||
if (cause instanceof IllegalStateException) {
|
||||
errorDetails.append(" IllegalStateException details: ").append(cause.getMessage()).append("\n");
|
||||
} else if (cause instanceof com.fasterxml.jackson.core.JsonParseException) {
|
||||
com.fasterxml.jackson.core.JsonParseException jsonEx = (com.fasterxml.jackson.core.JsonParseException) cause;
|
||||
errorDetails.append(" JSON Parse Error at line ").append(jsonEx.getLocation().getLineNr())
|
||||
.append(", column ").append(jsonEx.getLocation().getColumnNr()).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for YouTube OAuth errors
|
||||
if (requiresYoutubeLogin(exception))
|
||||
{
|
||||
LOGGER.error(
|
||||
"Track {} has failed to play: {}. "
|
||||
+ "YouTube now requires a signed-in session for this video. "
|
||||
+ "Set playback.youtube.useOAuth = true in your config and restart, "
|
||||
+ "then follow the sign-in code printed here on startup. "
|
||||
+ "More info: https://jmusicbot.com/youtube-oauth2\n{}",
|
||||
track.getIdentifier(),
|
||||
describeYoutubeLoginFailure(exception),
|
||||
errorDetails.toString()
|
||||
);
|
||||
}
|
||||
else {
|
||||
LOGGER.error("Track {} has failed to play\n{}", track.getIdentifier(), errorDetails.toString(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phrases YouTube returns when a track can only be played by a signed-in session.
|
||||
*
|
||||
* <p>Stored lowercase and with typographic apostrophes folded to ASCII, because
|
||||
* YouTube writes "you\u2019re" with U+2019 rather than an ASCII quote.
|
||||
*/
|
||||
private static final String[] YOUTUBE_LOGIN_MARKERS = {
|
||||
"sign in to confirm you're not a bot",
|
||||
"please sign in",
|
||||
"this video requires login",
|
||||
"confirm your age"
|
||||
};
|
||||
|
||||
/**
|
||||
* Reports whether a playback failure was YouTube demanding an authenticated session.
|
||||
*
|
||||
* <p>The check walks the entire throwable graph rather than testing
|
||||
* {@code exception.getMessage()} alone. youtube-source tries several clients and,
|
||||
* when they all fail, reports an {@code AllClientsFailedException} whose own message
|
||||
* is just "All clients failed to load the item." - the actionable per-client reasons
|
||||
* hang off it as causes and suppressed exceptions. Matching only the top-level
|
||||
* message meant this hint never fired for the failure it was written for.
|
||||
*/
|
||||
private static boolean requiresYoutubeLogin(Throwable throwable)
|
||||
{
|
||||
return containsLoginMarker(flattenMessages(throwable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the specific login-related reason from the throwable graph, for logging.
|
||||
*/
|
||||
private static String describeYoutubeLoginFailure(Throwable throwable)
|
||||
{
|
||||
for (String message : flattenMessages(throwable).split("\n"))
|
||||
{
|
||||
if (containsLoginMarker(message))
|
||||
{
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
return throwable.getMessage() != null ? throwable.getMessage() : "sign-in required";
|
||||
}
|
||||
|
||||
private static boolean containsLoginMarker(String normalisedText)
|
||||
{
|
||||
for (String marker : YOUTUBE_LOGIN_MARKERS)
|
||||
{
|
||||
if (normalisedText.contains(marker))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the messages of a throwable, its causes and its suppressed exceptions into
|
||||
* one normalised, newline-separated block.
|
||||
*/
|
||||
private static String flattenMessages(Throwable throwable)
|
||||
{
|
||||
return collectMessages(throwable, new HashSet<>(), new StringBuilder()).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive worker for {@link #flattenMessages(Throwable)}. The visited set guards
|
||||
* against cycles, which self-referencing causes can otherwise turn into infinite
|
||||
* recursion.
|
||||
*/
|
||||
private static StringBuilder collectMessages(Throwable throwable, Set<Throwable> visited, StringBuilder out)
|
||||
{
|
||||
if (throwable == null || !visited.add(throwable))
|
||||
{
|
||||
return out;
|
||||
}
|
||||
|
||||
String message = throwable.getMessage();
|
||||
if (message != null)
|
||||
{
|
||||
out.append(message.toLowerCase()
|
||||
.replace('\u2019', '\'')
|
||||
.replace('\u2018', '\''))
|
||||
.append('\n');
|
||||
}
|
||||
|
||||
collectMessages(throwable.getCause(), visited, out);
|
||||
for (Throwable suppressed : throwable.getSuppressed())
|
||||
{
|
||||
collectMessages(suppressed, visited, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrackStart(AudioPlayer player, AudioTrack track)
|
||||
{
|
||||
// Access the metadata object
|
||||
var info = track.getInfo();
|
||||
|
||||
LOGGER.debug("Track Started Details:");
|
||||
LOGGER.debug(" - Title: {}", info.title);
|
||||
LOGGER.debug(" - Author: {}", info.author);
|
||||
LOGGER.debug(" - Duration: {} ms", info.length);
|
||||
LOGGER.debug(" - Identifier: {}", info.identifier);
|
||||
LOGGER.debug(" - URI: {}", info.uri);
|
||||
LOGGER.debug(" - Is Stream: {}", info.isStream);
|
||||
LOGGER.debug(" - Source: {}", track.getSourceManager() != null ? track.getSourceManager().getSourceName() : "unknown");
|
||||
LOGGER.debug(" - Player Vol: {}", player.getVolume());
|
||||
LOGGER.debug(" - Is Paused: {}", player.isPaused());
|
||||
votes.clear();
|
||||
metricsListener.onSessionReset(); // Reset metrics for new track
|
||||
|
||||
// Record track start for timeline and time-to-first-frame tracking
|
||||
String trackTitle = null;
|
||||
String trackUri = null;
|
||||
if (track != null && track.getInfo() != null) {
|
||||
trackTitle = track.getInfo().title;
|
||||
trackUri = track.getInfo().uri;
|
||||
|
||||
LOGGER.debug("Starting track: {} (ID: {}, URI: {}, Source: {})",
|
||||
trackTitle,
|
||||
track.getIdentifier(),
|
||||
trackUri,
|
||||
track.getSourceManager() != null ? track.getSourceManager().getSourceName() : "Unknown");
|
||||
}
|
||||
metricsListener.onTrackStart(trackTitle, trackUri);
|
||||
if (track != null)
|
||||
{
|
||||
QueuedTrack startedTrack = new QueuedTrack(track.makeClone(), track.getUserData(RequestMetadata.class));
|
||||
queue.addToHistory(startedTrack);
|
||||
}
|
||||
|
||||
if (lastReason == null)
|
||||
lastReason = "Playing next song.";
|
||||
|
||||
manager.getBot().getNowplayingHandler().onTrackUpdate(guildId, track);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs)
|
||||
{
|
||||
// Record the stuck event in performance metrics
|
||||
String trackTitle = null;
|
||||
String trackUri = null;
|
||||
|
||||
if (track != null && track.getInfo() != null) {
|
||||
trackTitle = track.getInfo().title;
|
||||
trackUri = track.getInfo().uri;
|
||||
}
|
||||
|
||||
metricsListener.onTrackStuck(thresholdMs, trackTitle, trackUri);
|
||||
|
||||
// Build detailed log message
|
||||
StringBuilder details = new StringBuilder();
|
||||
details.append("Track stuck detected (decoder/stream stall):\n");
|
||||
details.append(" Threshold exceeded: ").append(thresholdMs).append("ms\n");
|
||||
|
||||
if (track != null) {
|
||||
details.append(" Track ID: ").append(track.getIdentifier()).append("\n");
|
||||
AudioTrackInfo info = track.getInfo();
|
||||
if (info != null) {
|
||||
details.append(" Title: ").append(info.title != null ? info.title : "N/A").append("\n");
|
||||
details.append(" URI: ").append(info.uri != null ? info.uri : "N/A").append("\n");
|
||||
details.append(" Position: ").append(track.getPosition()).append("ms / ")
|
||||
.append(info.length).append("ms\n");
|
||||
details.append(" Source: ").append(track.getSourceManager() != null
|
||||
? track.getSourceManager().getSourceName() : "Unknown").append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Log request metadata if available
|
||||
RequestMetadata rm = track != null ? track.getUserData(RequestMetadata.class) : null;
|
||||
if (rm != null && rm.user != null) {
|
||||
details.append(" Requested by: ").append(rm.user.username)
|
||||
.append(" (ID: ").append(rm.user.id).append(")\n");
|
||||
}
|
||||
|
||||
LOGGER.warn("Track {} is stuck after {}ms\n{}",
|
||||
track != null ? track.getIdentifier() : "null",
|
||||
thresholdMs,
|
||||
details.toString());
|
||||
}
|
||||
|
||||
//
|
||||
public NowPlayingInfo getNowPlayingInfo(JDA jda)
|
||||
{
|
||||
return new NowPlayingInfo(
|
||||
audioPlayer.getPlayingTrack(),
|
||||
jda.getGuildById(guildId),
|
||||
audioPlayer.isPaused(),
|
||||
audioPlayer.getVolume(),
|
||||
queue.size(),
|
||||
queue.getHistory().size(),
|
||||
isCurrentTrackFavorited(),
|
||||
lastReason
|
||||
);
|
||||
}
|
||||
|
||||
// Formatting
|
||||
public MessageCreateData getNowPlaying(JDA jda)
|
||||
{
|
||||
if(isMusicPlaying(jda))
|
||||
return MessageFormatter.buildNowPlayingMessage(manager.getBot(), getNowPlayingInfo(jda));
|
||||
return null;
|
||||
}
|
||||
|
||||
public MessageCreateData getNoMusicPlaying(JDA jda)
|
||||
{
|
||||
return MessageFormatter.buildNoMusicPlayingMessage(manager.getBot(), getNowPlayingInfo(jda));
|
||||
}
|
||||
|
||||
public String getStatusEmoji()
|
||||
{
|
||||
return audioPlayer.isPaused() ? PAUSE_EMOJI : PLAY_EMOJI;
|
||||
}
|
||||
|
||||
public void markCurrentTrackFavorited(String uri)
|
||||
{
|
||||
favoritedTrackUri = uri;
|
||||
}
|
||||
|
||||
public boolean isCurrentTrackFavorited()
|
||||
{
|
||||
AudioTrack currentTrack = audioPlayer.getPlayingTrack();
|
||||
if (currentTrack == null || currentTrack.getInfo() == null)
|
||||
return false;
|
||||
String currentUri = currentTrack.getInfo().uri;
|
||||
return currentUri != null && currentUri.equals(favoritedTrackUri);
|
||||
}
|
||||
|
||||
// Audio Send Handler methods
|
||||
@Override
|
||||
public boolean canProvide()
|
||||
{
|
||||
long startNanos = System.nanoTime();
|
||||
lastFrame = audioPlayer.provide();
|
||||
long latencyNanos = System.nanoTime() - startNanos;
|
||||
|
||||
boolean frameAvailable = lastFrame != null;
|
||||
metricsListener.onFrameProvided(frameAvailable, latencyNanos);
|
||||
|
||||
return frameAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the performance metrics for this audio handler.
|
||||
*
|
||||
* @return the performance metrics instance, or null if running in no-GUI mode
|
||||
*/
|
||||
public PerformanceMetrics getPerformanceMetrics() {
|
||||
return metricsListener instanceof PerformanceMetrics
|
||||
? (PerformanceMetrics) metricsListener
|
||||
: null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ByteBuffer provide20MsAudio()
|
||||
{
|
||||
return ByteBuffer.wrap(lastFrame.getData());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOpus()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
||||
|
||||
/**
|
||||
* Interface for wrapping audio load result handlers to add monitoring/metrics.
|
||||
* When GUI is disabled, the NO_OP implementation passes through the delegate directly.
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public interface AudioLoadWrapper {
|
||||
|
||||
/**
|
||||
* Wraps an AudioLoadResultHandler to optionally add monitoring.
|
||||
*
|
||||
* @param query the search query or URL being loaded
|
||||
* @param delegate the original handler to delegate to
|
||||
* @return a wrapped handler (or the delegate directly for NO_OP)
|
||||
*/
|
||||
AudioLoadResultHandler wrap(String query, AudioLoadResultHandler delegate);
|
||||
|
||||
/**
|
||||
* No-operation wrapper that passes through the delegate without monitoring.
|
||||
* Used when GUI is disabled to avoid allocating monitoring resources.
|
||||
*/
|
||||
AudioLoadWrapper NO_OP = (query, delegate) -> delegate;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
/**
|
||||
* Listener interface for audio performance metrics events.
|
||||
* Used by AudioHandler to report audio events for performance monitoring.
|
||||
*
|
||||
* <p>When GUI is enabled, {@link PerformanceMetrics} implements this interface
|
||||
* to collect metrics. When GUI is disabled, the {@link #NO_OP} singleton is used
|
||||
* to avoid any overhead.
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public interface AudioMetricsListener {
|
||||
|
||||
/**
|
||||
* Called when an audio frame is requested.
|
||||
*
|
||||
* @param provided true if audio frame was available, false if missed
|
||||
* @param latencyNanos time taken to get frame from Lavaplayer in nanoseconds
|
||||
*/
|
||||
void onFrameProvided(boolean provided, long latencyNanos);
|
||||
|
||||
/**
|
||||
* Called when a track starts playing.
|
||||
*
|
||||
* @param title the track title (may be null)
|
||||
* @param uri the track URI (may be null)
|
||||
*/
|
||||
void onTrackStart(String title, String uri);
|
||||
|
||||
/**
|
||||
* Called when a track ends.
|
||||
*
|
||||
* @param title the track title (may be null)
|
||||
* @param uri the track URI (may be null)
|
||||
*/
|
||||
void onTrackEnd(String title, String uri);
|
||||
|
||||
/**
|
||||
* Called when a track throws an exception.
|
||||
*
|
||||
* @param title the track title (may be null)
|
||||
* @param uri the track URI (may be null)
|
||||
*/
|
||||
void onTrackException(String title, String uri);
|
||||
|
||||
/**
|
||||
* Called when a track gets stuck.
|
||||
*
|
||||
* @param thresholdMs the threshold (in ms) that was exceeded
|
||||
* @param title the track title (may be null)
|
||||
* @param uri the track URI (may be null)
|
||||
*/
|
||||
void onTrackStuck(long thresholdMs, String title, String uri);
|
||||
|
||||
/**
|
||||
* Called when the session should be reset (typically when a new track starts).
|
||||
*/
|
||||
void onSessionReset();
|
||||
|
||||
/**
|
||||
* No-op implementation for when metrics collection is disabled (no-GUI mode).
|
||||
* All methods are empty and SHOULD be inlined by the JIT compiler.
|
||||
*/
|
||||
AudioMetricsListener NO_OP = new AudioMetricsListener() {
|
||||
@Override
|
||||
public void onFrameProvided(boolean provided, long latencyNanos) {}
|
||||
|
||||
@Override
|
||||
public void onTrackStart(String title, String uri) {}
|
||||
|
||||
@Override
|
||||
public void onTrackEnd(String title, String uri) {}
|
||||
|
||||
@Override
|
||||
public void onTrackException(String title, String uri) {}
|
||||
|
||||
@Override
|
||||
public void onTrackStuck(long thresholdMs, String title, String uri) {}
|
||||
|
||||
@Override
|
||||
public void onSessionReset() {}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Modified 2026: YouTube playback fixes - the remote signature cipher is now applied
|
||||
* independently of OAuth and is configurable, and the client list adds the ANDROID
|
||||
* client and is ordered by reliability.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry;
|
||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers;
|
||||
import com.sedmelluq.discord.lavaplayer.source.bandcamp.BandcampAudioSourceManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.beam.BeamAudioSourceManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.getyarn.GetyarnAudioSourceManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.nico.NicoAudioSourceManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.twitch.TwitchStreamAudioSourceManager;
|
||||
import com.sedmelluq.discord.lavaplayer.source.vimeo.VimeoAudioSourceManager;
|
||||
import dev.lavalink.youtube.YoutubeAudioSourceManager;
|
||||
import dev.lavalink.youtube.YoutubeSourceOptions;
|
||||
import dev.lavalink.youtube.clients.AndroidVrWithThumbnail;
|
||||
import dev.lavalink.youtube.clients.AndroidWithThumbnail;
|
||||
import dev.lavalink.youtube.clients.ClientOptions;
|
||||
import dev.lavalink.youtube.clients.MWebWithThumbnail;
|
||||
import dev.lavalink.youtube.clients.Tv;
|
||||
import dev.lavalink.youtube.clients.TvHtml5SimplyWithThumbnail;
|
||||
import dev.lavalink.youtube.clients.WebWithThumbnail;
|
||||
import dev.lavalink.youtube.clients.skeleton.Client;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.jagrosh.jmusicbot.BotConfig;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
|
||||
/**
|
||||
* Enum representing available audio sources that can be listed in the config file.
|
||||
*
|
||||
* <p><b>Registration Order Matters:</b> Sources are registered with the audio player manager
|
||||
* in order of their {@link #getRegistrationPriority() registrationPriority}. When a URL is
|
||||
* played, sources are checked in registration order until one claims the URL. This means:
|
||||
* <ul>
|
||||
* <li>Platform-specific sources (YouTube, SoundCloud, etc.) should have LOW priority numbers
|
||||
* so they are registered FIRST and get first chance to claim their URLs</li>
|
||||
* <li>Catch-all sources (HTTP, LOCAL) should have HIGH priority numbers so they are
|
||||
* registered LAST and only claim URLs that no specific source wanted</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>If HTTP is registered before SoundCloud, it will claim SoundCloud URLs and fail to play them.
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public enum AudioSource
|
||||
{
|
||||
// Platform-specific sources (priority 10-90) - registered first to claim their URLs
|
||||
YOUTUBE(
|
||||
"youtube",
|
||||
"YouTube videos and playlists",
|
||||
10,
|
||||
(manager, config) -> {
|
||||
YoutubeAudioSourceManager yt = setupYoutubeAudioSourceManager(config);
|
||||
manager.registerSourceManager(yt);
|
||||
}
|
||||
),
|
||||
SOUNDCLOUD(
|
||||
"soundcloud",
|
||||
"SoundCloud tracks",
|
||||
20,
|
||||
(manager, config) -> manager.registerSourceManager(SoundCloudAudioSourceManager.createDefault())
|
||||
),
|
||||
BANDCAMP(
|
||||
"bandcamp",
|
||||
"Bandcamp albums and tracks",
|
||||
30,
|
||||
(manager, config) -> manager.registerSourceManager(new BandcampAudioSourceManager())
|
||||
),
|
||||
VIMEO(
|
||||
"vimeo",
|
||||
"Vimeo videos",
|
||||
40,
|
||||
(manager, config) -> manager.registerSourceManager(new VimeoAudioSourceManager())
|
||||
),
|
||||
TWITCH(
|
||||
"twitch",
|
||||
"Twitch streams",
|
||||
50,
|
||||
(manager, config) -> manager.registerSourceManager(new TwitchStreamAudioSourceManager())
|
||||
),
|
||||
BEAM(
|
||||
"beam",
|
||||
"Beam.pro streams",
|
||||
60,
|
||||
(manager, config) -> manager.registerSourceManager(new BeamAudioSourceManager())
|
||||
),
|
||||
GETYARN(
|
||||
"getyarn",
|
||||
"Getyarn.io clips",
|
||||
70,
|
||||
(manager, config) -> manager.registerSourceManager(new GetyarnAudioSourceManager())
|
||||
),
|
||||
NICO(
|
||||
"nico",
|
||||
"NicoNico videos",
|
||||
80,
|
||||
(manager, config) -> manager.registerSourceManager(new NicoAudioSourceManager())
|
||||
),
|
||||
|
||||
// Catch-all sources (priority 100+) - registered last as fallbacks
|
||||
HTTP(
|
||||
"http",
|
||||
"Direct HTTP audio links",
|
||||
100,
|
||||
(manager, config) -> manager.registerSourceManager(new HttpAudioSourceManager(MediaContainerRegistry.DEFAULT_REGISTRY))
|
||||
),
|
||||
LOCAL(
|
||||
"local",
|
||||
"Local file playback",
|
||||
110,
|
||||
(manager, config) -> AudioSourceManagers.registerLocalSource(manager)
|
||||
);
|
||||
|
||||
/** Identifies this bot to the remote cipher server. */
|
||||
private static final String REMOTE_CIPHER_USER_AGENT = "jmusicbot";
|
||||
|
||||
private final String configName;
|
||||
private final String description;
|
||||
private final int registrationPriority;
|
||||
private final BiConsumer<DefaultAudioPlayerManager, BotConfig> registrationAction;
|
||||
|
||||
AudioSource(String configName, String description, int registrationPriority,
|
||||
BiConsumer<DefaultAudioPlayerManager, BotConfig> registrationAction)
|
||||
{
|
||||
this.configName = configName;
|
||||
this.description = description;
|
||||
this.registrationPriority = registrationPriority;
|
||||
this.registrationAction = registrationAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the configuration name for this audio source (used in config files).
|
||||
*
|
||||
* @return the lowercase configuration name
|
||||
*/
|
||||
public String getConfigName()
|
||||
{
|
||||
return configName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a human-readable description of this audio source.
|
||||
*
|
||||
* @return the description
|
||||
*/
|
||||
public String getDescription()
|
||||
{
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the registration priority for this audio source.
|
||||
* Lower numbers are registered first. Platform-specific sources should have
|
||||
* low priorities (10-90), while catch-all sources like HTTP should have
|
||||
* high priorities (100+).
|
||||
*
|
||||
* @return the registration priority
|
||||
*/
|
||||
public int getRegistrationPriority()
|
||||
{
|
||||
return registrationPriority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all audio sources sorted by registration priority (lowest first).
|
||||
* This ensures platform-specific sources are registered before catch-all sources.
|
||||
*
|
||||
* @return list of audio sources in registration order
|
||||
*/
|
||||
public static java.util.List<AudioSource> valuesSortedByPriority()
|
||||
{
|
||||
return Arrays.stream(values())
|
||||
.sorted(java.util.Comparator.comparingInt(AudioSource::getRegistrationPriority))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an audio source from its configuration name.
|
||||
*
|
||||
* @param configName the configuration name (case-insensitive)
|
||||
* @return an Optional containing the AudioSource if found, empty otherwise
|
||||
*/
|
||||
public static Optional<AudioSource> fromConfigName(String configName)
|
||||
{
|
||||
if(configName == null)
|
||||
return Optional.empty();
|
||||
|
||||
return Arrays.stream(values())
|
||||
.filter(source -> source.configName.equalsIgnoreCase(configName.trim()))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this audio source with the given player manager.
|
||||
*
|
||||
* @param manager the player manager to register with
|
||||
* @param config the bot configuration
|
||||
*/
|
||||
public void register(DefaultAudioPlayerManager manager, BotConfig config)
|
||||
{
|
||||
registrationAction.accept(manager, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up and configures a YouTube audio source manager.
|
||||
*
|
||||
* @param config the bot configuration
|
||||
* @return the configured YouTube audio source manager
|
||||
*/
|
||||
private static YoutubeAudioSourceManager setupYoutubeAudioSourceManager(BotConfig config)
|
||||
{
|
||||
final Logger logger = LoggerFactory.getLogger(AudioSource.class);
|
||||
|
||||
boolean useOauth = config.useYouTubeOauth();
|
||||
YoutubeSourceOptions options = buildYoutubeOptions(config);
|
||||
Client[] clients = buildYoutubeClients(useOauth);
|
||||
|
||||
YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(options, clients);
|
||||
yt.setPlaylistPageCount(config.getMaxYTPlaylistPages());
|
||||
|
||||
if (useOauth)
|
||||
{
|
||||
applyOAuth(yt, logger);
|
||||
}
|
||||
return yt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds YouTube source options.
|
||||
*
|
||||
* <p>The remote cipher is applied whenever one is configured, <b>independently of
|
||||
* OAuth</b>. Signature deciphering and authentication are unrelated concerns:
|
||||
* deciphering turns YouTube's obfuscated stream signature into a playable URL,
|
||||
* while OAuth only decides whether requests are authenticated. Gating the cipher
|
||||
* behind OAuth left non-OAuth users on youtube-source's bundled
|
||||
* {@code LocalSignatureCipherManager}, whose extraction patterns stop matching
|
||||
* every time YouTube rotates its player script, producing
|
||||
* "Must find sig function from script: /s/player/<id>/..." playback failures.
|
||||
*/
|
||||
private static YoutubeSourceOptions buildYoutubeOptions(BotConfig config)
|
||||
{
|
||||
YoutubeSourceOptions options = new YoutubeSourceOptions()
|
||||
.setAllowSearch(true)
|
||||
.setAllowDirectVideoIds(true)
|
||||
.setAllowDirectPlaylistIds(true);
|
||||
|
||||
String remoteCipherUrl = config.getYoutubeRemoteCipherUrl();
|
||||
if (remoteCipherUrl != null && !remoteCipherUrl.trim().isEmpty())
|
||||
{
|
||||
String password = config.getYoutubeRemoteCipherPassword();
|
||||
// youtube-source expects null rather than an empty string when unauthenticated.
|
||||
if (password != null && password.trim().isEmpty())
|
||||
{
|
||||
password = null;
|
||||
}
|
||||
options.setRemoteCipher(remoteCipherUrl.trim(), password, REMOTE_CIPHER_USER_AGENT);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the appropriate YouTube clients based on OAuth setting.
|
||||
*
|
||||
* <p><b>Order matters.</b> Clients are tried in sequence, so the most reliable one goes
|
||||
* first and the most restricted one goes last:
|
||||
* <ul>
|
||||
* <li><b>AndroidWithThumbnail</b> - the workhorse. YouTube now answers most player
|
||||
* requests from the other anonymous clients with "This video requires login" or
|
||||
* "Sign in to confirm you're not a bot"; the Android client still streams them.</li>
|
||||
* <li><b>AndroidVrWithThumbnail</b> - second opinion when ANDROID is refused.</li>
|
||||
* <li><b>MWebWithThumbnail</b> - needs a working signature cipher, see
|
||||
* {@link #buildYoutubeOptions(BotConfig)}.</li>
|
||||
* <li><b>TvHtml5SimplyWithThumbnail</b> - frequently hits the bot check, so it sits late.</li>
|
||||
* <li><b>WebWithThumbnail</b> - last. YouTube increasingly returns SABR-only responses to
|
||||
* WEB, which carry no downloadable format ("No supported audio streams available,
|
||||
* available types:" with an empty list), so it is the least likely to succeed.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>With OAuth enabled the first three clients load metadata only and playback is driven by
|
||||
* {@code Tv}, the sole client for which {@code supportsOAuth()} is true - it is the only one
|
||||
* the refresh token is ever attached to. ANDROID is kept at the end as an anonymous playback
|
||||
* fallback: it carries no token, so it costs nothing in account exposure but keeps audio
|
||||
* flowing when the TV client is refused.
|
||||
*/
|
||||
private static Client[] buildYoutubeClients(boolean useOauth)
|
||||
{
|
||||
if (useOauth)
|
||||
{
|
||||
// Clients configured for metadata loading only (no playback/streaming)
|
||||
// This handles direct URLs without embedded player restrictions
|
||||
ClientOptions metadataOnly = new ClientOptions();
|
||||
metadataOnly.setPlayback(false);
|
||||
|
||||
return new Client[] {
|
||||
new AndroidVrWithThumbnail(metadataOnly), // metadata loading (non-embedded, non-OAuth)
|
||||
new MWebWithThumbnail(metadataOnly), // metadata loading (non-embedded, non-OAuth)
|
||||
new WebWithThumbnail(metadataOnly), // metadata loading (non-embedded, non-OAuth)
|
||||
new Tv(), // OAuth-authenticated playback
|
||||
new TvHtml5SimplyWithThumbnail(),
|
||||
new AndroidWithThumbnail() // anonymous playback fallback, no token
|
||||
};
|
||||
}
|
||||
// Clients are required even without OAuth to properly handle YouTube URLs
|
||||
return new Client[] {
|
||||
new AndroidWithThumbnail(),
|
||||
new AndroidVrWithThumbnail(),
|
||||
new MWebWithThumbnail(),
|
||||
new TvHtml5SimplyWithThumbnail(),
|
||||
new WebWithThumbnail()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads OAuth token and applies it to the YouTube source manager.
|
||||
* If no token exists, triggers the OAuth device flow to obtain one.
|
||||
*/
|
||||
private static void applyOAuth(YoutubeAudioSourceManager yt, Logger logger)
|
||||
{
|
||||
String token = readOAuthToken(logger);
|
||||
if (token != null)
|
||||
{
|
||||
logger.debug("Read YouTube OAuth2 refresh token from youtubetoken.txt");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Call useOauth2 with null token to trigger the device flow.
|
||||
// When token is null, this initiates the OAuth device code flow which
|
||||
// logs the authorization URL and code that YoutubeOauth2TokenHandler captures.
|
||||
yt.useOauth2(token, false);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.warn("Failed to authorise with YouTube. If this issue persists, delete the youtubetoken.txt file to reauthorise.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the OAuth token from file.
|
||||
*
|
||||
* @return the token, or null if file doesn't exist or read failed
|
||||
*/
|
||||
private static String readOAuthToken(Logger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Files.readString(OtherUtil.getPath("youtubetoken.txt"));
|
||||
}
|
||||
catch (NoSuchFileException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
logger.warn("Failed to read YouTube OAuth2 token file: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.management.GarbageCollectorMXBean;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Monitors JVM garbage collection events to help correlate with audio stutters.
|
||||
* Runs on a separate daemon thread to avoid impacting audio processing.
|
||||
*
|
||||
* <p>Uses {@link GarbageCollectorMXBean} to detect GC events by polling
|
||||
* collection count and time. When a change is detected, a GC event is recorded.
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public final class GCMonitor {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GCMonitor.class);
|
||||
private static final int MAX_EVENTS = 200;
|
||||
private static final int POLL_INTERVAL_MS = 500; // Poll every 500ms
|
||||
|
||||
private static volatile GCMonitor instance;
|
||||
|
||||
private final ConcurrentLinkedDeque<GCEvent> events = new ConcurrentLinkedDeque<>();
|
||||
private final List<GCState> gcStates = new ArrayList<>();
|
||||
private final ScheduledExecutorService scheduler;
|
||||
private volatile boolean running = false;
|
||||
|
||||
private GCMonitor() {
|
||||
// Initialize GC state tracking
|
||||
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
|
||||
gcStates.add(new GCState(gc.getName(), gc.getCollectionCount(), gc.getCollectionTime()));
|
||||
}
|
||||
|
||||
// Create daemon scheduler
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "GCMonitor");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance.
|
||||
*/
|
||||
public static GCMonitor getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (GCMonitor.class) {
|
||||
if (instance == null) {
|
||||
instance = new GCMonitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the GC monitoring thread.
|
||||
*/
|
||||
public void start() {
|
||||
if (running) return;
|
||||
|
||||
running = true;
|
||||
scheduler.scheduleAtFixedRate(this::pollGC, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS);
|
||||
LOG.info("GC Monitor started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the GC monitoring thread.
|
||||
*/
|
||||
public void stop() {
|
||||
running = false;
|
||||
scheduler.shutdown();
|
||||
LOG.info("GC Monitor stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls GC beans and records any new GC events.
|
||||
*/
|
||||
private void pollGC() {
|
||||
try {
|
||||
List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
for (int i = 0; i < gcBeans.size() && i < gcStates.size(); i++) {
|
||||
GarbageCollectorMXBean gc = gcBeans.get(i);
|
||||
GCState state = gcStates.get(i);
|
||||
|
||||
long currentCount = gc.getCollectionCount();
|
||||
long currentTime = gc.getCollectionTime();
|
||||
|
||||
if (currentCount > state.lastCount) {
|
||||
// GC occurred since last poll
|
||||
long collections = currentCount - state.lastCount;
|
||||
long timeSpent = currentTime - state.lastTime;
|
||||
|
||||
// Record the event
|
||||
GCEvent event = new GCEvent(
|
||||
now,
|
||||
gc.getName(),
|
||||
collections,
|
||||
timeSpent
|
||||
);
|
||||
events.addLast(event);
|
||||
|
||||
// Trim old events
|
||||
while (events.size() > MAX_EVENTS) {
|
||||
events.pollFirst();
|
||||
}
|
||||
|
||||
// Update state
|
||||
state.lastCount = currentCount;
|
||||
state.lastTime = currentTime;
|
||||
|
||||
// Log significant GC events
|
||||
if (timeSpent > 50) {
|
||||
LOG.debug("GC detected: {} - {} collections, {}ms", gc.getName(), collections, timeSpent);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error polling GC stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets recent GC events within the specified time window.
|
||||
*
|
||||
* @param windowSeconds how many seconds of history to return
|
||||
* @return array of GC events
|
||||
*/
|
||||
public GCEvent[] getRecentEvents(int windowSeconds) {
|
||||
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
|
||||
|
||||
return events.stream()
|
||||
.filter(e -> e.timestamp >= cutoff)
|
||||
.toArray(GCEvent[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all recorded GC events.
|
||||
*/
|
||||
public GCEvent[] getAllEvents() {
|
||||
return events.toArray(new GCEvent[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all recorded events.
|
||||
*/
|
||||
public void clear() {
|
||||
events.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets total GC count across all collectors.
|
||||
*/
|
||||
public long getTotalGCCount() {
|
||||
return ManagementFactory.getGarbageCollectorMXBeans().stream()
|
||||
.mapToLong(GarbageCollectorMXBean::getCollectionCount)
|
||||
.sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets total GC time across all collectors.
|
||||
*/
|
||||
public long getTotalGCTime() {
|
||||
return ManagementFactory.getGarbageCollectorMXBeans().stream()
|
||||
.mapToLong(GarbageCollectorMXBean::getCollectionTime)
|
||||
.sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable state for tracking GC bean changes.
|
||||
*/
|
||||
private static class GCState {
|
||||
final String name;
|
||||
long lastCount;
|
||||
long lastTime;
|
||||
|
||||
GCState(String name, long count, long time) {
|
||||
this.name = name;
|
||||
this.lastCount = count;
|
||||
this.lastTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a detected GC event.
|
||||
*/
|
||||
public record GCEvent(
|
||||
long timestamp,
|
||||
String collectorName,
|
||||
long collections,
|
||||
long durationMs
|
||||
) {
|
||||
/**
|
||||
* Returns severity based on duration.
|
||||
*/
|
||||
public Severity severity() {
|
||||
if (durationMs >= 100) return Severity.SEVERE;
|
||||
if (durationMs >= 50) return Severity.MODERATE;
|
||||
if (durationMs >= 20) return Severity.MINOR;
|
||||
return Severity.MINIMAL;
|
||||
}
|
||||
|
||||
public enum Severity {
|
||||
MINIMAL, MINOR, MODERATE, SEVERE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import com.jagrosh.jmusicbot.utils.MessageFormatter;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Activity;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.exceptions.ErrorResponseException;
|
||||
import net.dv8tion.jda.api.requests.ErrorResponse;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageEditBuilder;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageEditData;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class NowPlayingHandler
|
||||
{
|
||||
private record NPLocation(long channelId, long messageId) {}
|
||||
private static final class GuildState
|
||||
{
|
||||
private long version;
|
||||
private boolean reconciling;
|
||||
private boolean pending;
|
||||
private boolean forceNewMessage;
|
||||
private NPLocation location;
|
||||
}
|
||||
|
||||
private final Bot bot;
|
||||
private final Map<Long, GuildState> guildStates; // guild -> now playing reconcile state
|
||||
private final Set<Long> missingCommandChannelAlertedGuilds;
|
||||
|
||||
public NowPlayingHandler(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.guildStates = new ConcurrentHashMap<>();
|
||||
this.missingCommandChannelAlertedGuilds = ConcurrentHashMap.newKeySet();
|
||||
}
|
||||
|
||||
public void init()
|
||||
{
|
||||
// Schedule the 10-second update thread only when progress bar updates are enabled.
|
||||
if (bot.getConfig().showNpProgressBar())
|
||||
bot.getThreadpool().scheduleWithFixedDelay(this::updateAll, 0, 10, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void setLastNPMessage(Message m)
|
||||
{
|
||||
long guildId = m.getGuild().getIdLong();
|
||||
GuildState state = getOrCreateState(guildId);
|
||||
synchronized (state)
|
||||
{
|
||||
state.location = new NPLocation(m.getChannel().getIdLong(), m.getIdLong());
|
||||
state.version++;
|
||||
}
|
||||
}
|
||||
|
||||
public void clearLastNPMessage(Guild guild)
|
||||
{
|
||||
long guildId = guild.getIdLong();
|
||||
GuildState state = guildStates.get(guildId);
|
||||
if (state == null)
|
||||
return;
|
||||
synchronized (state)
|
||||
{
|
||||
state.location = null;
|
||||
state.version++;
|
||||
}
|
||||
}
|
||||
|
||||
// "event"-based methods
|
||||
public void onTrackUpdate(long guildId, AudioTrack track)
|
||||
{
|
||||
// Track start should force a fresh message; stop should reconcile existing location.
|
||||
if (track != null)
|
||||
{
|
||||
requestReconcile(guildId, "track-start", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
requestReconcile(guildId, "track-stop", false);
|
||||
}
|
||||
|
||||
// update bot status if applicable
|
||||
if(bot.getConfig().getSongInStatus())
|
||||
{
|
||||
if(track != null)
|
||||
{
|
||||
String title = FormatUtil.getTrackTitle(track);
|
||||
bot.getJDA().getPresence().setActivity(Activity.listening(title));
|
||||
}
|
||||
else
|
||||
{
|
||||
bot.resetGame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void requestReconcile(long guildId)
|
||||
{
|
||||
requestReconcile(guildId, "manual", false);
|
||||
}
|
||||
|
||||
public void requestReconcile(long guildId, String reason)
|
||||
{
|
||||
requestReconcile(guildId, reason, false);
|
||||
}
|
||||
|
||||
public void refreshNowPlaying(long guildId)
|
||||
{
|
||||
requestReconcile(guildId, "refresh", false);
|
||||
}
|
||||
|
||||
private void requestReconcile(long guildId, String reason, boolean forceNewMessage)
|
||||
{
|
||||
GuildState state = getOrCreateState(guildId);
|
||||
long versionToRun;
|
||||
synchronized (state)
|
||||
{
|
||||
state.version++;
|
||||
if (forceNewMessage)
|
||||
state.forceNewMessage = true;
|
||||
if (state.reconciling)
|
||||
{
|
||||
state.pending = true;
|
||||
return;
|
||||
}
|
||||
state.reconciling = true;
|
||||
versionToRun = state.version;
|
||||
}
|
||||
reconcile(guildId, versionToRun);
|
||||
}
|
||||
|
||||
private GuildState getOrCreateState(long guildId)
|
||||
{
|
||||
return guildStates.computeIfAbsent(guildId, __ -> new GuildState());
|
||||
}
|
||||
|
||||
private void reconcile(long guildId, long reconcileVersion)
|
||||
{
|
||||
GuildState state = guildStates.get(guildId);
|
||||
if (state == null)
|
||||
return;
|
||||
|
||||
Guild guild = bot.getJDA().getGuildById(guildId);
|
||||
if(guild == null)
|
||||
{
|
||||
synchronized (state)
|
||||
{
|
||||
state.location = null;
|
||||
}
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
AudioHandler handler = (AudioHandler) guild.getAudioManager().getSendingHandler();
|
||||
if (handler == null)
|
||||
{
|
||||
synchronized (state)
|
||||
{
|
||||
state.location = null;
|
||||
}
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
AudioTrack currentTrack = handler.getPlayer().getPlayingTrack();
|
||||
MessageCreateData playingMsg = handler.getNowPlaying(bot.getJDA());
|
||||
if (currentTrack != null && playingMsg == null)
|
||||
{
|
||||
// Track is active but rich NP payload may not be ready yet; render fallback NP.
|
||||
playingMsg = MessageFormatter.buildNowPlayingMessage(bot, handler.getNowPlayingInfo(bot.getJDA()));
|
||||
}
|
||||
boolean isPlaying = playingMsg != null;
|
||||
MessageCreateData targetMsg = isPlaying ? playingMsg : handler.getNoMusicPlaying(bot.getJDA());
|
||||
|
||||
NPLocation loc;
|
||||
boolean forceNewMessage;
|
||||
synchronized (state)
|
||||
{
|
||||
loc = state.location;
|
||||
forceNewMessage = state.forceNewMessage;
|
||||
state.forceNewMessage = false;
|
||||
}
|
||||
|
||||
if (!isPlaying && loc == null)
|
||||
{
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying && (forceNewMessage || loc == null))
|
||||
{
|
||||
MessageCreateData msg = targetMsg;
|
||||
if (msg == null)
|
||||
{
|
||||
msg = MessageFormatter.buildNowPlayingMessage(bot, handler.getNowPlayingInfo(bot.getJDA()));
|
||||
}
|
||||
sendPlayingMessage(guildId, guild, currentTrack, loc, msg, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
if (loc == null)
|
||||
{
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
TextChannel tc = guild.getTextChannelById(loc.channelId());
|
||||
if (tc == null)
|
||||
{
|
||||
synchronized (state)
|
||||
{
|
||||
if (state.version == reconcileVersion)
|
||||
state.location = null;
|
||||
}
|
||||
if (isPlaying)
|
||||
{
|
||||
sendPlayingMessage(guildId, guild, currentTrack, null, targetMsg, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean clearOnSuccess = !isPlaying;
|
||||
tc.editMessageById(loc.messageId(), asEditData(targetMsg)).queue(
|
||||
success -> {
|
||||
if (clearOnSuccess)
|
||||
{
|
||||
GuildState currentState = guildStates.get(guildId);
|
||||
if (currentState != null)
|
||||
{
|
||||
synchronized (currentState)
|
||||
{
|
||||
if (currentState.version == reconcileVersion)
|
||||
currentState.location = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
},
|
||||
throwable -> {
|
||||
handleUpdateError(guildId, reconcileVersion, throwable);
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void sendPlayingMessage(long guildId, Guild guild, AudioTrack currentTrack, NPLocation previousLocation,
|
||||
MessageCreateData msg, long reconcileVersion)
|
||||
{
|
||||
TextChannel tc;
|
||||
if(previousLocation == null)
|
||||
{
|
||||
if (currentTrack.getUserData(RequestMetadata.class) != null)
|
||||
{
|
||||
long channelId = currentTrack.getUserData(RequestMetadata.class).channelId;
|
||||
tc = guild.getTextChannelById(channelId);
|
||||
}
|
||||
else
|
||||
{
|
||||
tc = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
tc = guild.getTextChannelById(previousLocation.channelId());
|
||||
}
|
||||
|
||||
if (tc == null)
|
||||
{
|
||||
tc = resolveFallbackChannel(guild);
|
||||
}
|
||||
if (tc == null)
|
||||
{
|
||||
GuildState state = guildStates.get(guildId);
|
||||
if (state != null)
|
||||
{
|
||||
synchronized (state)
|
||||
{
|
||||
if (state.version == reconcileVersion)
|
||||
state.location = null;
|
||||
}
|
||||
}
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousLocation != null)
|
||||
tc.deleteMessageById(previousLocation.messageId()).queue(s -> {}, t -> {});
|
||||
|
||||
tc.sendMessage(msg).queue(
|
||||
m -> {
|
||||
GuildState state = guildStates.get(guildId);
|
||||
if (state != null)
|
||||
{
|
||||
synchronized (state)
|
||||
{
|
||||
if (state.version == reconcileVersion || state.location == null)
|
||||
state.location = new NPLocation(m.getChannel().getIdLong(), m.getIdLong());
|
||||
}
|
||||
}
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
},
|
||||
throwable -> {
|
||||
handleUpdateError(guildId, reconcileVersion, throwable);
|
||||
finishReconcile(guildId, reconcileVersion);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TextChannel resolveFallbackChannel(Guild guild)
|
||||
{
|
||||
TextChannel commandChannel = bot.getSettingsManager().getSettings(guild).getTextChannel(guild);
|
||||
if (commandChannel == null)
|
||||
{
|
||||
notifyOwnerMissingCommandChannel(guild, "is not set");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!guild.getSelfMember().hasPermission(commandChannel, Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND))
|
||||
{
|
||||
notifyOwnerMissingCommandChannel(guild, "is set but the bot cannot send messages there");
|
||||
return null;
|
||||
}
|
||||
|
||||
missingCommandChannelAlertedGuilds.remove(guild.getIdLong());
|
||||
return commandChannel;
|
||||
}
|
||||
|
||||
private void notifyOwnerMissingCommandChannel(Guild guild, String reason)
|
||||
{
|
||||
long guildId = guild.getIdLong();
|
||||
if (!missingCommandChannelAlertedGuilds.add(guildId))
|
||||
return;
|
||||
|
||||
long ownerId = bot.getConfig().getOwnerId();
|
||||
if (ownerId <= 0L)
|
||||
return;
|
||||
|
||||
String msg = "NowPlaying fallback failed for guild **" + guild.getName() + "** (`" + guild.getId() + "`): "
|
||||
+ "the command channel " + reason + ". Please set it with `/settc`.";
|
||||
|
||||
|
||||
var ownerLookup = bot.getJDA().retrieveUserById(ownerId);
|
||||
|
||||
ownerLookup.queue(
|
||||
user -> user.openPrivateChannel().queue(
|
||||
pc -> pc.sendMessage(msg).queue(),
|
||||
throwable -> {}
|
||||
),
|
||||
throwable -> {}
|
||||
);
|
||||
}
|
||||
|
||||
public void onMessageDelete(Guild guild, long messageId)
|
||||
{
|
||||
GuildState state = guildStates.get(guild.getIdLong());
|
||||
if (state == null)
|
||||
return;
|
||||
NPLocation loc;
|
||||
synchronized (state)
|
||||
{
|
||||
loc = state.location;
|
||||
}
|
||||
if(loc != null && loc.messageId() == messageId)
|
||||
{
|
||||
synchronized (state)
|
||||
{
|
||||
state.location = null;
|
||||
state.version++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateAll()
|
||||
{
|
||||
guildStates.keySet().forEach(guildId -> requestReconcile(guildId, "periodic", false));
|
||||
}
|
||||
|
||||
private void finishReconcile(long guildId, long completedVersion)
|
||||
{
|
||||
GuildState state = guildStates.get(guildId);
|
||||
if (state == null)
|
||||
return;
|
||||
Long rerunVersion = null;
|
||||
synchronized (state)
|
||||
{
|
||||
if (state.pending)
|
||||
{
|
||||
state.pending = false;
|
||||
rerunVersion = state.version;
|
||||
}
|
||||
else
|
||||
{
|
||||
state.reconciling = false;
|
||||
}
|
||||
}
|
||||
if (rerunVersion != null)
|
||||
reconcile(guildId, rerunVersion);
|
||||
}
|
||||
|
||||
private void handleUpdateError(long guildId, long reconcileVersion, Throwable t)
|
||||
{
|
||||
if (t instanceof ErrorResponseException ex)
|
||||
{
|
||||
ErrorResponse response = ex.getErrorResponse();
|
||||
switch (response)
|
||||
{
|
||||
// Permanent errors: Remove the tracking
|
||||
case UNKNOWN_MESSAGE:
|
||||
case UNKNOWN_CHANNEL:
|
||||
case MISSING_ACCESS:
|
||||
case MISSING_PERMISSIONS:
|
||||
GuildState state = guildStates.get(guildId);
|
||||
if (state != null)
|
||||
{
|
||||
synchronized (state)
|
||||
{
|
||||
if (state.version == reconcileVersion)
|
||||
state.location = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// Transient errors: Do nothing, let the next loop or event try again
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageEditData asEditData(MessageCreateData source)
|
||||
{
|
||||
return new MessageEditBuilder()
|
||||
.setContent(source.getContent() == null ? "" : source.getContent())
|
||||
.setEmbeds(source.getEmbeds())
|
||||
.setComponents(source.getComponents())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
|
||||
/**
|
||||
* Represents information about the current playing track
|
||||
*/
|
||||
public class NowPlayingInfo {
|
||||
public final AudioTrack track;
|
||||
public final Guild guild;
|
||||
public final boolean isPaused;
|
||||
public final long position;
|
||||
public final long duration;
|
||||
public final int volume;
|
||||
public final int queueSize;
|
||||
public final int previousTrackCount;
|
||||
public final boolean isCurrentTrackFavorited;
|
||||
public final String footerInfo;
|
||||
|
||||
public NowPlayingInfo(AudioTrack track, Guild guild, boolean isPaused, int volume, int queueSize, String footerInfo) {
|
||||
this(track, guild, isPaused, volume, queueSize, 0, false, footerInfo);
|
||||
}
|
||||
|
||||
public NowPlayingInfo(
|
||||
AudioTrack track,
|
||||
Guild guild,
|
||||
boolean isPaused,
|
||||
int volume,
|
||||
int queueSize,
|
||||
int previousTrackCount,
|
||||
String footerInfo
|
||||
) {
|
||||
this(track, guild, isPaused, volume, queueSize, previousTrackCount, false, footerInfo);
|
||||
}
|
||||
|
||||
public NowPlayingInfo(
|
||||
AudioTrack track,
|
||||
Guild guild,
|
||||
boolean isPaused,
|
||||
int volume,
|
||||
int queueSize,
|
||||
int previousTrackCount,
|
||||
boolean isCurrentTrackFavorited,
|
||||
String footerInfo
|
||||
) {
|
||||
this.track = track;
|
||||
this.guild = guild;
|
||||
this.isPaused = isPaused;
|
||||
this.position = track == null
|
||||
? 0
|
||||
: track.getPosition();
|
||||
this.duration = track == null
|
||||
? 0
|
||||
: track.getDuration();
|
||||
this.volume = volume;
|
||||
this.queueSize = queueSize;
|
||||
this.previousTrackCount = Math.max(0, previousTrackCount);
|
||||
this.isCurrentTrackFavorited = isCurrentTrackFavorited;
|
||||
this.footerInfo = footerInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Collects performance metrics for audio frame processing using lock-free ring buffers.
|
||||
* Designed for minimal overhead on the audio thread (O(1) recording, no allocations).
|
||||
*
|
||||
* <p>JDA requests audio frames every ~20ms. This class tracks:
|
||||
* <ul>
|
||||
* <li>Frame availability (provided vs missed - the key stutter indicator)</li>
|
||||
* <li>Frame latency (time to get frame from Lavaplayer)</li>
|
||||
* <li>Consecutive frame streaks (buffer health indicator)</li>
|
||||
* <li>Stutter events (consecutive missed frames)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public class PerformanceMetrics implements AudioMetricsListener {
|
||||
|
||||
/** Maximum number of frames to retain (2 minutes at 50fps) */
|
||||
private static final int MAX_HISTORY_SIZE = 6000;
|
||||
|
||||
/** Maximum stutter events to retain */
|
||||
private static final int MAX_STUTTER_EVENTS = 100;
|
||||
|
||||
/** Maximum stuck events to retain */
|
||||
private static final int MAX_STUCK_EVENTS = 50;
|
||||
|
||||
/** Maximum track events to retain */
|
||||
private static final int MAX_TRACK_EVENTS = 100;
|
||||
|
||||
/** Frame counts for miss rate windows (10s = 500 frames, 60s = 3000 frames at 50fps) */
|
||||
private static final int FRAMES_10_SECONDS = 500;
|
||||
private static final int FRAMES_60_SECONDS = 3000;
|
||||
|
||||
private final long guildId;
|
||||
|
||||
/** Expected interval between frame requests (Discord requests frames every 20ms) */
|
||||
private static final long EXPECTED_CADENCE_MS = 20;
|
||||
|
||||
/** Threshold for considering a frame "late" (jitter threshold) */
|
||||
private static final long LATE_THRESHOLD_MS = 5;
|
||||
|
||||
// Ring buffer for frame data - primitive arrays for zero allocation
|
||||
private final long[] timestamps = new long[MAX_HISTORY_SIZE];
|
||||
private final boolean[] frameProvided = new boolean[MAX_HISTORY_SIZE];
|
||||
private final float[] latencyMs = new float[MAX_HISTORY_SIZE]; // float saves memory, sufficient precision
|
||||
private final short[] cadenceDeltaMs = new short[MAX_HISTORY_SIZE]; // delta from expected 20ms (short saves memory)
|
||||
private final AtomicInteger ringHead = new AtomicInteger(0);
|
||||
private volatile int frameCount = 0; // How many frames recorded (up to MAX_HISTORY_SIZE)
|
||||
|
||||
// Stutter events - use ConcurrentLinkedDeque (stutters are rare, allocation OK)
|
||||
private final ConcurrentLinkedDeque<StutterEvent> stutterEvents = new ConcurrentLinkedDeque<>();
|
||||
|
||||
// Stuck events (Lavaplayer track stuck) - rare, allocation OK
|
||||
private final ConcurrentLinkedDeque<StuckEvent> stuckEvents = new ConcurrentLinkedDeque<>();
|
||||
|
||||
// Track events (start/end/exception/stuck) - rare, allocation OK
|
||||
private final ConcurrentLinkedDeque<TrackEvent> trackEvents = new ConcurrentLinkedDeque<>();
|
||||
|
||||
// Time-to-first-frame tracking
|
||||
private volatile long trackStartTime = 0;
|
||||
private volatile long firstFrameTime = 0;
|
||||
private volatile boolean waitingForFirstFrame = false;
|
||||
private volatile long lastTimeToFirstFrameMs = 0;
|
||||
|
||||
// Counters (atomic for thread safety)
|
||||
private final AtomicLong totalFramesRequested = new AtomicLong(0);
|
||||
private final AtomicLong totalFramesProvided = new AtomicLong(0);
|
||||
private final AtomicLong totalFramesMissed = new AtomicLong(0);
|
||||
private final AtomicLong totalLatencyNanos = new AtomicLong(0);
|
||||
private final AtomicLong stutterCount = new AtomicLong(0);
|
||||
private final AtomicLong stuckCount = new AtomicLong(0);
|
||||
|
||||
// Tracking for stutter detection and buffer health
|
||||
private volatile int consecutiveMisses = 0;
|
||||
private volatile long lastMissTimestamp = 0;
|
||||
private volatile int consecutiveSuccess = 0; // For buffer health
|
||||
private volatile long sessionStartTime;
|
||||
|
||||
// Cadence tracking (time between frame requests)
|
||||
private volatile long lastFrameRequestTime = 0;
|
||||
private final AtomicLong totalCadenceDeltaMs = new AtomicLong(0);
|
||||
private final AtomicLong lateFrameCount = new AtomicLong(0);
|
||||
private volatile long maxCadenceDeltaMs = 0;
|
||||
|
||||
public PerformanceMetrics(long guildId) {
|
||||
this.guildId = guildId;
|
||||
this.sessionStartTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a frame request and its result.
|
||||
* This method is called on the audio thread every ~20ms and must be extremely fast.
|
||||
* O(1) time complexity, no allocations in steady state.
|
||||
*
|
||||
* @param provided true if audio frame was available, false if missed
|
||||
* @param latencyNanos time taken to get frame from Lavaplayer in nanoseconds
|
||||
*/
|
||||
@Override
|
||||
public void onFrameProvided(boolean provided, long latencyNanos) {
|
||||
long currentTimeMs = System.currentTimeMillis();
|
||||
|
||||
// Calculate cadence delta (deviation from expected 20ms interval)
|
||||
long cadenceDelta = 0;
|
||||
if (lastFrameRequestTime > 0) {
|
||||
long actualInterval = currentTimeMs - lastFrameRequestTime;
|
||||
cadenceDelta = actualInterval - EXPECTED_CADENCE_MS;
|
||||
|
||||
// Track late frames (significant deviation from expected cadence)
|
||||
if (cadenceDelta > LATE_THRESHOLD_MS) {
|
||||
lateFrameCount.incrementAndGet();
|
||||
}
|
||||
|
||||
// Track total and max delta for statistics
|
||||
if (cadenceDelta > 0) {
|
||||
totalCadenceDeltaMs.addAndGet(cadenceDelta);
|
||||
if (cadenceDelta > maxCadenceDeltaMs) {
|
||||
maxCadenceDeltaMs = cadenceDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastFrameRequestTime = currentTimeMs;
|
||||
|
||||
// Update counters (atomic, lock-free)
|
||||
totalFramesRequested.incrementAndGet();
|
||||
|
||||
if (provided) {
|
||||
totalFramesProvided.incrementAndGet();
|
||||
totalLatencyNanos.addAndGet(latencyNanos);
|
||||
consecutiveSuccess++;
|
||||
|
||||
// Track time-to-first-frame
|
||||
if (waitingForFirstFrame && trackStartTime > 0) {
|
||||
firstFrameTime = currentTimeMs;
|
||||
lastTimeToFirstFrameMs = firstFrameTime - trackStartTime;
|
||||
waitingForFirstFrame = false;
|
||||
}
|
||||
|
||||
// End of a stutter sequence - record it (rare path, allocation OK)
|
||||
if (consecutiveMisses > 0) {
|
||||
recordStutterEvent(consecutiveMisses, lastMissTimestamp);
|
||||
consecutiveMisses = 0;
|
||||
}
|
||||
} else {
|
||||
totalFramesMissed.incrementAndGet();
|
||||
consecutiveMisses++;
|
||||
consecutiveSuccess = 0;
|
||||
lastMissTimestamp = currentTimeMs;
|
||||
}
|
||||
|
||||
// Write to ring buffer (O(1), no allocation)
|
||||
int index = ringHead.getAndUpdate(i -> (i + 1) % MAX_HISTORY_SIZE);
|
||||
timestamps[index] = currentTimeMs;
|
||||
frameProvided[index] = provided;
|
||||
latencyMs[index] = latencyNanos / 1_000_000.0f;
|
||||
// Clamp cadence delta to short range to save memory
|
||||
cadenceDeltaMs[index] = (short) Math.max(Short.MIN_VALUE, Math.min(Short.MAX_VALUE, cadenceDelta));
|
||||
|
||||
// Track how many frames we have (up to MAX_HISTORY_SIZE)
|
||||
if (frameCount < MAX_HISTORY_SIZE) {
|
||||
frameCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a stutter event (sequence of consecutive missed frames).
|
||||
* Called rarely, so allocation is acceptable.
|
||||
*/
|
||||
private void recordStutterEvent(int missedFrames, long timestamp) {
|
||||
stutterCount.incrementAndGet();
|
||||
int durationMs = missedFrames * 20; // Each frame is 20ms
|
||||
|
||||
stutterEvents.addLast(new StutterEvent(timestamp, missedFrames, durationMs));
|
||||
|
||||
while (stutterEvents.size() > MAX_STUTTER_EVENTS) {
|
||||
stutterEvents.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a Lavaplayer track stuck event.
|
||||
* Called when Lavaplayer's decoder/stream stalls and can't provide audio.
|
||||
*
|
||||
* @param thresholdMs the threshold (in ms) that was exceeded before the stuck event triggered
|
||||
* @param title the title of the stuck track (may be null)
|
||||
* @param uri the URI of the stuck track (may be null)
|
||||
*/
|
||||
@Override
|
||||
public void onTrackStuck(long thresholdMs, String title, String uri) {
|
||||
stuckCount.incrementAndGet();
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
stuckEvents.addLast(new StuckEvent(timestamp, thresholdMs, title, uri));
|
||||
|
||||
while (stuckEvents.size() > MAX_STUCK_EVENTS) {
|
||||
stuckEvents.pollFirst();
|
||||
}
|
||||
|
||||
// Also record as a track event for timeline
|
||||
recordTrackEvent(TrackEventType.STUCK, title, uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a track lifecycle event.
|
||||
*
|
||||
* @param type the event type
|
||||
* @param trackTitle the track title (may be null)
|
||||
* @param trackUri the track URI (may be null)
|
||||
*/
|
||||
public void recordTrackEvent(TrackEventType type, String trackTitle, String trackUri) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
trackEvents.addLast(new TrackEvent(timestamp, type, trackTitle, trackUri));
|
||||
|
||||
while (trackEvents.size() > MAX_TRACK_EVENTS) {
|
||||
trackEvents.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a track start event and begins time-to-first-frame tracking.
|
||||
*
|
||||
* @param title the track title
|
||||
* @param uri the track URI
|
||||
*/
|
||||
@Override
|
||||
public void onTrackStart(String title, String uri) {
|
||||
trackStartTime = System.currentTimeMillis();
|
||||
firstFrameTime = 0;
|
||||
waitingForFirstFrame = true;
|
||||
recordTrackEvent(TrackEventType.STARTED, title, uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a track end event.
|
||||
*
|
||||
* @param title the track title
|
||||
* @param uri the track URI
|
||||
*/
|
||||
@Override
|
||||
public void onTrackEnd(String title, String uri) {
|
||||
waitingForFirstFrame = false;
|
||||
recordTrackEvent(TrackEventType.ENDED, title, uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a track exception event.
|
||||
*
|
||||
* @param title the track title
|
||||
* @param uri the track URI
|
||||
*/
|
||||
@Override
|
||||
public void onTrackException(String title, String uri) {
|
||||
waitingForFirstFrame = false;
|
||||
recordTrackEvent(TrackEventType.EXCEPTION, title, uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets recent track events.
|
||||
*
|
||||
* @param windowSeconds how many seconds of history to return
|
||||
* @return array of track events within the window
|
||||
*/
|
||||
public TrackEvent[] getRecentTrackEvents(int windowSeconds) {
|
||||
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
|
||||
return trackEvents.stream()
|
||||
.filter(e -> e.timestamp() >= cutoff)
|
||||
.toArray(TrackEvent[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the time-to-first-frame for the most recent track in milliseconds.
|
||||
*
|
||||
* @return time in ms between track start and first audio frame, or 0 if not yet recorded
|
||||
*/
|
||||
public long getTimeToFirstFrameMs() {
|
||||
return lastTimeToFirstFrameMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total count of stuck events in the current session.
|
||||
*/
|
||||
public long getStuckCount() {
|
||||
return stuckCount.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets recent stuck events.
|
||||
*
|
||||
* @param windowSeconds how many seconds of history to return
|
||||
* @return array of stuck events within the window
|
||||
*/
|
||||
public StuckEvent[] getRecentStuckEvents(int windowSeconds) {
|
||||
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
|
||||
return stuckEvents.stream()
|
||||
.filter(e -> e.timestamp() >= cutoff)
|
||||
.toArray(StuckEvent[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets session counters (called when track starts).
|
||||
*/
|
||||
@Override
|
||||
public void onSessionReset() {
|
||||
totalFramesRequested.set(0);
|
||||
totalFramesProvided.set(0);
|
||||
totalFramesMissed.set(0);
|
||||
totalLatencyNanos.set(0);
|
||||
stutterCount.set(0);
|
||||
stuckCount.set(0);
|
||||
consecutiveMisses = 0;
|
||||
consecutiveSuccess = 0;
|
||||
lastMissTimestamp = 0;
|
||||
frameCount = 0;
|
||||
ringHead.set(0);
|
||||
sessionStartTime = System.currentTimeMillis();
|
||||
stutterEvents.clear();
|
||||
stuckEvents.clear();
|
||||
// Reset cadence tracking
|
||||
lastFrameRequestTime = 0;
|
||||
totalCadenceDeltaMs.set(0);
|
||||
lateFrameCount.set(0);
|
||||
maxCadenceDeltaMs = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a snapshot of current metrics for display.
|
||||
* This is called on the UI thread and does all the heavy computation.
|
||||
*
|
||||
* @param graphWindowSeconds how many seconds of history to include
|
||||
*/
|
||||
public MetricsSnapshot getSnapshot(int graphWindowSeconds) {
|
||||
long requested = totalFramesRequested.get();
|
||||
long provided = totalFramesProvided.get();
|
||||
long missed = totalFramesMissed.get();
|
||||
long stutters = stutterCount.get();
|
||||
int currentStreak = consecutiveSuccess;
|
||||
|
||||
double avgLatencyMs = provided > 0
|
||||
? (totalLatencyNanos.get() / (double) provided) / 1_000_000.0
|
||||
: 0;
|
||||
|
||||
double missRate = requested > 0
|
||||
? (missed * 100.0) / requested
|
||||
: 0;
|
||||
|
||||
// Calculate how many frames for the requested window (50 fps)
|
||||
int graphPoints = Math.min(graphWindowSeconds * 50, frameCount);
|
||||
|
||||
// Copy frame data from ring buffer (on UI thread, OK to allocate)
|
||||
FrameMetric[] frameHistory = extractFrameHistory(graphPoints);
|
||||
|
||||
// Get stutter events
|
||||
StutterEvent[] stutterArray = stutterEvents.toArray(new StutterEvent[0]);
|
||||
|
||||
// Get stuck events (Lavaplayer track stuck)
|
||||
StuckEvent[] stuckArray = getRecentStuckEvents(graphWindowSeconds);
|
||||
long stucks = stuckCount.get();
|
||||
|
||||
// Calculate latency buckets for line graph (100ms buckets)
|
||||
LatencyBucket[] latencyBuckets = computeLatencyBuckets(frameHistory);
|
||||
|
||||
// Calculate quality history (1-second chunks)
|
||||
QualityPoint[] qualityHistory = computeQualityHistory(frameHistory);
|
||||
|
||||
// Calculate recent stats (last 50 frames = 1 second)
|
||||
int recentMissed = countRecentMisses(50);
|
||||
|
||||
// Calculate frames per second
|
||||
long durationMs = System.currentTimeMillis() - sessionStartTime;
|
||||
double framesPerSecond = durationMs > 0 ? (requested * 1000.0) / durationMs : 0;
|
||||
|
||||
// Buffer health based on consecutive success streak
|
||||
int bufferHealth = Math.min(100, currentStreak * 2); // 50+ consecutive = 100%
|
||||
|
||||
// Get GC events from GCMonitor
|
||||
GCMonitor.GCEvent[] gcEvents = GCMonitor.getInstance().getRecentEvents(graphWindowSeconds);
|
||||
|
||||
// Cadence statistics
|
||||
long lateFrames = lateFrameCount.get();
|
||||
long maxCadence = maxCadenceDeltaMs;
|
||||
double avgCadenceDelta = requested > 1 ? (double) totalCadenceDeltaMs.get() / (requested - 1) : 0;
|
||||
|
||||
// Calculate p95 latency
|
||||
double p95Latency = computeP95Latency(frameHistory);
|
||||
|
||||
// Calculate miss rate windows
|
||||
double missRate10 = getMissRate10s();
|
||||
double missRate60 = getMissRate60s();
|
||||
|
||||
// Get track events
|
||||
TrackEvent[] trackEventArray = getRecentTrackEvents(graphWindowSeconds);
|
||||
|
||||
// Get time-to-first-frame
|
||||
long ttff = lastTimeToFirstFrameMs;
|
||||
|
||||
return new MetricsSnapshot(
|
||||
guildId,
|
||||
durationMs,
|
||||
requested,
|
||||
provided,
|
||||
missed,
|
||||
missRate,
|
||||
missRate10,
|
||||
missRate60,
|
||||
avgLatencyMs,
|
||||
p95Latency,
|
||||
stutters,
|
||||
stucks,
|
||||
recentMissed,
|
||||
framesPerSecond,
|
||||
graphWindowSeconds,
|
||||
bufferHealth,
|
||||
currentStreak,
|
||||
lateFrames,
|
||||
avgCadenceDelta,
|
||||
maxCadence,
|
||||
ttff,
|
||||
frameHistory,
|
||||
stutterArray,
|
||||
stuckArray,
|
||||
trackEventArray,
|
||||
latencyBuckets,
|
||||
qualityHistory,
|
||||
gcEvents
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts frame history from ring buffer.
|
||||
*/
|
||||
private FrameMetric[] extractFrameHistory(int count) {
|
||||
if (count <= 0 || frameCount == 0) {
|
||||
return new FrameMetric[0];
|
||||
}
|
||||
|
||||
int actualCount = Math.min(count, frameCount);
|
||||
FrameMetric[] result = new FrameMetric[actualCount];
|
||||
|
||||
int head = ringHead.get();
|
||||
int start = (head - actualCount + MAX_HISTORY_SIZE) % MAX_HISTORY_SIZE;
|
||||
|
||||
for (int i = 0; i < actualCount; i++) {
|
||||
int idx = (start + i) % MAX_HISTORY_SIZE;
|
||||
result[i] = new FrameMetric(timestamps[idx], frameProvided[idx], latencyMs[idx], cadenceDeltaMs[idx]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts recent missed frames from the ring buffer.
|
||||
*/
|
||||
private int countRecentMisses(int frameCount) {
|
||||
int count = Math.min(frameCount, this.frameCount);
|
||||
if (count == 0) return 0;
|
||||
|
||||
int missed = 0;
|
||||
int head = ringHead.get();
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
int idx = (head - 1 - i + MAX_HISTORY_SIZE) % MAX_HISTORY_SIZE;
|
||||
if (!frameProvided[idx]) {
|
||||
missed++;
|
||||
}
|
||||
}
|
||||
|
||||
return missed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates miss rate for a specific frame window.
|
||||
*
|
||||
* @param framesToCheck number of frames to analyze
|
||||
* @return miss rate as a percentage (0-100)
|
||||
*/
|
||||
private double calculateMissRate(int framesToCheck) {
|
||||
int count = Math.min(framesToCheck, this.frameCount);
|
||||
if (count == 0) return 0;
|
||||
|
||||
int missed = countRecentMisses(count);
|
||||
return (missed * 100.0) / count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the miss rate for the last 10 seconds (500 frames).
|
||||
*/
|
||||
public double getMissRate10s() {
|
||||
return calculateMissRate(FRAMES_10_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the miss rate for the last 60 seconds (3000 frames).
|
||||
*/
|
||||
public double getMissRate60s() {
|
||||
return calculateMissRate(FRAMES_60_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes latency buckets for line graph (aggregated every 100ms).
|
||||
*/
|
||||
private LatencyBucket[] computeLatencyBuckets(FrameMetric[] frames) {
|
||||
if (frames.length == 0) {
|
||||
return new LatencyBucket[0];
|
||||
}
|
||||
|
||||
List<LatencyBucket> buckets = new ArrayList<>();
|
||||
long bucketSize = 100; // 100ms per bucket
|
||||
|
||||
long bucketTimestamp = frames[0].timestamp();
|
||||
double min = Double.MAX_VALUE, max = 0, sum = 0;
|
||||
int count = 0;
|
||||
|
||||
for (int i = 0; i < frames.length; i++) {
|
||||
FrameMetric frame = frames[i];
|
||||
|
||||
// Check if we've moved to a new bucket
|
||||
if (frame.timestamp() >= bucketTimestamp + bucketSize && count > 0) {
|
||||
buckets.add(new LatencyBucket(bucketTimestamp, min, max, sum / count));
|
||||
bucketTimestamp = frame.timestamp();
|
||||
min = Double.MAX_VALUE;
|
||||
max = 0;
|
||||
sum = 0;
|
||||
count = 0;
|
||||
}
|
||||
|
||||
if (frame.frameProvided()) {
|
||||
double lat = frame.latencyMs();
|
||||
min = Math.min(min, lat);
|
||||
max = Math.max(max, lat);
|
||||
sum += lat;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final bucket
|
||||
if (count > 0) {
|
||||
buckets.add(new LatencyBucket(bucketTimestamp, min, max, sum / count));
|
||||
}
|
||||
|
||||
return buckets.toArray(new LatencyBucket[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes quality score history (sampled every 1 second).
|
||||
*/
|
||||
private QualityPoint[] computeQualityHistory(FrameMetric[] frames) {
|
||||
if (frames.length == 0) {
|
||||
return new QualityPoint[0];
|
||||
}
|
||||
|
||||
List<QualityPoint> points = new ArrayList<>();
|
||||
long chunkSize = 1000; // 1 second per point
|
||||
|
||||
long chunkTimestamp = frames[0].timestamp();
|
||||
int provided = 0, missed = 0;
|
||||
double latencySum = 0;
|
||||
|
||||
for (FrameMetric frame : frames) {
|
||||
if (frame.timestamp() >= chunkTimestamp + chunkSize) {
|
||||
// Compute quality for this chunk
|
||||
int score = computeChunkQuality(provided, missed, latencySum / Math.max(1, provided));
|
||||
points.add(new QualityPoint(chunkTimestamp, score));
|
||||
|
||||
chunkTimestamp = frame.timestamp();
|
||||
provided = 0;
|
||||
missed = 0;
|
||||
latencySum = 0;
|
||||
}
|
||||
|
||||
if (frame.frameProvided()) {
|
||||
provided++;
|
||||
latencySum += frame.latencyMs();
|
||||
} else {
|
||||
missed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final chunk
|
||||
if (provided + missed > 0) {
|
||||
int score = computeChunkQuality(provided, missed, latencySum / Math.max(1, provided));
|
||||
points.add(new QualityPoint(chunkTimestamp, score));
|
||||
}
|
||||
|
||||
return points.toArray(new QualityPoint[0]);
|
||||
}
|
||||
|
||||
private int computeChunkQuality(int provided, int missed, double avgLatency) {
|
||||
int total = provided + missed;
|
||||
if (total == 0) return 100;
|
||||
|
||||
double missRate = (missed * 100.0) / total;
|
||||
double score = 100.0;
|
||||
score -= Math.min(50, missRate * 10);
|
||||
score -= Math.min(30, missed * 6);
|
||||
if (avgLatency > 5) {
|
||||
score -= Math.min(20, (avgLatency - 5) * 2);
|
||||
}
|
||||
|
||||
return (int) Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the p95 (95th percentile) latency from frame history.
|
||||
* p95 latency means 95% of frames were processed faster than this value.
|
||||
*
|
||||
* @param frames the frame history to analyze
|
||||
* @return p95 latency in milliseconds, or 0 if insufficient data
|
||||
*/
|
||||
private double computeP95Latency(FrameMetric[] frames) {
|
||||
if (frames.length < 20) {
|
||||
return 0; // Need at least 20 samples for meaningful percentile
|
||||
}
|
||||
|
||||
// Extract latencies from provided frames only
|
||||
float[] latencies = new float[frames.length];
|
||||
int count = 0;
|
||||
for (FrameMetric frame : frames) {
|
||||
if (frame.frameProvided()) {
|
||||
latencies[count++] = frame.latencyMs();
|
||||
}
|
||||
}
|
||||
|
||||
if (count < 20) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sort and find 95th percentile
|
||||
float[] validLatencies = Arrays.copyOf(latencies, count);
|
||||
Arrays.sort(validLatencies);
|
||||
|
||||
int p95Index = (int) Math.ceil(count * 0.95) - 1;
|
||||
p95Index = Math.max(0, Math.min(p95Index, count - 1));
|
||||
|
||||
return validLatencies[p95Index];
|
||||
}
|
||||
|
||||
public long getGuildId() {
|
||||
return guildId;
|
||||
}
|
||||
|
||||
// ===== Record Types =====
|
||||
|
||||
/**
|
||||
* Single frame metric data point.
|
||||
*/
|
||||
public record FrameMetric(long timestamp, boolean frameProvided, float latencyMs, short cadenceDeltaMs) {
|
||||
/**
|
||||
* Returns true if this frame was "late" (cadence exceeded threshold).
|
||||
*/
|
||||
public boolean isLate() {
|
||||
return cadenceDeltaMs > LATE_THRESHOLD_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latency statistics for a time bucket (100ms).
|
||||
*/
|
||||
public record LatencyBucket(long timestamp, double min, double max, double avg) {}
|
||||
|
||||
/**
|
||||
* Quality score at a point in time.
|
||||
*/
|
||||
public record QualityPoint(long timestamp, int score) {}
|
||||
|
||||
/**
|
||||
* Represents a stutter event (one or more consecutive missed frames).
|
||||
*/
|
||||
public record StutterEvent(long timestamp, int missedFrames, int durationMs) {
|
||||
public Severity severity() {
|
||||
if (durationMs >= 200) return Severity.SEVERE;
|
||||
if (durationMs >= 100) return Severity.MODERATE;
|
||||
if (durationMs >= 40) return Severity.MINOR;
|
||||
return Severity.MINIMAL;
|
||||
}
|
||||
|
||||
public enum Severity {
|
||||
MINIMAL, MINOR, MODERATE, SEVERE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of track lifecycle events.
|
||||
*/
|
||||
public enum TrackEventType {
|
||||
STARTED, ENDED, EXCEPTION, STUCK
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a track lifecycle event for timeline visualization.
|
||||
*/
|
||||
public record TrackEvent(long timestamp, TrackEventType type, String trackTitle, String trackUri) {
|
||||
/**
|
||||
* Returns a short display string for the event type.
|
||||
*/
|
||||
public String typeSymbol() {
|
||||
return switch (type) {
|
||||
case STARTED -> "▶";
|
||||
case ENDED -> "■";
|
||||
case EXCEPTION -> "⚠";
|
||||
case STUCK -> "✕";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Lavaplayer track stuck event (decoder/stream stall).
|
||||
* These indicate the audio source couldn't provide data for the threshold duration.
|
||||
*/
|
||||
public record StuckEvent(long timestamp, long thresholdMs, String trackTitle, String trackUri) {
|
||||
/**
|
||||
* Returns severity based on threshold duration.
|
||||
*/
|
||||
public Severity severity() {
|
||||
if (thresholdMs >= 20000) return Severity.SEVERE;
|
||||
if (thresholdMs >= 10000) return Severity.MODERATE;
|
||||
return Severity.MINOR;
|
||||
}
|
||||
|
||||
public enum Severity {
|
||||
MINOR, MODERATE, SEVERE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of current metrics for UI display.
|
||||
*/
|
||||
public record MetricsSnapshot(
|
||||
long guildId,
|
||||
long sessionDurationMs,
|
||||
long totalFramesRequested,
|
||||
long totalFramesProvided,
|
||||
long totalFramesMissed,
|
||||
double missRatePercent,
|
||||
double missRate10s,
|
||||
double missRate60s,
|
||||
double avgLatencyMs,
|
||||
double p95LatencyMs,
|
||||
long stutterCount,
|
||||
long stuckCount,
|
||||
int recentFramesMissed,
|
||||
double framesPerSecond,
|
||||
int graphWindowSeconds,
|
||||
int bufferHealth,
|
||||
int consecutiveStreak,
|
||||
long lateFrameCount,
|
||||
double avgCadenceDeltaMs,
|
||||
long maxCadenceDeltaMs,
|
||||
long timeToFirstFrameMs,
|
||||
FrameMetric[] frameHistory,
|
||||
StutterEvent[] stutterEvents,
|
||||
StuckEvent[] stuckEvents,
|
||||
TrackEvent[] trackEvents,
|
||||
LatencyBucket[] latencyBuckets,
|
||||
QualityPoint[] qualityHistory,
|
||||
GCMonitor.GCEvent[] gcEvents
|
||||
) {
|
||||
public String formattedDuration() {
|
||||
long seconds = sessionDurationMs / 1000;
|
||||
long minutes = seconds / 60;
|
||||
seconds = seconds % 60;
|
||||
return String.format("%d:%02d", minutes, seconds);
|
||||
}
|
||||
|
||||
public HealthStatus healthStatus() {
|
||||
if (missRatePercent > 5 || recentFramesMissed > 3 || stuckCount > 0) {
|
||||
return HealthStatus.CRITICAL;
|
||||
}
|
||||
if (missRatePercent > 1 || recentFramesMissed > 0 || stutterCount > 0 || maxCadenceDeltaMs > 50) {
|
||||
return HealthStatus.WARNING;
|
||||
}
|
||||
return HealthStatus.GOOD;
|
||||
}
|
||||
|
||||
public int qualityScore() {
|
||||
double score = 100.0;
|
||||
score -= Math.min(50, missRatePercent * 10);
|
||||
score -= Math.min(30, recentFramesMissed * 6);
|
||||
score -= Math.min(20, stuckCount * 10); // Stuck events heavily impact quality
|
||||
if (avgLatencyMs > 5) {
|
||||
score -= Math.min(20, (avgLatencyMs - 5) * 2);
|
||||
}
|
||||
// Penalize high jitter/late frames
|
||||
if (maxCadenceDeltaMs > 50) {
|
||||
score -= Math.min(10, (maxCadenceDeltaMs - 50) / 10.0);
|
||||
}
|
||||
return (int) Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are cadence/jitter issues.
|
||||
*/
|
||||
public boolean hasJitterIssues() {
|
||||
return maxCadenceDeltaMs > 30 || avgCadenceDeltaMs > 5;
|
||||
}
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
/**
|
||||
* Exports this snapshot to JSON for diagnostics sharing.
|
||||
*
|
||||
* @return JSON string representation
|
||||
*/
|
||||
public String toJson() {
|
||||
ObjectNode root = MAPPER.createObjectNode();
|
||||
|
||||
// Metadata
|
||||
root.put("exportTime", TIME_FMT.format(Instant.now()));
|
||||
root.put("guildId", guildId);
|
||||
root.put("windowSeconds", graphWindowSeconds);
|
||||
|
||||
// Summary
|
||||
ObjectNode summary = root.putObject("summary");
|
||||
summary.put("healthStatus", healthStatus().name());
|
||||
summary.put("qualityScore", qualityScore());
|
||||
summary.put("duration", formattedDuration());
|
||||
summary.put("sessionDurationMs", sessionDurationMs);
|
||||
|
||||
// Frame Stats
|
||||
ObjectNode frames = root.putObject("frameStats");
|
||||
frames.put("requested", totalFramesRequested);
|
||||
frames.put("provided", totalFramesProvided);
|
||||
frames.put("missed", totalFramesMissed);
|
||||
frames.put("missRatePercent", Math.round(missRatePercent * 100) / 100.0);
|
||||
frames.put("missRate10s", Math.round(missRate10s * 100) / 100.0);
|
||||
frames.put("missRate60s", Math.round(missRate60s * 100) / 100.0);
|
||||
frames.put("avgLatencyMs", Math.round(avgLatencyMs * 100) / 100.0);
|
||||
frames.put("p95LatencyMs", Math.round(p95LatencyMs * 100) / 100.0);
|
||||
frames.put("framesPerSecond", Math.round(framesPerSecond * 10) / 10.0);
|
||||
frames.put("bufferHealth", bufferHealth);
|
||||
frames.put("consecutiveStreak", consecutiveStreak);
|
||||
frames.put("recentMissed", recentFramesMissed);
|
||||
frames.put("timeToFirstFrameMs", timeToFirstFrameMs);
|
||||
|
||||
// Cadence/Jitter Stats
|
||||
ObjectNode cadence = root.putObject("cadenceStats");
|
||||
cadence.put("lateFrameCount", lateFrameCount);
|
||||
cadence.put("avgCadenceDeltaMs", Math.round(avgCadenceDeltaMs * 100) / 100.0);
|
||||
cadence.put("maxCadenceDeltaMs", maxCadenceDeltaMs);
|
||||
cadence.put("hasJitterIssues", hasJitterIssues());
|
||||
|
||||
// Event counts
|
||||
ObjectNode events = root.putObject("eventCounts");
|
||||
events.put("stutters", stutterCount);
|
||||
events.put("stuckEvents", stuckCount);
|
||||
events.put("trackEvents", trackEvents.length);
|
||||
events.put("gcEvents", gcEvents.length);
|
||||
events.put("lateFrames", lateFrameCount);
|
||||
|
||||
// Stutter events
|
||||
ArrayNode stutterArr = root.putArray("stutterEvents");
|
||||
for (StutterEvent e : stutterEvents) {
|
||||
ObjectNode node = stutterArr.addObject();
|
||||
node.put("timestamp", e.timestamp());
|
||||
node.put("time", TIME_FMT.format(Instant.ofEpochMilli(e.timestamp())));
|
||||
node.put("missedFrames", e.missedFrames());
|
||||
node.put("durationMs", e.durationMs());
|
||||
node.put("severity", e.severity().name());
|
||||
}
|
||||
|
||||
// Stuck events
|
||||
ArrayNode stuckArr = root.putArray("stuckEvents");
|
||||
for (StuckEvent e : stuckEvents) {
|
||||
ObjectNode node = stuckArr.addObject();
|
||||
node.put("timestamp", e.timestamp());
|
||||
node.put("time", TIME_FMT.format(Instant.ofEpochMilli(e.timestamp())));
|
||||
node.put("thresholdMs", e.thresholdMs());
|
||||
node.put("trackTitle", e.trackTitle());
|
||||
node.put("trackUri", e.trackUri());
|
||||
node.put("severity", e.severity().name());
|
||||
}
|
||||
|
||||
// Track events
|
||||
ArrayNode trackArr = root.putArray("trackEvents");
|
||||
for (TrackEvent e : trackEvents) {
|
||||
ObjectNode node = trackArr.addObject();
|
||||
node.put("timestamp", e.timestamp());
|
||||
node.put("time", TIME_FMT.format(Instant.ofEpochMilli(e.timestamp())));
|
||||
node.put("type", e.type().name());
|
||||
node.put("trackTitle", e.trackTitle());
|
||||
node.put("trackUri", e.trackUri());
|
||||
}
|
||||
|
||||
// GC events
|
||||
ArrayNode gcArr = root.putArray("gcEvents");
|
||||
for (GCMonitor.GCEvent e : gcEvents) {
|
||||
ObjectNode node = gcArr.addObject();
|
||||
node.put("timestamp", e.timestamp());
|
||||
node.put("time", TIME_FMT.format(Instant.ofEpochMilli(e.timestamp())));
|
||||
node.put("collector", e.collectorName());
|
||||
node.put("collections", e.collections());
|
||||
node.put("durationMs", e.durationMs());
|
||||
node.put("severity", e.severity().name());
|
||||
}
|
||||
|
||||
// Latency buckets (for charting)
|
||||
ArrayNode latencyArr = root.putArray("latencyBuckets");
|
||||
for (LatencyBucket b : latencyBuckets) {
|
||||
ObjectNode node = latencyArr.addObject();
|
||||
node.put("timestamp", b.timestamp());
|
||||
node.put("min", Math.round(b.min() * 100) / 100.0);
|
||||
node.put("max", Math.round(b.max() * 100) / 100.0);
|
||||
node.put("avg", Math.round(b.avg() * 100) / 100.0);
|
||||
}
|
||||
|
||||
// Quality history (for charting)
|
||||
ArrayNode qualityArr = root.putArray("qualityHistory");
|
||||
for (QualityPoint p : qualityHistory) {
|
||||
ObjectNode node = qualityArr.addObject();
|
||||
node.put("timestamp", p.timestamp());
|
||||
node.put("score", p.score());
|
||||
}
|
||||
|
||||
try {
|
||||
return MAPPER.writeValueAsString(root);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{\"error\": \"Failed to serialize metrics\"}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a compact summary suitable for Discord posts.
|
||||
*
|
||||
* @return formatted summary string
|
||||
*/
|
||||
public String toDiscordSummary() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("**JMusicBot Audio Diagnostics**\n");
|
||||
sb.append("```\n");
|
||||
sb.append("Health: ").append(healthStatus().name())
|
||||
.append(" | Quality: ").append(qualityScore()).append("%\n");
|
||||
sb.append("Duration: ").append(formattedDuration())
|
||||
.append(" | Window: ").append(graphWindowSeconds).append("s\n");
|
||||
sb.append("\n");
|
||||
sb.append("Frames: ").append(totalFramesProvided).append(" provided, ")
|
||||
.append(totalFramesMissed).append(" missed\n");
|
||||
sb.append("Miss Rate: ").append(String.format("%.2f%%", missRatePercent));
|
||||
sb.append(" (10s: ").append(String.format("%.2f%%", missRate10s));
|
||||
sb.append(", 60s: ").append(String.format("%.2f%%", missRate60s)).append(")\n");
|
||||
sb.append("Avg Latency: ").append(String.format("%.2f ms", avgLatencyMs))
|
||||
.append(" | p95: ").append(String.format("%.2f ms", p95LatencyMs)).append("\n");
|
||||
sb.append("Buffer Health: ").append(bufferHealth).append("%\n");
|
||||
if (timeToFirstFrameMs > 0) {
|
||||
sb.append("Time to First Frame: ").append(timeToFirstFrameMs).append("ms\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
sb.append("Stutters: ").append(stutterCount).append("\n");
|
||||
sb.append("Stuck Events: ").append(stuckCount).append("\n");
|
||||
sb.append("Track Events: ").append(trackEvents.length).append("\n");
|
||||
sb.append("GC Events: ").append(gcEvents.length).append("\n");
|
||||
sb.append("Late Frames: ").append(lateFrameCount)
|
||||
.append(" (max jitter: ").append(maxCadenceDeltaMs).append("ms)\n");
|
||||
sb.append("```\n");
|
||||
sb.append("*Exported: ").append(TIME_FMT.format(Instant.now())).append("*");
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
public enum HealthStatus {
|
||||
GOOD, WARNING, CRITICAL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import org.apache.http.HttpHost;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.BotConfig;
|
||||
import com.jagrosh.jmusicbot.utils.ProxyUtil;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class PlayerManager extends DefaultAudioPlayerManager
|
||||
{
|
||||
private final static Logger LOGGER = LoggerFactory.getLogger(PlayerManager.class);
|
||||
private final Bot bot;
|
||||
|
||||
public PlayerManager(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
}
|
||||
|
||||
public void init()
|
||||
{
|
||||
BotConfig config = bot.getConfig();
|
||||
|
||||
// Configure frame buffer for GC protection (default 2000ms = 2 seconds of audio buffered)
|
||||
setFrameBufferDuration(config.getFrameBufferMs());
|
||||
|
||||
// Configure proxy for Lavaplayer HTTP requests if enabled
|
||||
// This should be done BEFORE registering source managers
|
||||
if (config.proxyLavaplayer() && config.hasProxy()) {
|
||||
HttpHost proxy = ProxyUtil.createApacheProxy(config);
|
||||
setHttpBuilderConfigurator(builder -> builder.setProxy(proxy));
|
||||
LOGGER.info("Lavaplayer configured to use proxy: {}:{}",
|
||||
config.getProxyHost(), config.getProxyPort());
|
||||
}
|
||||
|
||||
// Register transformative audio sources
|
||||
TransformativeAudioSourceManager.createTransforms(config.getTransforms())
|
||||
.forEach(this::registerSourceManager);
|
||||
|
||||
// Register enabled audio sources with error handling
|
||||
for (AudioSource source : config.getEnabledAudioSources())
|
||||
{
|
||||
try
|
||||
{
|
||||
source.register(this, config);
|
||||
LOGGER.debug("Successfully registered audio source: {}", source.getConfigName());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOGGER.error("Failed to register audio source '{}': {}",
|
||||
source.getConfigName(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Bot getBot()
|
||||
{
|
||||
return bot;
|
||||
}
|
||||
|
||||
public boolean hasHandler(Guild guild)
|
||||
{
|
||||
return guild.getAudioManager().getSendingHandler()!=null;
|
||||
}
|
||||
|
||||
public AudioHandler setUpHandler(Guild guild)
|
||||
{
|
||||
AudioHandler handler;
|
||||
if(guild.getAudioManager().getSendingHandler()==null)
|
||||
{
|
||||
AudioPlayer player = createPlayer();
|
||||
player.setVolume(bot.getSettingsManager().getSettings(guild).getVolume());
|
||||
handler = new AudioHandler(this, guild, player);
|
||||
player.addListener(handler);
|
||||
guild.getAudioManager().setSendingHandler(handler);
|
||||
}
|
||||
else
|
||||
handler = (AudioHandler) guild.getAudioManager().getSendingHandler();
|
||||
return handler;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2021 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.jagrosh.jmusicbot.queue.Queueable;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import com.jagrosh.jmusicbot.utils.TimeUtil;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class QueuedTrack implements Queueable
|
||||
{
|
||||
private final AudioTrack track;
|
||||
private final RequestMetadata requestMetadata;
|
||||
|
||||
public QueuedTrack(AudioTrack track, RequestMetadata rm)
|
||||
{
|
||||
this.track = track;
|
||||
this.track.setUserData(rm == null ? RequestMetadata.EMPTY : rm);
|
||||
|
||||
this.requestMetadata = rm;
|
||||
if (this.track.isSeekable() && rm != null)
|
||||
track.setPosition(rm.requestInfo.startTimestamp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getIdentifier()
|
||||
{
|
||||
return requestMetadata.getOwner();
|
||||
}
|
||||
|
||||
public AudioTrack getTrack()
|
||||
{
|
||||
return track;
|
||||
}
|
||||
|
||||
public RequestMetadata getRequestMetadata()
|
||||
{
|
||||
return requestMetadata;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
String entry = "`[" + TimeUtil.formatTime(track.getDuration()) + "]` ";
|
||||
var trackInfo = track.getInfo();
|
||||
String title = FormatUtil.getTrackTitle(track);
|
||||
String safeTitle = FormatUtil.filter(title == null ? "" : title);
|
||||
entry = entry + (trackInfo.uri.startsWith("http") ? "[**" + safeTitle + "**](" + trackInfo.uri + ")" : "**" + safeTitle + "**");
|
||||
return entry + " - <@" + track.getUserData(RequestMetadata.class).getOwner() + ">";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2021 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.utils.TimeUtil;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import net.dv8tion.jda.api.entities.User;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class RequestMetadata
|
||||
{
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public static final RequestMetadata EMPTY = new RequestMetadata(null, null, 0L);
|
||||
|
||||
public final UserInfo user;
|
||||
public final RequestInfo requestInfo;
|
||||
public final long channelId;
|
||||
|
||||
@JsonCreator
|
||||
public RequestMetadata(
|
||||
@JsonProperty("user") UserInfo user,
|
||||
@JsonProperty("requestInfo") RequestInfo requestInfo,
|
||||
@JsonProperty("channelId") Long channelId)
|
||||
{
|
||||
this.user = user;
|
||||
this.requestInfo = requestInfo;
|
||||
this.channelId = channelId != null ? channelId : 0L;
|
||||
}
|
||||
|
||||
public RequestMetadata(User user, RequestInfo requestInfo, long channelId)
|
||||
{
|
||||
this.user = user == null
|
||||
? null
|
||||
: new UserInfo(user.getIdLong(), user.getName(), user.getDiscriminator(), user.getEffectiveAvatarUrl());
|
||||
this.requestInfo = requestInfo;
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
public RequestMetadata(User user, RequestInfo requestInfo)
|
||||
{
|
||||
this(user, requestInfo, 0L);
|
||||
}
|
||||
|
||||
public long getOwner()
|
||||
{
|
||||
return user == null ? 0L : user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns JSON representation of this object.
|
||||
* This ensures compatibility with YouTube source extensions that expect JSON userData.
|
||||
*/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
try
|
||||
{
|
||||
return objectMapper.writeValueAsString(this);
|
||||
}
|
||||
catch (JsonProcessingException e)
|
||||
{
|
||||
// Fallback to a simple JSON-like representation if serialization fails
|
||||
return "{\"user\":" + (user != null ? user.toString() : "null") +
|
||||
",\"requestInfo\":" + (requestInfo != null ? requestInfo.toString() : "null") + "}";
|
||||
}
|
||||
}
|
||||
|
||||
public static RequestMetadata fromResultHandler(AudioTrack track, CommandEvent event)
|
||||
{
|
||||
return new RequestMetadata(event.getAuthor(), new RequestInfo(event.getArgs(), track.getInfo().uri), event.getChannel().getIdLong());
|
||||
}
|
||||
|
||||
public static class RequestInfo
|
||||
{
|
||||
public final String query, url;
|
||||
public final long startTimestamp;
|
||||
|
||||
@JsonCreator
|
||||
public RequestInfo(
|
||||
@JsonProperty("query") String query,
|
||||
@JsonProperty("url") String url,
|
||||
@JsonProperty("startTimestamp") Long startTimestamp)
|
||||
{
|
||||
this.url = url;
|
||||
this.query = query;
|
||||
// If startTimestamp is null or 0 and we have a query, try to extract it from the query
|
||||
if (startTimestamp == null || startTimestamp == 0)
|
||||
{
|
||||
this.startTimestamp = query != null ? tryGetTimestamp(query) : 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.startTimestamp = startTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
public RequestInfo(String query, String url)
|
||||
{
|
||||
this(query, url, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
try
|
||||
{
|
||||
return objectMapper.writeValueAsString(this);
|
||||
}
|
||||
catch (JsonProcessingException e)
|
||||
{
|
||||
return "{\"query\":\"" + (query != null ? query.replace("\"", "\\\"") : "") +
|
||||
"\",\"url\":\"" + (url != null ? url.replace("\"", "\\\"") : "") +
|
||||
"\",\"startTimestamp\":" + startTimestamp + "}";
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern youtubeTimestampPattern = Pattern.compile("youtu(?:\\.be|be\\..+)/.*\\?.*(?!.*list=)t=([\\dhms]+)");
|
||||
private static long tryGetTimestamp(String url)
|
||||
{
|
||||
Matcher matcher = youtubeTimestampPattern.matcher(url);
|
||||
return matcher.find() ? TimeUtil.parseUnitTime(matcher.group(1)) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static class UserInfo
|
||||
{
|
||||
public final long id;
|
||||
public final String username, discrim, avatar;
|
||||
|
||||
@JsonCreator
|
||||
public UserInfo(
|
||||
@JsonProperty("id") long id,
|
||||
@JsonProperty("username") String username,
|
||||
@JsonProperty("discrim") String discrim,
|
||||
@JsonProperty("avatar") String avatar)
|
||||
{
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.discrim = discrim;
|
||||
this.avatar = avatar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
try
|
||||
{
|
||||
return objectMapper.writeValueAsString(this);
|
||||
}
|
||||
catch (JsonProcessingException e)
|
||||
{
|
||||
return "{\"id\":" + id +
|
||||
",\"username\":\"" + (username != null ? username.replace("\"", "\\\"") : "") +
|
||||
"\",\"discrim\":\"" + (discrim != null ? discrim.replace("\"", "\\\"") : "") +
|
||||
"\",\"avatar\":\"" + (avatar != null ? avatar.replace("\"", "\\\"") : "") + "\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.MemoryUsage;
|
||||
import java.lang.management.ThreadMXBean;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Monitors JVM and system health metrics for diagnostics.
|
||||
* Tracks CPU usage, memory, and thread statistics.
|
||||
*
|
||||
* <p>Uses {@link com.sun.management.OperatingSystemMXBean} for CPU metrics
|
||||
* and {@link ThreadMXBean} for thread statistics.
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public final class SystemHealthMonitor {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SystemHealthMonitor.class);
|
||||
private static final int MAX_SAMPLES = 600; // 10 minutes at 1s intervals
|
||||
private static final int SAMPLE_INTERVAL_MS = 1000; // Sample every second
|
||||
|
||||
private static volatile SystemHealthMonitor instance;
|
||||
|
||||
private final ConcurrentLinkedDeque<HealthSample> samples = new ConcurrentLinkedDeque<>();
|
||||
private final ScheduledExecutorService scheduler;
|
||||
private final MemoryMXBean memoryBean;
|
||||
private final ThreadMXBean threadBean;
|
||||
private final com.sun.management.OperatingSystemMXBean osBean;
|
||||
|
||||
private volatile boolean running = false;
|
||||
private volatile long lastHeapUsed = 0;
|
||||
private volatile long lastSampleTime = 0;
|
||||
private volatile long expectedNextSampleTime = 0;
|
||||
private volatile long maxDriftMs = 0;
|
||||
private volatile long totalDriftMs = 0;
|
||||
private volatile long driftSampleCount = 0;
|
||||
|
||||
private SystemHealthMonitor() {
|
||||
this.memoryBean = ManagementFactory.getMemoryMXBean();
|
||||
this.threadBean = ManagementFactory.getThreadMXBean();
|
||||
|
||||
// Get the sun.management version for CPU metrics
|
||||
var osMxBean = ManagementFactory.getOperatingSystemMXBean();
|
||||
if (osMxBean instanceof com.sun.management.OperatingSystemMXBean sunBean) {
|
||||
this.osBean = sunBean;
|
||||
} else {
|
||||
this.osBean = null;
|
||||
LOG.warn("OperatingSystemMXBean not available - CPU metrics will be unavailable");
|
||||
}
|
||||
|
||||
// Create daemon scheduler
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "SystemHealthMonitor");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance.
|
||||
*/
|
||||
public static SystemHealthMonitor getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SystemHealthMonitor.class) {
|
||||
if (instance == null) {
|
||||
instance = new SystemHealthMonitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the health monitoring thread.
|
||||
*/
|
||||
public void start() {
|
||||
if (running) return;
|
||||
|
||||
running = true;
|
||||
long now = System.currentTimeMillis();
|
||||
lastSampleTime = now;
|
||||
lastHeapUsed = getHeapUsed();
|
||||
expectedNextSampleTime = now + SAMPLE_INTERVAL_MS;
|
||||
maxDriftMs = 0;
|
||||
totalDriftMs = 0;
|
||||
driftSampleCount = 0;
|
||||
|
||||
scheduler.scheduleAtFixedRate(this::takeSample, SAMPLE_INTERVAL_MS, SAMPLE_INTERVAL_MS, TimeUnit.MILLISECONDS);
|
||||
LOG.info("System Health Monitor started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the health monitoring thread.
|
||||
*/
|
||||
public void stop() {
|
||||
running = false;
|
||||
scheduler.shutdown();
|
||||
LOG.info("System Health Monitor stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a health sample.
|
||||
*/
|
||||
private void takeSample() {
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
long timeDelta = now - lastSampleTime;
|
||||
|
||||
// Calculate scheduler drift (how late this sample is)
|
||||
long schedulerDriftMs = 0;
|
||||
if (expectedNextSampleTime > 0) {
|
||||
schedulerDriftMs = now - expectedNextSampleTime;
|
||||
if (schedulerDriftMs > 0) {
|
||||
totalDriftMs += schedulerDriftMs;
|
||||
driftSampleCount++;
|
||||
if (schedulerDriftMs > maxDriftMs) {
|
||||
maxDriftMs = schedulerDriftMs;
|
||||
if (schedulerDriftMs > 50) {
|
||||
LOG.debug("High scheduler drift detected: {}ms", schedulerDriftMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expectedNextSampleTime = now + SAMPLE_INTERVAL_MS;
|
||||
|
||||
// CPU metrics
|
||||
double processCpuLoad = osBean != null ? osBean.getProcessCpuLoad() * 100 : -1;
|
||||
double systemCpuLoad = osBean != null ? osBean.getCpuLoad() * 100 : -1;
|
||||
|
||||
// Memory metrics
|
||||
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
|
||||
long heapUsed = heapUsage.getUsed();
|
||||
long heapMax = heapUsage.getMax();
|
||||
double heapPercent = heapMax > 0 ? (heapUsed * 100.0) / heapMax : 0;
|
||||
|
||||
// Allocation rate (bytes per second)
|
||||
long heapDelta = heapUsed - lastHeapUsed;
|
||||
double allocationRate = timeDelta > 0 ? (heapDelta * 1000.0) / timeDelta : 0;
|
||||
|
||||
// Thread metrics
|
||||
int threadCount = threadBean.getThreadCount();
|
||||
int peakThreadCount = threadBean.getPeakThreadCount();
|
||||
long[] deadlockedThreads = threadBean.findDeadlockedThreads();
|
||||
int deadlockedCount = deadlockedThreads != null ? deadlockedThreads.length : 0;
|
||||
|
||||
// Create sample
|
||||
HealthSample sample = new HealthSample(
|
||||
now,
|
||||
processCpuLoad,
|
||||
systemCpuLoad,
|
||||
heapUsed,
|
||||
heapMax,
|
||||
heapPercent,
|
||||
allocationRate,
|
||||
threadCount,
|
||||
peakThreadCount,
|
||||
deadlockedCount,
|
||||
schedulerDriftMs
|
||||
);
|
||||
|
||||
samples.addLast(sample);
|
||||
|
||||
// Trim old samples
|
||||
while (samples.size() > MAX_SAMPLES) {
|
||||
samples.pollFirst();
|
||||
}
|
||||
|
||||
// Update for next iteration
|
||||
lastHeapUsed = heapUsed;
|
||||
lastSampleTime = now;
|
||||
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error taking health sample", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current heap memory used.
|
||||
*/
|
||||
private long getHeapUsed() {
|
||||
return memoryBean.getHeapMemoryUsage().getUsed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets recent health samples within the specified time window.
|
||||
*
|
||||
* @param windowSeconds how many seconds of history to return
|
||||
* @return array of health samples
|
||||
*/
|
||||
public HealthSample[] getRecentSamples(int windowSeconds) {
|
||||
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
|
||||
return samples.stream()
|
||||
.filter(s -> s.timestamp() >= cutoff)
|
||||
.toArray(HealthSample[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most recent health sample.
|
||||
*
|
||||
* @return latest sample or null if none available
|
||||
*/
|
||||
public HealthSample getLatestSample() {
|
||||
return samples.peekLast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all recorded samples.
|
||||
*/
|
||||
public HealthSample[] getAllSamples() {
|
||||
return samples.toArray(new HealthSample[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all recorded samples.
|
||||
*/
|
||||
public void clear() {
|
||||
samples.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum scheduler drift observed since start.
|
||||
*/
|
||||
public long getMaxDriftMs() {
|
||||
return maxDriftMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the average scheduler drift since start.
|
||||
*/
|
||||
public double getAvgDriftMs() {
|
||||
return driftSampleCount > 0 ? (double) totalDriftMs / driftSampleCount : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether CPU metrics are available.
|
||||
*/
|
||||
public boolean isCpuMetricsAvailable() {
|
||||
return osBean != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a snapshot summary of current system health.
|
||||
*/
|
||||
public HealthSnapshot getSnapshot(int windowSeconds) {
|
||||
HealthSample[] windowSamples = getRecentSamples(windowSeconds);
|
||||
HealthSample latest = getLatestSample();
|
||||
|
||||
if (latest == null || windowSamples.length == 0) {
|
||||
return HealthSnapshot.EMPTY;
|
||||
}
|
||||
|
||||
// Calculate averages and peaks
|
||||
double avgProcessCpu = 0, maxProcessCpu = 0;
|
||||
double avgSystemCpu = 0, maxSystemCpu = 0;
|
||||
double avgHeapPercent = 0, maxHeapPercent = 0;
|
||||
double avgAllocRate = 0, maxAllocRate = 0;
|
||||
int maxThreadCount = 0;
|
||||
long totalDrift = 0, maxDrift = 0;
|
||||
int driftCount = 0;
|
||||
|
||||
for (HealthSample s : windowSamples) {
|
||||
avgProcessCpu += s.processCpuPercent();
|
||||
maxProcessCpu = Math.max(maxProcessCpu, s.processCpuPercent());
|
||||
avgSystemCpu += s.systemCpuPercent();
|
||||
maxSystemCpu = Math.max(maxSystemCpu, s.systemCpuPercent());
|
||||
avgHeapPercent += s.heapUsedPercent();
|
||||
maxHeapPercent = Math.max(maxHeapPercent, s.heapUsedPercent());
|
||||
avgAllocRate += s.allocationRateBytesPerSec();
|
||||
maxAllocRate = Math.max(maxAllocRate, s.allocationRateBytesPerSec());
|
||||
maxThreadCount = Math.max(maxThreadCount, s.threadCount());
|
||||
|
||||
// Scheduler drift stats
|
||||
if (s.schedulerDriftMs() > 0) {
|
||||
totalDrift += s.schedulerDriftMs();
|
||||
driftCount++;
|
||||
maxDrift = Math.max(maxDrift, s.schedulerDriftMs());
|
||||
}
|
||||
}
|
||||
|
||||
int n = windowSamples.length;
|
||||
avgProcessCpu /= n;
|
||||
avgSystemCpu /= n;
|
||||
avgHeapPercent /= n;
|
||||
avgAllocRate /= n;
|
||||
double avgDrift = driftCount > 0 ? (double) totalDrift / driftCount : 0;
|
||||
|
||||
return new HealthSnapshot(
|
||||
latest.timestamp(),
|
||||
latest.processCpuPercent(),
|
||||
avgProcessCpu,
|
||||
maxProcessCpu,
|
||||
latest.systemCpuPercent(),
|
||||
avgSystemCpu,
|
||||
maxSystemCpu,
|
||||
latest.heapUsedBytes(),
|
||||
latest.heapMaxBytes(),
|
||||
latest.heapUsedPercent(),
|
||||
avgHeapPercent,
|
||||
maxHeapPercent,
|
||||
avgAllocRate,
|
||||
maxAllocRate,
|
||||
latest.threadCount(),
|
||||
maxThreadCount,
|
||||
latest.deadlockedThreadCount(),
|
||||
latest.schedulerDriftMs(),
|
||||
avgDrift,
|
||||
maxDrift,
|
||||
windowSamples
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single health sample.
|
||||
*/
|
||||
public record HealthSample(
|
||||
long timestamp,
|
||||
double processCpuPercent,
|
||||
double systemCpuPercent,
|
||||
long heapUsedBytes,
|
||||
long heapMaxBytes,
|
||||
double heapUsedPercent,
|
||||
double allocationRateBytesPerSec,
|
||||
int threadCount,
|
||||
int peakThreadCount,
|
||||
int deadlockedThreadCount,
|
||||
long schedulerDriftMs
|
||||
) {
|
||||
/**
|
||||
* Returns formatted heap usage.
|
||||
*/
|
||||
public String formattedHeap() {
|
||||
long usedMb = heapUsedBytes / (1024 * 1024);
|
||||
long maxMb = heapMaxBytes / (1024 * 1024);
|
||||
return usedMb + "/" + maxMb + " MB";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns formatted allocation rate.
|
||||
*/
|
||||
public String formattedAllocRate() {
|
||||
if (allocationRateBytesPerSec < 0) {
|
||||
return "N/A";
|
||||
}
|
||||
double mbPerSec = allocationRateBytesPerSec / (1024 * 1024);
|
||||
return String.format("%.1f MB/s", mbPerSec);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of system health for UI display.
|
||||
*/
|
||||
public record HealthSnapshot(
|
||||
long timestamp,
|
||||
double currentProcessCpu,
|
||||
double avgProcessCpu,
|
||||
double maxProcessCpu,
|
||||
double currentSystemCpu,
|
||||
double avgSystemCpu,
|
||||
double maxSystemCpu,
|
||||
long heapUsedBytes,
|
||||
long heapMaxBytes,
|
||||
double currentHeapPercent,
|
||||
double avgHeapPercent,
|
||||
double maxHeapPercent,
|
||||
double avgAllocationRate,
|
||||
double maxAllocationRate,
|
||||
int currentThreadCount,
|
||||
int maxThreadCount,
|
||||
int deadlockedThreads,
|
||||
long currentSchedulerDriftMs,
|
||||
double avgSchedulerDriftMs,
|
||||
long maxSchedulerDriftMs,
|
||||
HealthSample[] samples
|
||||
) {
|
||||
public static final HealthSnapshot EMPTY = new HealthSnapshot(
|
||||
0, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, new HealthSample[0]
|
||||
);
|
||||
|
||||
public boolean isEmpty() {
|
||||
return samples.length == 0;
|
||||
}
|
||||
|
||||
public String formattedHeap() {
|
||||
long usedMb = heapUsedBytes / (1024 * 1024);
|
||||
long maxMb = heapMaxBytes / (1024 * 1024);
|
||||
return usedMb + "/" + maxMb + " MB";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns health status based on metrics.
|
||||
*/
|
||||
public HealthStatus status() {
|
||||
if (deadlockedThreads > 0) return HealthStatus.CRITICAL;
|
||||
if (currentProcessCpu > 90 || currentHeapPercent > 90) return HealthStatus.CRITICAL;
|
||||
if (currentProcessCpu > 70 || currentHeapPercent > 80) return HealthStatus.WARNING;
|
||||
if (maxSchedulerDriftMs > 100) return HealthStatus.WARNING; // High scheduler drift
|
||||
return HealthStatus.GOOD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if scheduler drift indicates potential thread starvation.
|
||||
*/
|
||||
public boolean hasHighDrift() {
|
||||
return maxSchedulerDriftMs > 50;
|
||||
}
|
||||
}
|
||||
|
||||
public enum HealthStatus {
|
||||
GOOD, WARNING, CRITICAL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Monitors track loading operations to track performance and success rates by source.
|
||||
* This helps diagnose issues with specific audio sources (YouTube, Spotify, etc.).
|
||||
*
|
||||
* <p>Tracks for each load request:
|
||||
* <ul>
|
||||
* <li>Load duration (time from loadItem call to result)</li>
|
||||
* <li>Source manager (which audio source handled the request)</li>
|
||||
* <li>Load result (success, failure, no match)</li>
|
||||
* <li>Error messages for failures</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Implements {@link AudioLoadWrapper} for use with dependency injection.
|
||||
* When GUI is disabled, {@link AudioLoadWrapper#NO_OP} should be used instead
|
||||
* of this class to avoid allocating monitoring resources.
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public final class TrackLoadingMonitor implements AudioLoadWrapper {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TrackLoadingMonitor.class);
|
||||
private static final int MAX_LOAD_EVENTS = 500;
|
||||
private static final int MAX_EVENTS_PER_SOURCE = 100;
|
||||
|
||||
private static volatile TrackLoadingMonitor instance;
|
||||
|
||||
// Global load events
|
||||
private final ConcurrentLinkedDeque<LoadEvent> loadEvents = new ConcurrentLinkedDeque<>();
|
||||
|
||||
// Per-source statistics
|
||||
private final ConcurrentMap<String, SourceStats> sourceStats = new ConcurrentHashMap<>();
|
||||
|
||||
// Global counters
|
||||
private final AtomicLong totalLoads = new AtomicLong(0);
|
||||
private final AtomicLong successfulLoads = new AtomicLong(0);
|
||||
private final AtomicLong failedLoads = new AtomicLong(0);
|
||||
private final AtomicLong noMatchLoads = new AtomicLong(0);
|
||||
|
||||
private TrackLoadingMonitor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance.
|
||||
*/
|
||||
public static TrackLoadingMonitor getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (TrackLoadingMonitor.class) {
|
||||
if (instance == null) {
|
||||
instance = new TrackLoadingMonitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an AudioLoadResultHandler to monitor loading performance.
|
||||
* Implements the {@link AudioLoadWrapper} interface.
|
||||
*
|
||||
* @param query the search query or URL
|
||||
* @param delegate the original handler to delegate to
|
||||
* @return a wrapped handler that records metrics
|
||||
*/
|
||||
@Override
|
||||
public AudioLoadResultHandler wrap(String query, AudioLoadResultHandler delegate) {
|
||||
return createWrapper(query, delegate);
|
||||
}
|
||||
|
||||
private AudioLoadResultHandler createWrapper(String query, AudioLoadResultHandler delegate) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
return new AudioLoadResultHandler() {
|
||||
@Override
|
||||
public void trackLoaded(AudioTrack track) {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
String source = track.getSourceManager() != null
|
||||
? track.getSourceManager().getSourceName()
|
||||
: "unknown";
|
||||
|
||||
recordLoad(new LoadEvent(
|
||||
System.currentTimeMillis(),
|
||||
query,
|
||||
source,
|
||||
LoadResult.TRACK_LOADED,
|
||||
duration,
|
||||
track.getInfo().title,
|
||||
null
|
||||
));
|
||||
|
||||
delegate.trackLoaded(track);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playlistLoaded(AudioPlaylist playlist) {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
String source = "unknown";
|
||||
if (!playlist.getTracks().isEmpty() && playlist.getTracks().get(0).getSourceManager() != null) {
|
||||
source = playlist.getTracks().get(0).getSourceManager().getSourceName();
|
||||
}
|
||||
|
||||
recordLoad(new LoadEvent(
|
||||
System.currentTimeMillis(),
|
||||
query,
|
||||
source,
|
||||
LoadResult.PLAYLIST_LOADED,
|
||||
duration,
|
||||
playlist.getName(),
|
||||
null
|
||||
));
|
||||
|
||||
delegate.playlistLoaded(playlist);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void noMatches() {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
String source = guessSourceFromQuery(query);
|
||||
|
||||
recordLoad(new LoadEvent(
|
||||
System.currentTimeMillis(),
|
||||
query,
|
||||
source,
|
||||
LoadResult.NO_MATCHES,
|
||||
duration,
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
delegate.noMatches();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadFailed(FriendlyException exception) {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
String source = guessSourceFromQuery(query);
|
||||
|
||||
recordLoad(new LoadEvent(
|
||||
System.currentTimeMillis(),
|
||||
query,
|
||||
source,
|
||||
LoadResult.LOAD_FAILED,
|
||||
duration,
|
||||
null,
|
||||
exception.getMessage()
|
||||
));
|
||||
|
||||
delegate.loadFailed(exception);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses the source from the query URL.
|
||||
*/
|
||||
private String guessSourceFromQuery(String query) {
|
||||
if (query == null) return "unknown";
|
||||
query = query.toLowerCase();
|
||||
|
||||
if (query.contains("youtube.com") || query.contains("youtu.be")) {
|
||||
return "youtube";
|
||||
} else if (query.contains("soundcloud.com")) {
|
||||
return "soundcloud";
|
||||
} else if (query.contains("spotify.com")) {
|
||||
return "spotify";
|
||||
} else if (query.contains("twitch.tv")) {
|
||||
return "twitch";
|
||||
} else if (query.contains("bandcamp.com")) {
|
||||
return "bandcamp";
|
||||
} else if (query.contains("vimeo.com")) {
|
||||
return "vimeo";
|
||||
} else if (query.startsWith("http://") || query.startsWith("https://")) {
|
||||
return "http";
|
||||
}
|
||||
return "search";
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a load event.
|
||||
*/
|
||||
private void recordLoad(LoadEvent event) {
|
||||
totalLoads.incrementAndGet();
|
||||
|
||||
switch (event.result()) {
|
||||
case TRACK_LOADED, PLAYLIST_LOADED -> successfulLoads.incrementAndGet();
|
||||
case LOAD_FAILED -> failedLoads.incrementAndGet();
|
||||
case NO_MATCHES -> noMatchLoads.incrementAndGet();
|
||||
}
|
||||
|
||||
// Add to global events
|
||||
loadEvents.addLast(event);
|
||||
while (loadEvents.size() > MAX_LOAD_EVENTS) {
|
||||
loadEvents.pollFirst();
|
||||
}
|
||||
|
||||
// Update source stats
|
||||
sourceStats.computeIfAbsent(event.source(), k -> new SourceStats())
|
||||
.recordLoad(event);
|
||||
|
||||
if (event.result() == LoadResult.LOAD_FAILED) {
|
||||
LOG.debug("Track load failed for '{}' from {}: {}",
|
||||
event.query(), event.source(), event.errorMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets recent load events.
|
||||
*
|
||||
* @param limit maximum number of events to return
|
||||
* @return array of recent load events
|
||||
*/
|
||||
public LoadEvent[] getRecentEvents(int limit) {
|
||||
return loadEvents.stream()
|
||||
.skip(Math.max(0, loadEvents.size() - limit))
|
||||
.toArray(LoadEvent[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets load events for a specific source.
|
||||
*
|
||||
* @param source the source name
|
||||
* @param limit maximum number of events to return
|
||||
* @return array of load events for the source
|
||||
*/
|
||||
public LoadEvent[] getEventsForSource(String source, int limit) {
|
||||
return loadEvents.stream()
|
||||
.filter(e -> source.equalsIgnoreCase(e.source()))
|
||||
.skip(Math.max(0, loadEvents.size() - limit))
|
||||
.limit(limit)
|
||||
.toArray(LoadEvent[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets statistics for a specific source.
|
||||
*/
|
||||
public SourceStats getSourceStats(String source) {
|
||||
return sourceStats.get(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all source names that have been tracked.
|
||||
*/
|
||||
public String[] getTrackedSources() {
|
||||
return sourceStats.keySet().toArray(new String[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a snapshot of loading metrics.
|
||||
*/
|
||||
public LoadingSnapshot getSnapshot(int windowSeconds) {
|
||||
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
|
||||
|
||||
LoadEvent[] recentEvents = loadEvents.stream()
|
||||
.filter(e -> e.timestamp() >= cutoff)
|
||||
.toArray(LoadEvent[]::new);
|
||||
|
||||
int success = 0, failed = 0, noMatch = 0;
|
||||
long totalDuration = 0;
|
||||
|
||||
for (LoadEvent e : recentEvents) {
|
||||
totalDuration += e.loadDurationMs();
|
||||
switch (e.result()) {
|
||||
case TRACK_LOADED, PLAYLIST_LOADED -> success++;
|
||||
case LOAD_FAILED -> failed++;
|
||||
case NO_MATCHES -> noMatch++;
|
||||
}
|
||||
}
|
||||
|
||||
double avgDuration = recentEvents.length > 0 ? (double) totalDuration / recentEvents.length : 0;
|
||||
double successRate = recentEvents.length > 0 ? (success * 100.0) / recentEvents.length : 100;
|
||||
|
||||
// Calculate p95 load time
|
||||
double p95Duration = 0;
|
||||
if (recentEvents.length >= 5) {
|
||||
long[] durations = Arrays.stream(recentEvents)
|
||||
.mapToLong(LoadEvent::loadDurationMs)
|
||||
.sorted()
|
||||
.toArray();
|
||||
int p95Index = (int) Math.ceil(durations.length * 0.95) - 1;
|
||||
p95Index = Math.max(0, Math.min(p95Index, durations.length - 1));
|
||||
p95Duration = durations[p95Index];
|
||||
}
|
||||
|
||||
return new LoadingSnapshot(
|
||||
windowSeconds,
|
||||
totalLoads.get(),
|
||||
successfulLoads.get(),
|
||||
failedLoads.get(),
|
||||
noMatchLoads.get(),
|
||||
recentEvents.length,
|
||||
success,
|
||||
failed,
|
||||
noMatch,
|
||||
avgDuration,
|
||||
p95Duration,
|
||||
successRate,
|
||||
recentEvents,
|
||||
sourceStats.keySet().toArray(new String[0])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all recorded events and statistics.
|
||||
*/
|
||||
public void clear() {
|
||||
loadEvents.clear();
|
||||
sourceStats.clear();
|
||||
totalLoads.set(0);
|
||||
successfulLoads.set(0);
|
||||
failedLoads.set(0);
|
||||
noMatchLoads.set(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load result types.
|
||||
*/
|
||||
public enum LoadResult {
|
||||
TRACK_LOADED,
|
||||
PLAYLIST_LOADED,
|
||||
NO_MATCHES,
|
||||
LOAD_FAILED
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single track load event.
|
||||
*/
|
||||
public record LoadEvent(
|
||||
long timestamp,
|
||||
String query,
|
||||
String source,
|
||||
LoadResult result,
|
||||
long loadDurationMs,
|
||||
String trackOrPlaylistName,
|
||||
String errorMessage
|
||||
) {
|
||||
public boolean isSuccess() {
|
||||
return result == LoadResult.TRACK_LOADED || result == LoadResult.PLAYLIST_LOADED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-source statistics.
|
||||
*/
|
||||
public static class SourceStats {
|
||||
private final AtomicLong totalLoads = new AtomicLong(0);
|
||||
private final AtomicLong successfulLoads = new AtomicLong(0);
|
||||
private final AtomicLong failedLoads = new AtomicLong(0);
|
||||
private final AtomicLong totalDurationMs = new AtomicLong(0);
|
||||
private volatile long maxDurationMs = 0;
|
||||
private final ConcurrentLinkedDeque<Long> recentDurations = new ConcurrentLinkedDeque<>();
|
||||
|
||||
void recordLoad(LoadEvent event) {
|
||||
totalLoads.incrementAndGet();
|
||||
totalDurationMs.addAndGet(event.loadDurationMs());
|
||||
|
||||
if (event.loadDurationMs() > maxDurationMs) {
|
||||
maxDurationMs = event.loadDurationMs();
|
||||
}
|
||||
|
||||
if (event.isSuccess()) {
|
||||
successfulLoads.incrementAndGet();
|
||||
} else if (event.result() == LoadResult.LOAD_FAILED) {
|
||||
failedLoads.incrementAndGet();
|
||||
}
|
||||
|
||||
recentDurations.addLast(event.loadDurationMs());
|
||||
while (recentDurations.size() > MAX_EVENTS_PER_SOURCE) {
|
||||
recentDurations.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public long getTotalLoads() { return totalLoads.get(); }
|
||||
public long getSuccessfulLoads() { return successfulLoads.get(); }
|
||||
public long getFailedLoads() { return failedLoads.get(); }
|
||||
|
||||
public double getSuccessRate() {
|
||||
long total = totalLoads.get();
|
||||
return total > 0 ? (successfulLoads.get() * 100.0) / total : 100;
|
||||
}
|
||||
|
||||
public double getAverageDurationMs() {
|
||||
long total = totalLoads.get();
|
||||
return total > 0 ? (double) totalDurationMs.get() / total : 0;
|
||||
}
|
||||
|
||||
public long getMaxDurationMs() { return maxDurationMs; }
|
||||
|
||||
public double getP95DurationMs() {
|
||||
Long[] durations = recentDurations.toArray(new Long[0]);
|
||||
if (durations.length < 5) return getAverageDurationMs();
|
||||
|
||||
Arrays.sort(durations);
|
||||
int p95Index = (int) Math.ceil(durations.length * 0.95) - 1;
|
||||
p95Index = Math.max(0, Math.min(p95Index, durations.length - 1));
|
||||
return durations[p95Index];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of loading metrics for UI display.
|
||||
*/
|
||||
public record LoadingSnapshot(
|
||||
int windowSeconds,
|
||||
long totalLoadsAllTime,
|
||||
long successfulLoadsAllTime,
|
||||
long failedLoadsAllTime,
|
||||
long noMatchLoadsAllTime,
|
||||
int loadsInWindow,
|
||||
int successInWindow,
|
||||
int failedInWindow,
|
||||
int noMatchInWindow,
|
||||
double avgLoadDurationMs,
|
||||
double p95LoadDurationMs,
|
||||
double successRatePercent,
|
||||
LoadEvent[] recentEvents,
|
||||
String[] trackedSources
|
||||
) {
|
||||
public String formattedAvgDuration() {
|
||||
if (avgLoadDurationMs >= 1000) {
|
||||
return String.format("%.1fs", avgLoadDurationMs / 1000);
|
||||
}
|
||||
return String.format("%.0fms", avgLoadDurationMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2021 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
|
||||
import com.typesafe.config.Config;
|
||||
import dev.lavalink.youtube.YoutubeAudioSourceManager;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class TransformativeAudioSourceManager extends YoutubeAudioSourceManager
|
||||
{
|
||||
private final static Logger log = LoggerFactory.getLogger(TransformativeAudioSourceManager.class);
|
||||
private final String name, regex, replacement, selector, format;
|
||||
|
||||
public TransformativeAudioSourceManager(String name, Config object)
|
||||
{
|
||||
this(name, object.getString("regex"), object.getString("replacement"), object.getString("selector"), object.getString("format"));
|
||||
}
|
||||
|
||||
public TransformativeAudioSourceManager(String name, String regex, String replacement, String selector, String format)
|
||||
{
|
||||
this.name = name;
|
||||
this.regex = regex;
|
||||
this.replacement = replacement;
|
||||
this.selector = selector;
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSourceName()
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AudioItem loadItem(AudioPlayerManager apm, AudioReference ar)
|
||||
{
|
||||
if(ar.identifier == null || !ar.identifier.matches(regex))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
String url = ar.identifier.replaceAll(regex, replacement);
|
||||
Document doc = Jsoup.connect(url).get();
|
||||
String value = doc.selectFirst(selector).ownText();
|
||||
String formattedValue = String.format(format, value);
|
||||
return super.loadItem(apm, new AudioReference(formattedValue, null));
|
||||
}
|
||||
catch (PatternSyntaxException ex)
|
||||
{
|
||||
log.info(String.format("Invalid pattern syntax '%s' in source '%s'", regex, name));
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
log.warn(String.format("Failed to resolve URL in source '%s': ", name), ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.warn(String.format("Exception in source '%s'", name), ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<TransformativeAudioSourceManager> createTransforms(Config transforms)
|
||||
{
|
||||
try
|
||||
{
|
||||
return transforms.root().entrySet().stream()
|
||||
.map(e -> new TransformativeAudioSourceManager(e.getKey(), transforms.getConfig(e.getKey())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.warn("Invalid transform ", ex);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.audio;
|
||||
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
|
||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
/**
|
||||
* Monitors voice connection events to track reconnects and connection state changes.
|
||||
* This helps diagnose voice instability issues that can cause audio stuttering.
|
||||
*
|
||||
* <p>Tracks:
|
||||
* <ul>
|
||||
* <li>Voice connects and disconnects</li>
|
||||
* <li>Channel moves (reconnects)</li>
|
||||
* <li>Connection durations</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Arif Banai (arif-banai)
|
||||
*/
|
||||
public final class VoiceConnectionMonitor extends ListenerAdapter {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(VoiceConnectionMonitor.class);
|
||||
private static final int MAX_EVENTS_PER_GUILD = 100;
|
||||
private static final int MAX_GLOBAL_EVENTS = 500;
|
||||
|
||||
private static volatile VoiceConnectionMonitor instance;
|
||||
|
||||
/** Whether monitoring is enabled (disabled in no-GUI mode). Set at startup, must not be changed later. */
|
||||
private static volatile boolean enabled = true;
|
||||
|
||||
// Per-guild event tracking
|
||||
private final ConcurrentMap<Long, ConcurrentLinkedDeque<VoiceEvent>> guildEvents = new ConcurrentHashMap<>();
|
||||
|
||||
// Global event tracking (all guilds)
|
||||
private final ConcurrentLinkedDeque<VoiceEvent> globalEvents = new ConcurrentLinkedDeque<>();
|
||||
|
||||
// Track current connection state per guild
|
||||
private final ConcurrentMap<Long, ConnectionState> connectionStates = new ConcurrentHashMap<>();
|
||||
|
||||
private volatile JDA jda;
|
||||
|
||||
private VoiceConnectionMonitor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance.
|
||||
*/
|
||||
public static VoiceConnectionMonitor getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (VoiceConnectionMonitor.class) {
|
||||
if (instance == null) {
|
||||
instance = new VoiceConnectionMonitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether voice connection monitoring is enabled.
|
||||
* Should be called once at startup before any monitoring occurs.
|
||||
*
|
||||
* @param isEnabled true to enable monitoring, false to disable (no-GUI mode)
|
||||
*/
|
||||
public static void setEnabled(boolean isEnabled) {
|
||||
enabled = isEnabled;
|
||||
if (!enabled) {
|
||||
LOG.debug("Voice Connection Monitor disabled (no GUI mode)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this monitor with JDA.
|
||||
* Does nothing if monitoring is disabled (no-GUI mode).
|
||||
*
|
||||
* @param jda the JDA instance
|
||||
*/
|
||||
public void register(JDA jda) {
|
||||
if (!enabled) {
|
||||
LOG.debug("Voice Connection Monitor not registered (no GUI mode)");
|
||||
return;
|
||||
}
|
||||
this.jda = jda;
|
||||
jda.addEventListener(this);
|
||||
LOG.info("Voice Connection Monitor registered");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this monitor from JDA.
|
||||
*/
|
||||
public void unregister() {
|
||||
if (jda != null) {
|
||||
jda.removeEventListener(this);
|
||||
LOG.info("Voice Connection Monitor unregistered");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
|
||||
// Only track bot's own voice events
|
||||
if (!event.getMember().getUser().equals(event.getJDA().getSelfUser())) {
|
||||
return;
|
||||
}
|
||||
|
||||
long guildId = event.getGuild().getIdLong();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
if (event.getChannelLeft() == null && event.getChannelJoined() != null) {
|
||||
// Connected to voice
|
||||
recordEvent(guildId, new VoiceEvent(
|
||||
now,
|
||||
guildId,
|
||||
VoiceEventType.CONNECTED,
|
||||
null,
|
||||
event.getChannelJoined().getName()
|
||||
));
|
||||
|
||||
connectionStates.put(guildId, new ConnectionState(
|
||||
now,
|
||||
event.getChannelJoined().getName(),
|
||||
true,
|
||||
0
|
||||
));
|
||||
|
||||
} else if (event.getChannelLeft() != null && event.getChannelJoined() == null) {
|
||||
// Disconnected from voice
|
||||
ConnectionState prevState = connectionStates.get(guildId);
|
||||
|
||||
recordEvent(guildId, new VoiceEvent(
|
||||
now,
|
||||
guildId,
|
||||
VoiceEventType.DISCONNECTED,
|
||||
event.getChannelLeft().getName(),
|
||||
null
|
||||
));
|
||||
|
||||
if (prevState != null) {
|
||||
connectionStates.put(guildId, new ConnectionState(
|
||||
prevState.connectedSince(),
|
||||
null,
|
||||
false,
|
||||
prevState.reconnectCount()
|
||||
));
|
||||
}
|
||||
|
||||
} else if (event.getChannelLeft() != null && event.getChannelJoined() != null) {
|
||||
// Moved channels (potential reconnect)
|
||||
ConnectionState prevState = connectionStates.get(guildId);
|
||||
int reconnects = prevState != null ? prevState.reconnectCount() + 1 : 1;
|
||||
|
||||
recordEvent(guildId, new VoiceEvent(
|
||||
now,
|
||||
guildId,
|
||||
VoiceEventType.CHANNEL_MOVE,
|
||||
event.getChannelLeft().getName(),
|
||||
event.getChannelJoined().getName()
|
||||
));
|
||||
|
||||
connectionStates.put(guildId, new ConnectionState(
|
||||
prevState != null ? prevState.connectedSince() : now,
|
||||
event.getChannelJoined().getName(),
|
||||
true,
|
||||
reconnects
|
||||
));
|
||||
|
||||
LOG.debug("Voice channel move in guild {} (reconnect #{}): {} -> {}",
|
||||
guildId, reconnects, event.getChannelLeft().getName(), event.getChannelJoined().getName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a reconnect event manually (for when we detect reconnects through other means).
|
||||
*/
|
||||
public void recordReconnect(long guildId, String channelName) {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
recordEvent(guildId, new VoiceEvent(
|
||||
now,
|
||||
guildId,
|
||||
VoiceEventType.RECONNECT,
|
||||
channelName,
|
||||
channelName
|
||||
));
|
||||
|
||||
ConnectionState prevState = connectionStates.get(guildId);
|
||||
int reconnects = prevState != null ? prevState.reconnectCount() + 1 : 1;
|
||||
|
||||
connectionStates.put(guildId, new ConnectionState(
|
||||
prevState != null ? prevState.connectedSince() : now,
|
||||
channelName,
|
||||
true,
|
||||
reconnects
|
||||
));
|
||||
|
||||
LOG.debug("Voice reconnect recorded in guild {} (reconnect #{})", guildId, reconnects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a voice event.
|
||||
*/
|
||||
private void recordEvent(long guildId, VoiceEvent event) {
|
||||
// Add to guild-specific queue
|
||||
guildEvents.computeIfAbsent(guildId, k -> new ConcurrentLinkedDeque<>())
|
||||
.addLast(event);
|
||||
|
||||
// Trim guild queue
|
||||
ConcurrentLinkedDeque<VoiceEvent> guildQueue = guildEvents.get(guildId);
|
||||
while (guildQueue.size() > MAX_EVENTS_PER_GUILD) {
|
||||
guildQueue.pollFirst();
|
||||
}
|
||||
|
||||
// Add to global queue
|
||||
globalEvents.addLast(event);
|
||||
while (globalEvents.size() > MAX_GLOBAL_EVENTS) {
|
||||
globalEvents.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets recent voice events for a specific guild.
|
||||
*
|
||||
* @param guildId the guild ID
|
||||
* @param windowSeconds how many seconds of history to return
|
||||
* @return array of voice events
|
||||
*/
|
||||
public VoiceEvent[] getRecentEvents(long guildId, int windowSeconds) {
|
||||
ConcurrentLinkedDeque<VoiceEvent> queue = guildEvents.get(guildId);
|
||||
if (queue == null) {
|
||||
return new VoiceEvent[0];
|
||||
}
|
||||
|
||||
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
|
||||
return queue.stream()
|
||||
.filter(e -> e.timestamp() >= cutoff)
|
||||
.toArray(VoiceEvent[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all recent voice events across all guilds.
|
||||
*
|
||||
* @param windowSeconds how many seconds of history to return
|
||||
* @return array of voice events
|
||||
*/
|
||||
public VoiceEvent[] getAllRecentEvents(int windowSeconds) {
|
||||
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
|
||||
return globalEvents.stream()
|
||||
.filter(e -> e.timestamp() >= cutoff)
|
||||
.toArray(VoiceEvent[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current connection state for a guild.
|
||||
*
|
||||
* @param guildId the guild ID
|
||||
* @return connection state or null if not tracked
|
||||
*/
|
||||
public ConnectionState getConnectionState(long guildId) {
|
||||
return connectionStates.get(guildId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the reconnect count for a guild in the current session.
|
||||
*
|
||||
* @param guildId the guild ID
|
||||
* @return number of reconnects
|
||||
*/
|
||||
public int getReconnectCount(long guildId) {
|
||||
ConnectionState state = connectionStates.get(guildId);
|
||||
return state != null ? state.reconnectCount() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a snapshot of voice connection metrics for a guild.
|
||||
*/
|
||||
public VoiceSnapshot getSnapshot(long guildId, int windowSeconds) {
|
||||
VoiceEvent[] events = getRecentEvents(guildId, windowSeconds);
|
||||
ConnectionState state = getConnectionState(guildId);
|
||||
|
||||
int connects = 0, disconnects = 0, moves = 0, reconnects = 0;
|
||||
for (VoiceEvent e : events) {
|
||||
switch (e.type()) {
|
||||
case CONNECTED -> connects++;
|
||||
case DISCONNECTED -> disconnects++;
|
||||
case CHANNEL_MOVE -> moves++;
|
||||
case RECONNECT -> reconnects++;
|
||||
}
|
||||
}
|
||||
|
||||
long connectionDuration = 0;
|
||||
if (state != null && state.connected()) {
|
||||
connectionDuration = System.currentTimeMillis() - state.connectedSince();
|
||||
}
|
||||
|
||||
// Get gateway ping if JDA is available
|
||||
long gatewayPing = jda != null ? jda.getGatewayPing() : -1;
|
||||
|
||||
return new VoiceSnapshot(
|
||||
guildId,
|
||||
windowSeconds,
|
||||
events,
|
||||
connects,
|
||||
disconnects,
|
||||
moves,
|
||||
reconnects,
|
||||
state != null ? state.reconnectCount() : 0,
|
||||
state != null && state.connected(),
|
||||
state != null ? state.currentChannel() : null,
|
||||
connectionDuration,
|
||||
gatewayPing
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all recorded events.
|
||||
*/
|
||||
public void clear() {
|
||||
guildEvents.clear();
|
||||
globalEvents.clear();
|
||||
connectionStates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears events for a specific guild.
|
||||
*/
|
||||
public void clearGuild(long guildId) {
|
||||
guildEvents.remove(guildId);
|
||||
connectionStates.remove(guildId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of voice connection events.
|
||||
*/
|
||||
public enum VoiceEventType {
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
CHANNEL_MOVE,
|
||||
RECONNECT
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a voice connection event.
|
||||
*/
|
||||
public record VoiceEvent(
|
||||
long timestamp,
|
||||
long guildId,
|
||||
VoiceEventType type,
|
||||
String fromChannel,
|
||||
String toChannel
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Represents the current voice connection state for a guild.
|
||||
*/
|
||||
public record ConnectionState(
|
||||
long connectedSince,
|
||||
String currentChannel,
|
||||
boolean connected,
|
||||
int reconnectCount
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Snapshot of voice connection metrics for UI display.
|
||||
*/
|
||||
public record VoiceSnapshot(
|
||||
long guildId,
|
||||
int windowSeconds,
|
||||
VoiceEvent[] events,
|
||||
int connectCount,
|
||||
int disconnectCount,
|
||||
int channelMoveCount,
|
||||
int reconnectCountInWindow,
|
||||
int totalReconnects,
|
||||
boolean currentlyConnected,
|
||||
String currentChannel,
|
||||
long connectionDurationMs,
|
||||
long gatewayPingMs
|
||||
) {
|
||||
public String formattedDuration() {
|
||||
if (!currentlyConnected) return "Not connected";
|
||||
long seconds = connectionDurationMs / 1000;
|
||||
long minutes = seconds / 60;
|
||||
seconds = seconds % 60;
|
||||
if (minutes >= 60) {
|
||||
long hours = minutes / 60;
|
||||
minutes = minutes % 60;
|
||||
return String.format("%d:%02d:%02d", hours, minutes, seconds);
|
||||
}
|
||||
return String.format("%d:%02d", minutes, seconds);
|
||||
}
|
||||
|
||||
public boolean hasReconnects() {
|
||||
return totalReconnects > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.jagrosh.jmusicbot.commands;
|
||||
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Base implementation of OutputAdapter with no-op defaults.
|
||||
* Subclasses only need to override the methods they actually use.
|
||||
*/
|
||||
public abstract class BaseOutputAdapter implements MusicService.OutputAdapter
|
||||
{
|
||||
@Override
|
||||
public void replySuccess(String content)
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyError(String content)
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyWarning(String content)
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content)
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content, Consumer<Message> onSuccess)
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editNowPlaying(AudioHandler handler)
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editNoMusic(AudioHandler handler)
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowHelp()
|
||||
{
|
||||
// No-op by default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.jagrosh.jmusicbot.commands;
|
||||
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.GuildVoiceState;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||
import net.dv8tion.jda.api.exceptions.PermissionException;
|
||||
|
||||
/**
|
||||
* Shared validation logic for music commands (both text and slash commands).
|
||||
*/
|
||||
public final class MusicCommandValidator
|
||||
{
|
||||
private MusicCommandValidator() {} // Utility class
|
||||
|
||||
/**
|
||||
* Callback interface for handling validation errors.
|
||||
*/
|
||||
public interface ErrorHandler
|
||||
{
|
||||
void onTextChannelError(TextChannel requiredChannel);
|
||||
void onNotPlayingError();
|
||||
void onNotListeningError(AudioChannel requiredChannel);
|
||||
void onAfkChannelError();
|
||||
void onVoiceConnectError(AudioChannel channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates preconditions for music commands.
|
||||
*
|
||||
* @param guild The guild where the command was invoked
|
||||
* @param member The member who invoked the command
|
||||
* @param textChannel The text channel where the command was invoked
|
||||
* @param settings The guild settings
|
||||
* @param bot The bot instance
|
||||
* @param jda The JDA instance (for checking if music is playing)
|
||||
* @param bePlaying Whether music must be playing
|
||||
* @param beListening Whether the user must be listening in voice
|
||||
* @param errorHandler Callback for error messages
|
||||
* @return true if validation passed, false if an error was sent
|
||||
*/
|
||||
public static boolean validate(Guild guild, Member member, TextChannel textChannel,
|
||||
Settings settings, Bot bot, JDA jda,
|
||||
boolean bePlaying, boolean beListening,
|
||||
ErrorHandler errorHandler)
|
||||
{
|
||||
// Check text channel restriction
|
||||
TextChannel requiredChannel = settings.getTextChannel(guild);
|
||||
if (requiredChannel != null && !textChannel.equals(requiredChannel))
|
||||
{
|
||||
errorHandler.onTextChannelError(requiredChannel);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set up audio handler
|
||||
bot.getPlayerManager().setUpHandler(guild);
|
||||
|
||||
// Check if music must be playing
|
||||
if (bePlaying)
|
||||
{
|
||||
AudioHandler handler = (AudioHandler) guild.getAudioManager().getSendingHandler();
|
||||
if (handler == null || !handler.isMusicPlaying(jda))
|
||||
{
|
||||
errorHandler.onNotPlayingError();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user must be listening in voice
|
||||
if (beListening)
|
||||
{
|
||||
AudioChannel current = guild.getSelfMember().getVoiceState().getChannel();
|
||||
if (current == null)
|
||||
current = settings.getVoiceChannel(guild);
|
||||
|
||||
GuildVoiceState userState = member.getVoiceState();
|
||||
if (userState.getChannel() == null || userState.isDeafened() ||
|
||||
(current != null && !userState.getChannel().equals(current)))
|
||||
{
|
||||
errorHandler.onNotListeningError(current);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check AFK channel
|
||||
VoiceChannel afkChannel = guild.getAfkChannel();
|
||||
if (afkChannel != null && afkChannel.equals(userState.getChannel()))
|
||||
{
|
||||
errorHandler.onAfkChannelError();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Connect to voice channel if needed
|
||||
if (guild.getSelfMember().getVoiceState().getChannel() == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
guild.getAudioManager().openAudioConnection(userState.getChannel());
|
||||
}
|
||||
catch (PermissionException ex)
|
||||
{
|
||||
errorHandler.onVoiceConnectError(userState.getChannel());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandClient;
|
||||
import com.jagrosh.jdautilities.command.SlashCommand;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Registry that tracks slash command definitions and intelligently upserts
|
||||
* commands to Discord only when they have changed.
|
||||
*/
|
||||
public class SlashCommandRegistry
|
||||
{
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SlashCommandRegistry.class);
|
||||
private static final String HASH_FILE = ".slashcommands.hash";
|
||||
|
||||
/**
|
||||
* Registers slash commands with Discord if they have changed since the last registration.
|
||||
*
|
||||
* @param jda the JDA instance
|
||||
* @param client the CommandClient containing slash commands
|
||||
*/
|
||||
public static void registerIfChanged(JDA jda, CommandClient client)
|
||||
{
|
||||
List<SlashCommand> slashCommands = client.getSlashCommands();
|
||||
if (slashCommands == null || slashCommands.isEmpty())
|
||||
{
|
||||
LOG.info("No slash commands to register");
|
||||
return;
|
||||
}
|
||||
|
||||
String currentHash = calculateHash(slashCommands);
|
||||
String storedHash = loadStoredHash();
|
||||
|
||||
if (currentHash.equals(storedHash))
|
||||
{
|
||||
LOG.info("Slash commands unchanged, skipping registration ({} commands)", slashCommands.size());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("Slash commands changed, registering {} commands with Discord...", slashCommands.size());
|
||||
|
||||
// Build the command data for registration
|
||||
List<CommandData> commandData = slashCommands.stream()
|
||||
.map(SlashCommand::buildCommandData)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Register commands globally
|
||||
jda.updateCommands()
|
||||
.addCommands(commandData)
|
||||
.queue(
|
||||
commands -> {
|
||||
LOG.info("Successfully registered {} slash commands globally", commands.size());
|
||||
saveHash(currentHash);
|
||||
},
|
||||
error -> LOG.error("Failed to register slash commands: {}", error.getMessage())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces registration of all slash commands regardless of whether they've changed.
|
||||
*
|
||||
* @param jda the JDA instance
|
||||
* @param client the CommandClient containing slash commands
|
||||
*/
|
||||
public static void forceRegister(JDA jda, CommandClient client)
|
||||
{
|
||||
List<SlashCommand> slashCommands = client.getSlashCommands();
|
||||
if (slashCommands == null || slashCommands.isEmpty())
|
||||
{
|
||||
LOG.info("No slash commands to register");
|
||||
return;
|
||||
}
|
||||
|
||||
String currentHash = calculateHash(slashCommands);
|
||||
LOG.info("Force registering {} slash commands with Discord...", slashCommands.size());
|
||||
|
||||
List<CommandData> commandData = slashCommands.stream()
|
||||
.map(SlashCommand::buildCommandData)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
jda.updateCommands()
|
||||
.addCommands(commandData)
|
||||
.queue(
|
||||
commands -> {
|
||||
LOG.info("Successfully registered {} slash commands globally", commands.size());
|
||||
saveHash(currentHash);
|
||||
},
|
||||
error -> LOG.error("Failed to register slash commands: {}", error.getMessage())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a hash of all slash command definitions.
|
||||
* The hash is based on command name, description, and options.
|
||||
*/
|
||||
private static String calculateHash(List<SlashCommand> commands)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (SlashCommand cmd : commands)
|
||||
{
|
||||
SlashCommandData data = (SlashCommandData) cmd.buildCommandData();
|
||||
sb.append(data.getName()).append(":");
|
||||
sb.append(data.getDescription()).append(":");
|
||||
// Include options in hash
|
||||
data.getOptions().forEach(option -> {
|
||||
sb.append(option.getName()).append(",");
|
||||
sb.append(option.getDescription()).append(",");
|
||||
sb.append(option.getType().name()).append(",");
|
||||
sb.append(option.isRequired()).append(",");
|
||||
sb.append(option.isAutoComplete()).append(";");
|
||||
});
|
||||
// Include subcommands if any
|
||||
data.getSubcommands().forEach(sub -> {
|
||||
sb.append("sub:").append(sub.getName()).append(",");
|
||||
sb.append(sub.getDescription()).append(";");
|
||||
});
|
||||
sb.append("|");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes)
|
||||
{
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) hexString.append('0');
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
}
|
||||
catch (NoSuchAlgorithmException e)
|
||||
{
|
||||
LOG.warn("SHA-256 not available, using simple hash");
|
||||
return String.valueOf(sb.toString().hashCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the previously stored hash from file.
|
||||
*/
|
||||
private static String loadStoredHash()
|
||||
{
|
||||
try
|
||||
{
|
||||
Path path = OtherUtil.getPath(HASH_FILE);
|
||||
return new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
catch (NoSuchFileException e)
|
||||
{
|
||||
LOG.debug("No stored slash command hash found (first run)");
|
||||
return "";
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
LOG.warn("Failed to read stored slash command hash: {}", e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the hash to file for future comparison.
|
||||
*/
|
||||
private static void saveHash(String hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
Path path = OtherUtil.getPath(HASH_FILE);
|
||||
Files.write(path, hash.getBytes(StandardCharsets.UTF_8));
|
||||
LOG.debug("Saved slash command hash to {}", path.toAbsolutePath());
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
LOG.warn("Failed to save slash command hash: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the stored hash, forcing re-registration on next startup.
|
||||
*/
|
||||
public static void clearHash()
|
||||
{
|
||||
try
|
||||
{
|
||||
Path path = OtherUtil.getPath(HASH_FILE);
|
||||
Files.deleteIfExists(path);
|
||||
LOG.info("Cleared slash command hash");
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
LOG.warn("Failed to clear slash command hash: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1;
|
||||
|
||||
import com.jagrosh.jdautilities.command.Command;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public abstract class AdminCommand extends Command
|
||||
{
|
||||
public AdminCommand()
|
||||
{
|
||||
this.category = new Category("Admin", event ->
|
||||
{
|
||||
if(event.getAuthor().getId().equals(event.getClient().getOwnerId()))
|
||||
return true;
|
||||
if(event.getGuild()==null)
|
||||
return true;
|
||||
return event.getMember().hasPermission(Permission.MANAGE_SERVER);
|
||||
});
|
||||
this.guildOnly = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.jagrosh.jmusicbot.commands.v1;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandClient;
|
||||
import com.jagrosh.jdautilities.command.CommandClientBuilder;
|
||||
import com.jagrosh.jdautilities.examples.command.AboutCommand;
|
||||
import com.jagrosh.jdautilities.examples.command.PingCommand;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.BotConfig;
|
||||
import com.jagrosh.jmusicbot.JMusicBot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.admin.*;
|
||||
import com.jagrosh.jmusicbot.commands.v1.dj.*;
|
||||
import com.jagrosh.jmusicbot.commands.v1.general.SettingsCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v1.music.*;
|
||||
import com.jagrosh.jmusicbot.commands.v1.owner.*;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.ClearchannelSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.NpButtonsSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.NpLayoutSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.PrefixSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.QueuetypeSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.SetdjSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.SettingsSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.SettcSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.SetvcSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.SkipratioSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.ForceskipSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.ForceremoveSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.MovetrackSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.PauseSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.PlaynextSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.RepeatSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.SkiptoSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.StopSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.dj.VolumeSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.HistorySlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.NowPlayingSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.NpSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.PlaySlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.PlaylistsSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.QueueSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.RemoveSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.SearchSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.SeekSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.ShuffleSlashCmd;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.SkipSlashCmd;
|
||||
import com.jagrosh.jmusicbot.settings.SettingsManager;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
import net.dv8tion.jda.api.OnlineStatus;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.awt.*;
|
||||
|
||||
public class CommandFactory {
|
||||
|
||||
public static CommandClient createCommandClient(BotConfig config, SettingsManager settings, Bot bot) {
|
||||
AboutCommand aboutCommand = createAboutCommand();
|
||||
|
||||
CommandClientBuilder cb = new CommandClientBuilder()
|
||||
.setPrefix(config.getPrefix())
|
||||
.setAlternativePrefix(config.getAltPrefix())
|
||||
.setOwnerId(Long.toString(config.getOwnerId()))
|
||||
.setEmojis(config.getSuccess(), config.getWarning(), config.getError())
|
||||
.setHelpWord(config.getHelp())
|
||||
.setLinkedCacheSize(200)
|
||||
.setGuildSettingsManager(settings)
|
||||
.addCommands(aboutCommand,
|
||||
new PingCommand(),
|
||||
new SettingsCmd(bot),
|
||||
|
||||
// Lyrics functionality removed - JLyrics dependency removed
|
||||
// new LyricsCmd(bot),
|
||||
new NowPlayingCmd(bot),
|
||||
new PlayCmd(bot),
|
||||
new PlaylistsCmd(bot),
|
||||
new QueueCmd(bot),
|
||||
new HistoryCmd(bot),
|
||||
new RemoveCmd(bot),
|
||||
new SearchCmd(bot),
|
||||
new SCSearchCmd(bot),
|
||||
new SeekCmd(bot),
|
||||
new ShuffleCmd(bot),
|
||||
new SkipCmd(bot),
|
||||
|
||||
new ForceRemoveCmd(bot),
|
||||
new ForceskipCmd(bot),
|
||||
new MoveTrackCmd(bot),
|
||||
new PauseCmd(bot),
|
||||
new PlaynextCmd(bot),
|
||||
new RepeatCmd(bot),
|
||||
new SkiptoCmd(bot),
|
||||
new StopCmd(bot),
|
||||
new VolumeCmd(bot),
|
||||
|
||||
new PrefixCmd(bot),
|
||||
new QueueTypeCmd(bot),
|
||||
new NplayoutCmd(bot),
|
||||
new NpbuttonsCmd(bot),
|
||||
new SetdjCmd(bot),
|
||||
new SkipratioCmd(bot),
|
||||
new SettcCmd(bot),
|
||||
new SetvcCmd(bot),
|
||||
|
||||
new AutoplaylistCmd(bot),
|
||||
new DebugCmd(bot),
|
||||
new PlaylistCmd(bot),
|
||||
new SetavatarCmd(bot),
|
||||
new SetgameCmd(bot),
|
||||
new SetnameCmd(bot),
|
||||
new SetstatusCmd(bot),
|
||||
new ShutdownCmd(bot),
|
||||
new ClearchannelCmd(bot)
|
||||
).addSlashCommands(
|
||||
// Music commands
|
||||
new NowPlayingSlashCmd(bot),
|
||||
new NpSlashCmd(bot),
|
||||
new PlaySlashCmd(bot),
|
||||
new PlaylistsSlashCmd(bot),
|
||||
new QueueSlashCmd(bot),
|
||||
new HistorySlashCmd(bot),
|
||||
new RemoveSlashCmd(bot),
|
||||
new SearchSlashCmd(bot),
|
||||
new SeekSlashCmd(bot),
|
||||
new ShuffleSlashCmd(bot),
|
||||
new SkipSlashCmd(bot),
|
||||
|
||||
// DJ commands
|
||||
new ForceremoveSlashCmd(bot),
|
||||
new ForceskipSlashCmd(bot),
|
||||
new MovetrackSlashCmd(bot),
|
||||
new PauseSlashCmd(bot),
|
||||
new PlaynextSlashCmd(bot),
|
||||
new RepeatSlashCmd(bot),
|
||||
new SkiptoSlashCmd(bot),
|
||||
new StopSlashCmd(bot),
|
||||
new VolumeSlashCmd(bot),
|
||||
|
||||
// Admin commands
|
||||
new PrefixSlashCmd(bot),
|
||||
new QueuetypeSlashCmd(bot),
|
||||
new NpLayoutSlashCmd(bot),
|
||||
new NpButtonsSlashCmd(bot),
|
||||
new SetdjSlashCmd(bot),
|
||||
new SettcSlashCmd(bot),
|
||||
new SetvcSlashCmd(bot),
|
||||
new SkipratioSlashCmd(bot),
|
||||
new SettingsSlashCmd(bot),
|
||||
// Owner commands
|
||||
new ClearchannelSlashCmd(bot)
|
||||
).setManualUpsert(true);
|
||||
|
||||
if (config.useEval())
|
||||
cb.addCommand(new EvalCmd(bot));
|
||||
|
||||
if (config.getStatus() != OnlineStatus.UNKNOWN)
|
||||
cb.setStatus(config.getStatus());
|
||||
|
||||
if (config.getGame() == null)
|
||||
cb.useDefaultGame();
|
||||
else if (config.isGameNone())
|
||||
cb.setActivity(null);
|
||||
else
|
||||
cb.setActivity(config.getGame());
|
||||
|
||||
return cb.build();
|
||||
}
|
||||
|
||||
private static @NotNull AboutCommand createAboutCommand() {
|
||||
AboutCommand aboutCommand = new AboutCommand(Color.BLUE.brighter(),
|
||||
"a music bot that is [easy to host yourself!](https://github.com/arif-banai/MusicBot) (v" + OtherUtil.getCurrentVersion() + ")",
|
||||
new String[]{"High-quality music playback", "FairQueue™ Technology", "Easy to host yourself"},
|
||||
JMusicBot.RECOMMENDED_PERMS);
|
||||
aboutCommand.setIsAuthor(false);
|
||||
aboutCommand.setReplacementCharacter("\uD83C\uDFB6");
|
||||
return aboutCommand;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public abstract class DJCommand extends MusicCommand
|
||||
{
|
||||
public DJCommand(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.category = new Category("DJ", event -> checkDJPermission(event));
|
||||
}
|
||||
|
||||
public static boolean checkDJPermission(CommandEvent event)
|
||||
{
|
||||
if(event.getAuthor().getId().equals(event.getClient().getOwnerId()))
|
||||
return true;
|
||||
if(event.getGuild()==null)
|
||||
return true;
|
||||
if(event.getMember().hasPermission(Permission.MANAGE_SERVER))
|
||||
return true;
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
Role dj = settings.getRole(event.getGuild());
|
||||
return dj!=null && (event.getMember().getRoles().contains(dj) || dj.getIdLong()==event.getGuild().getIdLong());
|
||||
}
|
||||
|
||||
public static boolean checkDJPermission(Bot bot, Guild guild, Member member)
|
||||
{
|
||||
if(String.valueOf(bot.getConfig().getOwnerId()).equals(member.getUser().getId()))
|
||||
return true;
|
||||
if(guild==null)
|
||||
return true;
|
||||
if(member.hasPermission(Permission.MANAGE_SERVER))
|
||||
return true;
|
||||
Settings settings = bot.getSettingsManager().getSettings(guild);
|
||||
Role dj = settings.getRole(guild);
|
||||
return dj!=null && (member.getRoles().contains(dj) || dj.getIdLong()==guild.getIdLong());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1;
|
||||
|
||||
import com.jagrosh.jdautilities.command.Command;
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.MusicCommandValidator;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||
import net.dv8tion.jda.api.exceptions.PermissionException;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public abstract class MusicCommand extends Command
|
||||
{
|
||||
protected final Bot bot;
|
||||
protected boolean bePlaying;
|
||||
protected boolean beListening;
|
||||
|
||||
public MusicCommand(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.guildOnly = true;
|
||||
this.category = new Category("Music");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
String errorEmoji = event.getClient().getError();
|
||||
|
||||
boolean valid = MusicCommandValidator.validate(
|
||||
event.getGuild(),
|
||||
event.getMember(),
|
||||
event.getTextChannel(),
|
||||
settings,
|
||||
bot,
|
||||
event.getJDA(),
|
||||
bePlaying,
|
||||
beListening,
|
||||
new MusicCommandValidator.ErrorHandler()
|
||||
{
|
||||
@Override
|
||||
public void onTextChannelError(TextChannel requiredChannel)
|
||||
{
|
||||
// Text commands: delete message and reply in DM
|
||||
try
|
||||
{
|
||||
event.getMessage().delete().queue();
|
||||
}
|
||||
catch (PermissionException ignore) {}
|
||||
event.replyInDm(errorEmoji + " You can only use that command in " + requiredChannel.getAsMention() + "!");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotPlayingError()
|
||||
{
|
||||
event.reply(errorEmoji + " There must be music playing to use that!");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotListeningError(AudioChannel requiredChannel)
|
||||
{
|
||||
String channelName = requiredChannel == null ? "a voice channel" : requiredChannel.getAsMention();
|
||||
event.replyError("You must be listening in " + channelName + " to use that!");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAfkChannelError()
|
||||
{
|
||||
event.replyError("You cannot use that command in an AFK channel!");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceConnectError(AudioChannel channel)
|
||||
{
|
||||
event.reply(errorEmoji + " I am unable to connect to " + channel.getAsMention() + "!");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (valid)
|
||||
{
|
||||
doCommand(event);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract void doCommand(CommandEvent event);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1;
|
||||
|
||||
import com.jagrosh.jdautilities.command.Command;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public abstract class OwnerCommand extends Command
|
||||
{
|
||||
public OwnerCommand()
|
||||
{
|
||||
this.category = new Category("Owner");
|
||||
this.ownerCommand = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1;
|
||||
|
||||
import com.jagrosh.jdautilities.command.Command;
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.commands.BaseOutputAdapter;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Reusable OutputAdapter implementations for text commands.
|
||||
*/
|
||||
public final class TextOutputAdapters
|
||||
{
|
||||
private TextOutputAdapters() {} // Utility class
|
||||
|
||||
/**
|
||||
* Simple OutputAdapter for direct CommandEvent replies.
|
||||
* Used for commands that need basic reply functionality.
|
||||
*/
|
||||
public static class SimpleOutputAdapter extends BaseOutputAdapter
|
||||
{
|
||||
private final CommandEvent event;
|
||||
|
||||
public SimpleOutputAdapter(CommandEvent event)
|
||||
{
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replySuccess(String content)
|
||||
{
|
||||
event.replySuccess(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyError(String content)
|
||||
{
|
||||
event.replyError(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyWarning(String content)
|
||||
{
|
||||
event.replyWarning(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OutputAdapter for direct CommandEvent replies (before any loading message is sent).
|
||||
* Used when no args are provided and we need to show help or handle empty input.
|
||||
*/
|
||||
public static class CommandEventOutputAdapter extends BaseOutputAdapter
|
||||
{
|
||||
private final CommandEvent event;
|
||||
private final String commandName;
|
||||
private final Command[] children;
|
||||
|
||||
public CommandEventOutputAdapter(CommandEvent event, String commandName, Command[] children)
|
||||
{
|
||||
this.event = event;
|
||||
this.commandName = commandName;
|
||||
this.children = children;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replySuccess(String content)
|
||||
{
|
||||
event.replySuccess(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyError(String content)
|
||||
{
|
||||
event.replyError(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyWarning(String content)
|
||||
{
|
||||
event.replyWarning(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowHelp()
|
||||
{
|
||||
StringBuilder builder = new StringBuilder(event.getClient().getWarning() + " Play Commands:\n");
|
||||
builder.append("\n`").append(event.getClient().getPrefix()).append(commandName).append(" <song title>` - plays the first result from Youtube");
|
||||
builder.append("\n`").append(event.getClient().getPrefix()).append(commandName).append(" <URL>` - plays the provided song, playlist, or stream");
|
||||
for (Command cmd : children)
|
||||
{
|
||||
builder.append("\n`").append(event.getClient().getPrefix()).append(commandName).append(" ")
|
||||
.append(cmd.getName()).append(" ").append(cmd.getArguments()).append("` - ").append(cmd.getHelp());
|
||||
}
|
||||
event.reply(builder.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OutputAdapter for editing an existing message after a loading message was sent.
|
||||
* Used when args are provided and we show a loading message first.
|
||||
*/
|
||||
public static class MessageEditOutputAdapter extends BaseOutputAdapter
|
||||
{
|
||||
private final Message message;
|
||||
|
||||
public MessageEditOutputAdapter(Message message)
|
||||
{
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content)
|
||||
{
|
||||
message.editMessage(content).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content, Consumer<Message> onSuccess)
|
||||
{
|
||||
message.editMessage(content).queue(onSuccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.NowPlayingButtonsMode;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
|
||||
public class NpbuttonsCmd extends AdminCommand
|
||||
{
|
||||
private final Bot bot;
|
||||
|
||||
public NpbuttonsCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.name = "npbuttons";
|
||||
this.help = "sets whether now-playing messages include interactive buttons for this server";
|
||||
this.arguments = "<on|off|inherit>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
if (event.getArgs().isEmpty())
|
||||
{
|
||||
NowPlayingButtonsMode current = settings.getNowPlayingButtonsMode();
|
||||
boolean effectiveButtons = settings.showNowPlayingButtons(bot.getConfig());
|
||||
event.replySuccess("Now-playing buttons mode is `" + current.getUserInputValue()
|
||||
+ "` (effective: `" + (effectiveButtons ? "on" : "off") + "`).");
|
||||
return;
|
||||
}
|
||||
|
||||
NowPlayingButtonsMode mode = NowPlayingButtonsMode.fromInput(event.getArgs());
|
||||
if (mode == null)
|
||||
{
|
||||
event.replyError("Please provide one of: `on`, `off`, or `inherit`.");
|
||||
return;
|
||||
}
|
||||
|
||||
settings.setNowPlayingButtonsMode(mode);
|
||||
boolean effectiveButtons = settings.showNowPlayingButtons(bot.getConfig());
|
||||
event.replySuccess("Now-playing buttons mode set to `" + mode.getUserInputValue()
|
||||
+ "` (effective: `" + (effectiveButtons ? "on" : "off") + "`).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.NowPlayingLayoutMode;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
|
||||
public class NplayoutCmd extends AdminCommand
|
||||
{
|
||||
private final Bot bot;
|
||||
|
||||
public NplayoutCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.name = "nplayout";
|
||||
this.help = "sets the now-playing layout mode for this server";
|
||||
this.arguments = "<full|minimal|inherit>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
if (event.getArgs().isEmpty())
|
||||
{
|
||||
NowPlayingLayoutMode current = settings.getNowPlayingLayoutMode();
|
||||
boolean effectiveMinimal = settings.useMinimalNowPlayingMessage(bot.getConfig());
|
||||
event.replySuccess("Now-playing layout mode is `" + current.getUserInputValue()
|
||||
+ "` (effective: `" + (effectiveMinimal ? "minimal" : "full") + "`).");
|
||||
return;
|
||||
}
|
||||
|
||||
NowPlayingLayoutMode mode = NowPlayingLayoutMode.fromInput(event.getArgs());
|
||||
if (mode == null)
|
||||
{
|
||||
event.replyError("Please provide one of: `full`, `minimal`, or `inherit`.");
|
||||
return;
|
||||
}
|
||||
|
||||
settings.setNowPlayingLayoutMode(mode);
|
||||
boolean effectiveMinimal = settings.useMinimalNowPlayingMessage(bot.getConfig());
|
||||
event.replySuccess("Now-playing layout mode set to `" + mode.getUserInputValue()
|
||||
+ "` (effective: `" + (effectiveMinimal ? "minimal" : "full") + "`).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class PrefixCmd extends AdminCommand
|
||||
{
|
||||
public PrefixCmd(Bot bot)
|
||||
{
|
||||
this.name = "prefix";
|
||||
this.help = "sets a server-specific prefix";
|
||||
this.arguments = "<prefix|NONE>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.replyError("Please include a prefix or NONE");
|
||||
return;
|
||||
}
|
||||
|
||||
Settings s = event.getClient().getSettingsFor(event.getGuild());
|
||||
if(event.getArgs().equalsIgnoreCase("none"))
|
||||
{
|
||||
s.setPrefix(null);
|
||||
event.replySuccess("Prefix cleared.");
|
||||
}
|
||||
else
|
||||
{
|
||||
s.setPrefix(event.getArgs());
|
||||
event.replySuccess("Custom prefix set to `" + event.getArgs() + "` on *" + event.getGuild().getName() + "*");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2022 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.QueueType;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Wolfgang Schwendtbauer
|
||||
*/
|
||||
public class QueueTypeCmd extends AdminCommand
|
||||
{
|
||||
public QueueTypeCmd(Bot bot)
|
||||
{
|
||||
super();
|
||||
this.name = "queuetype";
|
||||
this.help = "changes the queue type";
|
||||
this.arguments = "[" + String.join("|", QueueType.getNames()) + "]";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String args = event.getArgs();
|
||||
QueueType value;
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
|
||||
if (args.isEmpty())
|
||||
{
|
||||
QueueType currentType = settings.getQueueType();
|
||||
event.reply(currentType.getEmoji() + " Current queue type is: `" + currentType.getUserFriendlyName() + "`.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
value = QueueType.valueOf(args.toUpperCase());
|
||||
}
|
||||
catch (IllegalArgumentException e)
|
||||
{
|
||||
event.replyError("Invalid queue type. Valid types are: [" + String.join("|", QueueType.getNames()) + "]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.getQueueType() != value)
|
||||
{
|
||||
settings.setQueueType(value);
|
||||
|
||||
AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler();
|
||||
if (handler != null)
|
||||
handler.setQueueType(value);
|
||||
}
|
||||
|
||||
event.reply(value.getEmoji() + " Queue type was set to `" + value.getUserFriendlyName() + "`.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SetdjCmd extends AdminCommand
|
||||
{
|
||||
public SetdjCmd(Bot bot)
|
||||
{
|
||||
this.name = "setdj";
|
||||
this.help = "sets the DJ role for certain music commands";
|
||||
this.arguments = "<rolename|NONE>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Please include a role name or NONE");
|
||||
return;
|
||||
}
|
||||
Settings s = event.getClient().getSettingsFor(event.getGuild());
|
||||
if(event.getArgs().equalsIgnoreCase("none"))
|
||||
{
|
||||
s.setDJRole(null);
|
||||
event.reply(event.getClient().getSuccess()+" DJ role cleared; Only Admins can use the DJ commands.");
|
||||
}
|
||||
else
|
||||
{
|
||||
List<Role> list = FinderUtil.findRoles(event.getArgs(), event.getGuild());
|
||||
if(list.isEmpty())
|
||||
event.reply(event.getClient().getWarning()+" No Roles found matching \""+event.getArgs()+"\"");
|
||||
else if (list.size()>1)
|
||||
event.reply(event.getClient().getWarning()+FormatUtil.listOfRoles(list, event.getArgs()));
|
||||
else
|
||||
{
|
||||
s.setDJRole(list.get(0));
|
||||
event.reply(event.getClient().getSuccess()+" DJ commands can now be used by users with the **"+list.get(0).getName()+"** role.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SettcCmd extends AdminCommand
|
||||
{
|
||||
public SettcCmd(Bot bot)
|
||||
{
|
||||
this.name = "settc";
|
||||
this.help = "sets the text channel for music commands";
|
||||
this.arguments = "<channel|NONE>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Please include a text channel or NONE");
|
||||
return;
|
||||
}
|
||||
Settings s = event.getClient().getSettingsFor(event.getGuild());
|
||||
if(event.getArgs().equalsIgnoreCase("none"))
|
||||
{
|
||||
s.setTextChannel(null);
|
||||
event.reply(event.getClient().getSuccess()+" Music commands can now be used in any channel");
|
||||
}
|
||||
else
|
||||
{
|
||||
List<TextChannel> list = FinderUtil.findTextChannels(event.getArgs(), event.getGuild());
|
||||
if(list.isEmpty())
|
||||
event.reply(event.getClient().getWarning()+" No Text Channels found matching \""+event.getArgs()+"\"");
|
||||
else if (list.size()>1)
|
||||
event.reply(event.getClient().getWarning()+FormatUtil.listOfTChannels(list, event.getArgs()));
|
||||
else
|
||||
{
|
||||
s.setTextChannel(list.get(0));
|
||||
event.reply(event.getClient().getSuccess()+" Music commands can now only be used in <#"+list.get(0).getId()+">");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SetvcCmd extends AdminCommand
|
||||
{
|
||||
public SetvcCmd(Bot bot)
|
||||
{
|
||||
this.name = "setvc";
|
||||
this.help = "sets the voice channel for playing music";
|
||||
this.arguments = "<channel|NONE>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Please include a voice channel or NONE");
|
||||
return;
|
||||
}
|
||||
Settings s = event.getClient().getSettingsFor(event.getGuild());
|
||||
if(event.getArgs().equalsIgnoreCase("none"))
|
||||
{
|
||||
s.setVoiceChannel(null);
|
||||
event.reply(event.getClient().getSuccess()+" Music can now be played in any channel");
|
||||
}
|
||||
else
|
||||
{
|
||||
List<VoiceChannel> list = FinderUtil.findVoiceChannels(event.getArgs(), event.getGuild());
|
||||
if(list.isEmpty())
|
||||
event.reply(event.getClient().getWarning()+" No Voice Channels found matching \""+event.getArgs()+"\"");
|
||||
else if (list.size()>1)
|
||||
event.reply(event.getClient().getWarning()+FormatUtil.listOfVChannels(list, event.getArgs()));
|
||||
else
|
||||
{
|
||||
s.setVoiceChannel(list.get(0));
|
||||
event.reply(event.getClient().getSuccess()+" Music can now only be played in "+list.get(0).getAsMention());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.admin;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.AdminCommand;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class SkipratioCmd extends AdminCommand
|
||||
{
|
||||
public SkipratioCmd(Bot bot)
|
||||
{
|
||||
this.name = "setskip";
|
||||
this.help = "sets a server-specific skip percentage";
|
||||
this.arguments = "<0 - 100>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
try
|
||||
{
|
||||
int val = Integer.parseInt(event.getArgs().endsWith("%") ? event.getArgs().substring(0,event.getArgs().length()-1) : event.getArgs());
|
||||
if( val < 0 || val > 100)
|
||||
{
|
||||
event.replyError("The provided value must be between 0 and 100!");
|
||||
return;
|
||||
}
|
||||
Settings s = event.getClient().getSettingsFor(event.getGuild());
|
||||
s.setSkipRatio(val / 100.0);
|
||||
event.replySuccess("Skip percentage has been set to `" + val + "%` of listeners on *" + event.getGuild().getName() + "*");
|
||||
}
|
||||
catch(NumberFormatException ex)
|
||||
{
|
||||
event.replyError("Please include an integer between 0 and 100 (default is 55). This number is the percentage of listening users that must vote to skip a song.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2019 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
|
||||
import com.jagrosh.jdautilities.menu.OrderedMenu;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.User;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Michaili K.
|
||||
*/
|
||||
public class ForceRemoveCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public ForceRemoveCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "forceremove";
|
||||
this.help = "removes all entries by a user from the queue";
|
||||
this.arguments = "<user>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.beListening = false;
|
||||
this.bePlaying = true;
|
||||
this.botPermissions = new Permission[]{Permission.MESSAGE_EMBED_LINKS};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
if (event.getArgs().isEmpty())
|
||||
{
|
||||
event.replyError("You need to mention a user!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (musicService.isQueueEmpty(event.getGuild()))
|
||||
{
|
||||
event.replyError("There is nothing in the queue!");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Member> found = FinderUtil.findMembers(event.getArgs(), event.getGuild());
|
||||
|
||||
if (found.isEmpty())
|
||||
{
|
||||
event.replyError("Unable to find the user!");
|
||||
return;
|
||||
}
|
||||
else if (found.size() > 1)
|
||||
{
|
||||
OrderedMenu.Builder builder = new OrderedMenu.Builder();
|
||||
for (int i = 0; i < found.size() && i < 4; i++)
|
||||
{
|
||||
Member member = found.get(i);
|
||||
builder.addChoice("**" + member.getUser().getName() + "**#" + member.getUser().getDiscriminator());
|
||||
}
|
||||
|
||||
builder
|
||||
.setSelection((msg, i) -> removeAllEntries(found.get(i - 1).getUser(), event))
|
||||
.setText("Found multiple users:")
|
||||
.setColor(event.getSelfMember().getColor())
|
||||
.useNumbers()
|
||||
.setUsers(event.getAuthor())
|
||||
.useCancelButton(true)
|
||||
.setCancel((msg) -> {})
|
||||
.setEventWaiter(bot.getWaiter())
|
||||
.setTimeout(1, TimeUnit.MINUTES)
|
||||
.build().display(event.getChannel());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
removeAllEntries(found.get(0).getUser(), event);
|
||||
}
|
||||
|
||||
private void removeAllEntries(User target, CommandEvent event)
|
||||
{
|
||||
int count = musicService.removeAllTracksByUser(event.getGuild(), target.getIdLong());
|
||||
if (count == 0)
|
||||
{
|
||||
event.replyWarning("**" + target.getName() + "** doesn't have any songs in the queue!");
|
||||
}
|
||||
else
|
||||
{
|
||||
event.replySuccess("Successfully removed `" + count + "` entries from " + FormatUtil.formatUsername(target) + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class ForceskipCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public ForceskipCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "forceskip";
|
||||
this.help = "skips the current song";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
MusicService.ForceSkipResult result = musicService.forceSkip(event.getGuild());
|
||||
if (result != null)
|
||||
{
|
||||
event.replySuccess("Skipped **" + result.trackTitle + "** " + result.requesterInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
* Command that provides users the ability to move a track in the playlist.
|
||||
*/
|
||||
public class MoveTrackCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public MoveTrackCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "movetrack";
|
||||
this.help = "move a track in the current queue to a different position";
|
||||
this.arguments = "<from> <to>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
int from;
|
||||
int to;
|
||||
|
||||
String[] parts = event.getArgs().split("\\s+", 2);
|
||||
if (parts.length < 2)
|
||||
{
|
||||
event.replyError("Please include two valid indexes.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
from = Integer.parseInt(parts[0]);
|
||||
to = Integer.parseInt(parts[1]);
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
event.replyError("Please provide two valid indexes.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (from == to)
|
||||
{
|
||||
event.replyError("Can't move a track to the same position.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!musicService.isValidQueuePosition(event.getGuild(), from))
|
||||
{
|
||||
event.replyError("`" + from + "` is not a valid position in the queue!");
|
||||
return;
|
||||
}
|
||||
if (!musicService.isValidQueuePosition(event.getGuild(), to))
|
||||
{
|
||||
event.replyError("`" + to + "` is not a valid position in the queue!");
|
||||
return;
|
||||
}
|
||||
|
||||
String trackTitle = musicService.moveTrackPosition(event.getGuild(), from, to);
|
||||
if (trackTitle != null)
|
||||
{
|
||||
event.replySuccess("Moved **" + trackTitle + "** from position `" + from + "` to `" + to + "`.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class PauseCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public PauseCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "pause";
|
||||
this.help = "pauses the current song";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
if (musicService.isPaused(event.getGuild()))
|
||||
{
|
||||
event.replyWarning("The player is already paused! Use `" + event.getClient().getPrefix() + "play` to unpause!");
|
||||
return;
|
||||
}
|
||||
|
||||
String trackTitle = musicService.setPaused(event.getGuild(), true);
|
||||
event.replySuccess("Paused **" + trackTitle + "**. Type `" + event.getClient().getPrefix() + "play` to unpause!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters.MessageEditOutputAdapter;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class PlaynextCmd extends DJCommand
|
||||
{
|
||||
private final String loadingEmoji;
|
||||
private final MusicService musicService;
|
||||
|
||||
public PlaynextCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.loadingEmoji = bot.getConfig().getLoading();
|
||||
this.name = "playnext";
|
||||
this.arguments = "<title|URL>";
|
||||
this.help = "plays a single song next";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.beListening = true;
|
||||
this.bePlaying = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
if (event.getArgs().isEmpty() && event.getMessage().getAttachments().isEmpty())
|
||||
{
|
||||
event.replyWarning("Please include a song title or URL!");
|
||||
return;
|
||||
}
|
||||
|
||||
String args = event.getArgs().startsWith("<") && event.getArgs().endsWith(">")
|
||||
? event.getArgs().substring(1, event.getArgs().length() - 1)
|
||||
: event.getArgs().isEmpty() ? event.getMessage().getAttachments().get(0).getUrl() : event.getArgs();
|
||||
|
||||
event.reply(loadingEmoji + " Loading... `[" + args + "]`", m ->
|
||||
musicService.playNext(event.getGuild(), event.getMember(), args, event.getTextChannel(),
|
||||
new MessageEditOutputAdapter(m)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import com.jagrosh.jmusicbot.settings.RepeatMode;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class RepeatCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public RepeatCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "repeat";
|
||||
this.help = "re-adds music to the queue when finished";
|
||||
this.arguments = "[off|all|single]";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = true;
|
||||
}
|
||||
|
||||
// override musiccommand's execute because we don't actually care where this is used
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String args = event.getArgs();
|
||||
RepeatMode value;
|
||||
RepeatMode currentMode = musicService.getRepeatMode(event.getGuild());
|
||||
|
||||
if (args.isEmpty())
|
||||
{
|
||||
value = (currentMode == RepeatMode.OFF) ? RepeatMode.ALL : RepeatMode.OFF;
|
||||
}
|
||||
else if (args.equalsIgnoreCase("false") || args.equalsIgnoreCase("off"))
|
||||
{
|
||||
value = RepeatMode.OFF;
|
||||
}
|
||||
else if (args.equalsIgnoreCase("true") || args.equalsIgnoreCase("on") || args.equalsIgnoreCase("all"))
|
||||
{
|
||||
value = RepeatMode.ALL;
|
||||
}
|
||||
else if (args.equalsIgnoreCase("one") || args.equalsIgnoreCase("single"))
|
||||
{
|
||||
value = RepeatMode.SINGLE;
|
||||
}
|
||||
else
|
||||
{
|
||||
event.replyError("Valid options are `off`, `all` or `single` (or leave empty to toggle between `off` and `all`)");
|
||||
return;
|
||||
}
|
||||
|
||||
musicService.setRepeatMode(event.getGuild(), value);
|
||||
event.replySuccess("Repeat mode is now `" + value.getUserFriendlyName() + "`");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event) { /* Intentionally Empty */ }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SkiptoCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public SkiptoCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "skipto";
|
||||
this.help = "skips to the specified song";
|
||||
this.arguments = "<position>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
int index;
|
||||
try
|
||||
{
|
||||
index = Integer.parseInt(event.getArgs());
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
event.replyError("`" + event.getArgs() + "` is not a valid integer!");
|
||||
return;
|
||||
}
|
||||
|
||||
int queueSize = musicService.getQueueSize(event.getGuild());
|
||||
if (index < 1 || index > queueSize)
|
||||
{
|
||||
event.replyError("Position must be a valid integer between 1 and " + queueSize + "!");
|
||||
return;
|
||||
}
|
||||
|
||||
String trackTitle = musicService.skipToPosition(event.getGuild(), index);
|
||||
if (trackTitle != null)
|
||||
{
|
||||
event.replySuccess("Skipped to **" + trackTitle + "**");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class StopCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public StopCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "stop";
|
||||
this.help = "stops the current song and clears the queue";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.bePlaying = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
musicService.stopAndClear(event.getGuild());
|
||||
event.replySuccess("The player has stopped and the queue has been cleared.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.dj;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class VolumeCmd extends DJCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public VolumeCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "volume";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.help = "sets or shows volume";
|
||||
this.arguments = "[0-150]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
int currentVolume = musicService.getVolume(event.getGuild());
|
||||
|
||||
if (event.getArgs().isEmpty())
|
||||
{
|
||||
event.reply(FormatUtil.volumeIcon(currentVolume) + " Current volume is `" + currentVolume + "`");
|
||||
}
|
||||
else
|
||||
{
|
||||
int newVolume;
|
||||
try
|
||||
{
|
||||
newVolume = Integer.parseInt(event.getArgs());
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
newVolume = -1;
|
||||
}
|
||||
|
||||
MusicService.VolumeResult result = musicService.setVolume(event.getGuild(), newVolume);
|
||||
if (result == null)
|
||||
{
|
||||
event.replyError("Volume must be a valid integer between 0 and 150!");
|
||||
}
|
||||
else
|
||||
{
|
||||
event.reply(FormatUtil.volumeIcon(result.newVolume) + " Volume changed from `" + result.oldVolume + "` to `" + result.newVolume + "`");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2017 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.general;
|
||||
|
||||
import com.jagrosh.jdautilities.command.Command;
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v2.admin.SettingsPanelRenderer;
|
||||
import com.jagrosh.jmusicbot.settings.QueueType;
|
||||
import com.jagrosh.jmusicbot.settings.RepeatMode;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import net.dv8tion.jda.api.EmbedBuilder;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
|
||||
|
||||
/**
|
||||
* Shows server settings. Users with Manage Server (or the bot owner) receive
|
||||
* the interactive settings panel; others see a read-only summary.
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SettingsCmd extends Command
|
||||
{
|
||||
private final static String EMOJI = "\uD83C\uDFA7"; // 🎧
|
||||
|
||||
private final Bot bot;
|
||||
|
||||
public SettingsCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.name = "settings";
|
||||
this.help = "shows the bots settings (interactive panel for admins)";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
Settings s = event.getClient().getSettingsFor(event.getGuild());
|
||||
boolean canManage = event.getMember() != null && event.getMember().hasPermission(Permission.MANAGE_SERVER)
|
||||
|| event.getAuthor().getId().equals(event.getClient().getOwnerId());
|
||||
|
||||
if (canManage)
|
||||
{
|
||||
long userId = event.getAuthor().getIdLong();
|
||||
String invokerName = event.getMember() != null ? event.getMember().getEffectiveName() : event.getAuthor().getName();
|
||||
event.getChannel()
|
||||
.sendMessage(new MessageCreateBuilder()
|
||||
.setComponents(SettingsPanelRenderer.buildSettingsMessageComponents(
|
||||
event.getGuild(), s, bot.getConfig(), userId, invokerName))
|
||||
.useComponentsV2()
|
||||
.build())
|
||||
.queue();
|
||||
return;
|
||||
}
|
||||
|
||||
MessageCreateBuilder builder = new MessageCreateBuilder()
|
||||
.addContent(EMOJI + " **")
|
||||
.addContent(FormatUtil.filter(event.getSelfUser().getName()))
|
||||
.addContent("** settings:");
|
||||
TextChannel tchan = s.getTextChannel(event.getGuild());
|
||||
VoiceChannel vchan = s.getVoiceChannel(event.getGuild());
|
||||
Role role = s.getRole(event.getGuild());
|
||||
EmbedBuilder ebuilder = new EmbedBuilder()
|
||||
.setColor(event.getSelfMember().getColors().getPrimary())
|
||||
.setDescription("Text Channel: " + (tchan == null ? "Any" : "**#" + tchan.getName() + "**")
|
||||
+ "\nVoice Channel: " + (vchan == null ? "Any" : vchan.getAsMention())
|
||||
+ "\nDJ Role: " + (role == null ? "None" : "**" + role.getName() + "**")
|
||||
+ "\nCustom Prefix: " + (s.getPrefix() == null ? "None" : "`" + s.getPrefix() + "`")
|
||||
+ "\nRepeat Mode: " + (s.getRepeatMode() == RepeatMode.OFF
|
||||
? s.getRepeatMode().getUserFriendlyName()
|
||||
: "**" + s.getRepeatMode().getUserFriendlyName() + "**")
|
||||
+ "\nQueue Type: " + (s.getQueueType() == QueueType.FAIR
|
||||
? s.getQueueType().getUserFriendlyName()
|
||||
: "**" + s.getQueueType().getUserFriendlyName() + "**")
|
||||
+ "\nDefault Playlist: " + (s.getDefaultPlaylist() == null ? "None" : "**" + s.getDefaultPlaylist() + "**")
|
||||
)
|
||||
.setFooter(event.getJDA().getGuilds().size() + " servers | "
|
||||
+ event.getJDA().getGuilds().stream().filter(g -> g.getSelfMember().getVoiceState().getChannel() != null).count()
|
||||
+ " audio connections", null);
|
||||
event.getChannel().sendMessage(builder.setEmbeds(ebuilder.build()).build()).queue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.HistorySlashCmd;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Text command to show playback history. Sends the same interactive embed and buttons as /history
|
||||
* so users can peek, queue, play, or save history as a playlist.
|
||||
*/
|
||||
public class HistoryCmd extends MusicCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public HistoryCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "history";
|
||||
this.help = "shows recently played tracks (history queue)";
|
||||
this.arguments = "[pagenum]";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.bePlaying = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
int page = 1;
|
||||
String args = event.getArgs().trim();
|
||||
if (!args.isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
page = Integer.parseInt(args.split("\\s+")[0]);
|
||||
}
|
||||
catch (NumberFormatException ignored)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
MusicService.HistoryInfo historyInfo = musicService.getHistoryInfo(event.getGuild(), event.getJDA());
|
||||
if (historyInfo == null)
|
||||
{
|
||||
event.reply(event.getClient().getWarning() + " Playback history is empty!");
|
||||
return;
|
||||
}
|
||||
if (historyInfo.isDisabled())
|
||||
{
|
||||
event.reply(event.getClient().getWarning() + " " + MusicService.HISTORY_DISABLED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (historyInfo.isEmpty())
|
||||
{
|
||||
event.reply(event.getClient().getWarning() + " Playback history is empty!");
|
||||
return;
|
||||
}
|
||||
|
||||
int totalPages = HistorySlashCmd.getTotalPages(historyInfo.tracks.length);
|
||||
if (page > totalPages)
|
||||
{
|
||||
page = totalPages;
|
||||
}
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
|
||||
long userId = event.getAuthor().getIdLong();
|
||||
int tracksOnPage = HistorySlashCmd.getTracksOnPage(page, historyInfo.tracks.length);
|
||||
|
||||
var member = event.getMember();
|
||||
var color = member != null ? member.getColor() : event.getSelfMember().getColor();
|
||||
var embed = HistorySlashCmd.buildHistoryEmbed(historyInfo, page, totalPages, 0, color);
|
||||
List<net.dv8tion.jda.api.components.actionrow.ActionRow> components =
|
||||
HistorySlashCmd.buildHistoryComponents(page, totalPages, tracksOnPage, 0, userId);
|
||||
|
||||
MessageCreateData message = new MessageCreateBuilder()
|
||||
.addEmbeds(embed)
|
||||
.setComponents(components)
|
||||
.build();
|
||||
event.reply(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
|
||||
// Lyrics functionality removed - JLyrics dependency removed
|
||||
// import net.dv8tion.jda.api.EmbedBuilder;
|
||||
// import net.dv8tion.jda.api.Permission;
|
||||
// import com.jagrosh.jlyrics.LyricsClient;
|
||||
// import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
|
||||
/**
|
||||
* Lyrics command - functionality removed (JLyrics dependency removed)
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class LyricsCmd extends MusicCommand
|
||||
{
|
||||
// Lyrics functionality removed - JLyrics dependency removed
|
||||
// private final LyricsClient client = new LyricsClient();
|
||||
|
||||
public LyricsCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.name = "lyrics";
|
||||
this.arguments = "[song name]";
|
||||
this.help = "shows the lyrics of a song (unimplemented - JLyrics dependency removed)";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
// this.botPermissions = new Permission[]{Permission.MESSAGE_EMBED_LINKS};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
// Lyrics functionality removed - JLyrics dependency removed
|
||||
event.replyError("Lyrics functionality has been removed.");
|
||||
|
||||
/* Original implementation commented out:
|
||||
String title;
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
AudioHandler sendingHandler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler();
|
||||
if (sendingHandler.isMusicPlaying(event.getJDA()))
|
||||
title = sendingHandler.getPlayer().getPlayingTrack().getInfo().title;
|
||||
else
|
||||
{
|
||||
event.replyError("There must be music playing to use that!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
title = event.getArgs();
|
||||
event.getChannel().sendTyping().queue();
|
||||
client.getLyrics(title).thenAccept(lyrics ->
|
||||
{
|
||||
if(lyrics == null)
|
||||
{
|
||||
event.replyError("Lyrics for `" + title + "` could not be found!" + (event.getArgs().isEmpty() ? " Try entering the song name manually (`lyrics [song name]`)" : ""));
|
||||
return;
|
||||
}
|
||||
|
||||
EmbedBuilder eb = new EmbedBuilder()
|
||||
.setAuthor(lyrics.getAuthor())
|
||||
.setColor(event.getSelfMember().getColor())
|
||||
.setTitle(lyrics.getTitle(), lyrics.getURL());
|
||||
if(lyrics.getContent().length()>15000)
|
||||
{
|
||||
event.replyWarning("Lyrics for `" + title + "` found but likely not correct: " + lyrics.getURL());
|
||||
}
|
||||
else if(lyrics.getContent().length()>2000)
|
||||
{
|
||||
String content = lyrics.getContent().trim();
|
||||
while(content.length() > 2000)
|
||||
{
|
||||
int index = content.lastIndexOf("\n\n", 2000);
|
||||
if(index == -1)
|
||||
index = content.lastIndexOf("\n", 2000);
|
||||
if(index == -1)
|
||||
index = content.lastIndexOf(" ", 2000);
|
||||
if(index == -1)
|
||||
index = 2000;
|
||||
event.reply(eb.setDescription(content.substring(0, index).trim()).build());
|
||||
content = content.substring(index).trim();
|
||||
eb.setAuthor(null).setTitle(null, null);
|
||||
}
|
||||
event.reply(eb.setDescription(content).build());
|
||||
}
|
||||
else
|
||||
event.reply(eb.setDescription(lyrics.getContent()).build());
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class NowPlayingCmd extends MusicCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public NowPlayingCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "nowplaying";
|
||||
this.help = "shows the song that is currently playing";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.botPermissions = new Permission[]{Permission.MESSAGE_EMBED_LINKS};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
MusicService.NowPlayingInfo info = musicService.getNowPlayingInfo(event.getGuild(), event.getJDA());
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
event.replyWarning("There is no music playing in this server.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.nowPlayingMessage == null || !info.isPlaying)
|
||||
{
|
||||
event.reply(info.noMusicMessage);
|
||||
bot.getNowplayingHandler().clearLastNPMessage(event.getGuild());
|
||||
}
|
||||
else
|
||||
{
|
||||
event.reply(info.nowPlayingMessage, msg -> bot.getNowplayingHandler().setLastNPMessage(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.Command;
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.audio.QueuedTrack;
|
||||
import com.jagrosh.jmusicbot.audio.RequestMetadata;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters.CommandEventOutputAdapter;
|
||||
import com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters.MessageEditOutputAdapter;
|
||||
import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class PlayCmd extends MusicCommand
|
||||
{
|
||||
private final String loadingEmoji;
|
||||
private final MusicService musicService;
|
||||
|
||||
public PlayCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.loadingEmoji = bot.getConfig().getLoading();
|
||||
this.name = "play";
|
||||
this.arguments = "<title|URL|subcommand>";
|
||||
this.help = "plays the provided song";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.beListening = true;
|
||||
this.bePlaying = false;
|
||||
this.children = new Command[]{new PlaylistCmd(bot)};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
String args = parseArgs(event);
|
||||
|
||||
if (args.isEmpty())
|
||||
{
|
||||
musicService.play(event.getGuild(), event.getMember(), args, event.getTextChannel(),
|
||||
new CommandEventOutputAdapter(event, name, children));
|
||||
return;
|
||||
}
|
||||
|
||||
event.reply(loadingEmoji + " Loading... `[" + args + "]`", m -> {
|
||||
musicService.play(event.getGuild(), event.getMember(), args, event.getTextChannel(),
|
||||
new MessageEditOutputAdapter(m));
|
||||
});
|
||||
}
|
||||
|
||||
private String parseArgs(CommandEvent event)
|
||||
{
|
||||
String args = event.getArgs();
|
||||
if (args.startsWith("<") && args.endsWith(">"))
|
||||
{
|
||||
return args.substring(1, args.length() - 1);
|
||||
}
|
||||
if (args.isEmpty() && !event.getMessage().getAttachments().isEmpty())
|
||||
{
|
||||
return event.getMessage().getAttachments().get(0).getUrl();
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
public class PlaylistCmd extends MusicCommand
|
||||
{
|
||||
public PlaylistCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.name = "playlist";
|
||||
this.aliases = new String[]{"pl"};
|
||||
this.arguments = "<name>";
|
||||
this.help = "plays the provided playlist";
|
||||
this.beListening = true;
|
||||
this.bePlaying = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Please include a playlist name.");
|
||||
return;
|
||||
}
|
||||
Playlist playlist = bot.getPlaylistLoader().getPlaylist(event.getArgs());
|
||||
if(playlist==null)
|
||||
{
|
||||
event.replyError("I could not find `"+event.getArgs()+".txt` in the Playlists folder.");
|
||||
return;
|
||||
}
|
||||
event.getChannel().sendMessage(loadingEmoji+" Loading playlist **"+event.getArgs()+"**... ("+playlist.getItems().size()+" items)").queue(m ->
|
||||
{
|
||||
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
|
||||
playlist.loadTracks(bot.getPlayerManager(), (at)->handler.addTrack(new QueuedTrack(at, RequestMetadata.fromResultHandler(at, event))), () -> {
|
||||
StringBuilder builder = new StringBuilder(playlist.getTracks().isEmpty()
|
||||
? event.getClient().getWarning()+" No tracks were loaded!"
|
||||
: event.getClient().getSuccess()+" Loaded **"+playlist.getTracks().size()+"** tracks!");
|
||||
if(!playlist.getErrors().isEmpty())
|
||||
builder.append("\nThe following tracks failed to load:");
|
||||
playlist.getErrors().forEach(err -> builder.append("\n`[").append(err.getIndex()+1).append("]` **").append(err.getItem()).append("**: ").append(err.getReason()));
|
||||
String str = builder.toString();
|
||||
if(str.length()>2000)
|
||||
str = str.substring(0,1994)+" (...)";
|
||||
m.editMessage(FormatUtil.filter(str)).queue();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.commands.v2.music.PlaylistsSlashCmd;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class PlaylistsCmd extends MusicCommand
|
||||
{
|
||||
public PlaylistsCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.name = "playlists";
|
||||
this.help = "shows the available playlists";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = true;
|
||||
this.beListening = false;
|
||||
this.beListening = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
var playlistNamesInfo = bot.getMusicService().getAvailablePlaylistNames();
|
||||
if(playlistNamesInfo.hasError())
|
||||
event.reply(event.getClient().getError()+" " + playlistNamesInfo.errorMessage);
|
||||
else if(playlistNamesInfo.names.isEmpty())
|
||||
event.reply(event.getClient().getWarning()+" There are no playlists in the Playlists folder!");
|
||||
else
|
||||
{
|
||||
List<String> list = playlistNamesInfo.names;
|
||||
int page = 1;
|
||||
int totalPages = PlaylistsSlashCmd.getTotalPages(list.size());
|
||||
int playlistsOnPage = PlaylistsSlashCmd.getPlaylistsOnPage(page, list.size());
|
||||
long userId = event.getAuthor().getIdLong();
|
||||
var member = event.getMember();
|
||||
var color = member != null ? member.getColor() : event.getSelfMember().getColor();
|
||||
|
||||
var embed = PlaylistsSlashCmd.buildPlaylistsEmbed(list, page, totalPages, 0, color);
|
||||
var components = PlaylistsSlashCmd.buildPlaylistsComponents(page, totalPages, playlistsOnPage, 0, userId);
|
||||
MessageCreateData message = new MessageCreateBuilder()
|
||||
.addEmbeds(embed)
|
||||
.setComponents(components)
|
||||
.build();
|
||||
event.reply(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jdautilities.menu.Paginator;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.exceptions.PermissionException;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class QueueCmd extends MusicCommand
|
||||
{
|
||||
private final Paginator.Builder builder;
|
||||
private final MusicService musicService;
|
||||
|
||||
public QueueCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "queue";
|
||||
this.help = "shows the current queue";
|
||||
this.arguments = "[pagenum]";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.bePlaying = true;
|
||||
this.botPermissions = new Permission[]{Permission.MESSAGE_ADD_REACTION, Permission.MESSAGE_EMBED_LINKS};
|
||||
builder = new Paginator.Builder()
|
||||
.setColumns(1)
|
||||
.setFinalAction(m -> {
|
||||
try
|
||||
{
|
||||
m.clearReactions().queue();
|
||||
}
|
||||
catch (PermissionException ignore)
|
||||
{
|
||||
}
|
||||
})
|
||||
.setItemsPerPage(10)
|
||||
.waitOnSinglePage(false)
|
||||
.useNumberedItems(true)
|
||||
.showPageNumbers(true)
|
||||
.wrapPageEnds(true)
|
||||
.setEventWaiter(bot.getWaiter())
|
||||
.setTimeout(1, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
int pagenum = 1;
|
||||
try
|
||||
{
|
||||
pagenum = Integer.parseInt(event.getArgs());
|
||||
}
|
||||
catch (NumberFormatException ignore)
|
||||
{
|
||||
}
|
||||
|
||||
MusicService.QueueInfo queueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA());
|
||||
if (queueInfo == null || queueInfo.isEmpty())
|
||||
{
|
||||
MusicService.NowPlayingInfo npInfo = musicService.getNowPlayingInfo(event.getGuild(), event.getJDA());
|
||||
MessageCreateData embed = npInfo != null && npInfo.isPlaying ? npInfo.nowPlayingMessage : (npInfo != null ? npInfo.noMusicMessage : null);
|
||||
|
||||
if (embed != null)
|
||||
{
|
||||
MessageCreateData built = new MessageCreateBuilder()
|
||||
.setContent(event.getClient().getWarning() + " There is no music in the queue!")
|
||||
.setEmbeds(embed.getEmbeds().get(0)).build();
|
||||
event.reply(built, m ->
|
||||
{
|
||||
if (npInfo != null && npInfo.isPlaying)
|
||||
bot.getNowplayingHandler().setLastNPMessage(m);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
event.replyWarning("There is no music in the queue!");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String successEmoji = event.getClient().getSuccess();
|
||||
builder.setText((i1, i2) -> musicService.formatQueueTitle(queueInfo, successEmoji))
|
||||
.setItems(queueInfo.tracks)
|
||||
.setUsers(event.getAuthor())
|
||||
.setColor(event.getSelfMember().getColors().getPrimary());
|
||||
builder.build().paginate(event.getChannel(), pagenum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters.SimpleOutputAdapter;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class RemoveCmd extends MusicCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public RemoveCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "remove";
|
||||
this.help = "removes a song from the queue";
|
||||
this.arguments = "<position|ALL>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.beListening = true;
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
SimpleOutputAdapter output = new SimpleOutputAdapter(event);
|
||||
|
||||
if (musicService.isQueueEmpty(event.getGuild()))
|
||||
{
|
||||
output.replyError("There is nothing in the queue!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getArgs().equalsIgnoreCase("all"))
|
||||
{
|
||||
musicService.removeAllTracks(event.getGuild(), event.getMember(), output);
|
||||
return;
|
||||
}
|
||||
|
||||
int pos;
|
||||
try
|
||||
{
|
||||
pos = Integer.parseInt(event.getArgs());
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
pos = 0;
|
||||
}
|
||||
|
||||
musicService.removeTrack(event.getGuild(), event.getMember(), pos, output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SCSearchCmd extends SearchCmd
|
||||
{
|
||||
public SCSearchCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.searchPrefix = "scsearch:";
|
||||
this.name = "scsearch";
|
||||
this.help = "searches Soundcloud for a provided query";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jdautilities.menu.OrderedMenu;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.audio.QueuedTrack;
|
||||
import com.jagrosh.jmusicbot.audio.RequestMetadata;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.utils.FormatUtil;
|
||||
import com.jagrosh.jmusicbot.utils.TimeUtil;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SearchCmd extends MusicCommand
|
||||
{
|
||||
protected String searchPrefix = "ytsearch:";
|
||||
private final OrderedMenu.Builder builder;
|
||||
private final String searchingEmoji;
|
||||
|
||||
public SearchCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.searchingEmoji = bot.getConfig().getSearching();
|
||||
this.name = "search";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.arguments = "<query>";
|
||||
this.help = "searches Youtube for a provided query";
|
||||
this.beListening = true;
|
||||
this.bePlaying = false;
|
||||
this.botPermissions = new Permission[]{Permission.MESSAGE_EMBED_LINKS};
|
||||
builder = new OrderedMenu.Builder()
|
||||
.allowTextInput(true)
|
||||
.useNumbers()
|
||||
.useCancelButton(true)
|
||||
.setEventWaiter(bot.getWaiter())
|
||||
.setTimeout(1, TimeUnit.MINUTES);
|
||||
}
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.replyError("Please include a query.");
|
||||
return;
|
||||
}
|
||||
String query = searchPrefix + event.getArgs();
|
||||
event.reply(searchingEmoji+" Searching... `["+event.getArgs()+"]`",
|
||||
m -> bot.getPlayerManager().loadItemOrdered(event.getGuild(), query,
|
||||
bot.getAudioLoadWrapper().wrap(query, new ResultHandler(m,event))));
|
||||
}
|
||||
|
||||
private class ResultHandler implements AudioLoadResultHandler
|
||||
{
|
||||
private final Message m;
|
||||
private final CommandEvent event;
|
||||
|
||||
private ResultHandler(Message m, CommandEvent event)
|
||||
{
|
||||
this.m = m;
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trackLoaded(AudioTrack track)
|
||||
{
|
||||
if(bot.getConfig().isTooLong(track))
|
||||
{
|
||||
String trackTitle = FormatUtil.getTrackTitle(track);
|
||||
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" This track (**"+trackTitle+"**) is longer than the allowed maximum: `"
|
||||
+ TimeUtil.formatTime(track.getDuration())+"` > `"+bot.getConfig().getMaxTime()+"`")).queue();
|
||||
return;
|
||||
}
|
||||
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
|
||||
int pos = handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1;
|
||||
String trackTitle = FormatUtil.getTrackTitle(track);
|
||||
m.editMessage(FormatUtil.filter(event.getClient().getSuccess()+" Added **"+trackTitle
|
||||
+"** (`"+ TimeUtil.formatTime(track.getDuration())+"`) "+(pos==0 ? "to begin playing"
|
||||
: " to the queue at position "+pos))).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playlistLoaded(AudioPlaylist playlist)
|
||||
{
|
||||
builder.setColor(event.getSelfMember().getColor())
|
||||
.setText(FormatUtil.filter(event.getClient().getSuccess()+" Search results for `"+event.getArgs()+"`:"))
|
||||
.setChoices(new String[0])
|
||||
.setSelection((msg,i) ->
|
||||
{
|
||||
AudioTrack track = playlist.getTracks().get(i-1);
|
||||
String trackTitle = FormatUtil.getTrackTitle(track);
|
||||
if(bot.getConfig().isTooLong(track))
|
||||
{
|
||||
event.replyWarning("This track (**"+trackTitle+"**) is longer than the allowed maximum: `"
|
||||
+ TimeUtil.formatTime(track.getDuration())+"` > `"+bot.getConfig().getMaxTime()+"`");
|
||||
return;
|
||||
}
|
||||
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
|
||||
int pos = handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1;
|
||||
event.replySuccess("Added **" + FormatUtil.filter(trackTitle)
|
||||
+ "** (`" + TimeUtil.formatTime(track.getDuration()) + "`) " + (pos==0 ? "to begin playing"
|
||||
: " to the queue at position "+pos));
|
||||
})
|
||||
.setCancel((msg) -> {})
|
||||
.setUsers(event.getAuthor())
|
||||
;
|
||||
for(int i=0; i<4 && i<playlist.getTracks().size(); i++)
|
||||
{
|
||||
AudioTrack track = playlist.getTracks().get(i);
|
||||
String trackTitle = FormatUtil.getTrackTitle(track);
|
||||
builder.addChoices("`["+ TimeUtil.formatTime(track.getDuration())+"]` [**"+FormatUtil.filter(trackTitle == null ? "" : trackTitle)+"**]("+track.getInfo().uri+")");
|
||||
}
|
||||
builder.build().display(m);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void noMatches()
|
||||
{
|
||||
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" No results found for `"+event.getArgs()+"`.")).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadFailed(FriendlyException throwable)
|
||||
{
|
||||
if(throwable.severity==Severity.COMMON)
|
||||
m.editMessage(event.getClient().getError()+" Error loading: "+throwable.getMessage()).queue();
|
||||
else
|
||||
m.editMessage(event.getClient().getError()+" Error loading track.").queue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2020 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters.SimpleOutputAdapter;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
* @author Whew., Inc.
|
||||
*/
|
||||
public class SeekCmd extends MusicCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public SeekCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "seek";
|
||||
this.help = "seeks the current song";
|
||||
this.arguments = "[+ | -] <HH:MM:SS | MM:SS | SS>|<0h0m0s | 0m0s | 0s>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.beListening = true;
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
musicService.seek(event.getGuild(), event.getMember(), event.getArgs(), new SimpleOutputAdapter(event));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class ShuffleCmd extends MusicCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public ShuffleCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "shuffle";
|
||||
this.help = "shuffles songs you have added";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.beListening = true;
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
int shuffled = musicService.shuffleUserTracks(event.getGuild(), event.getAuthor().getIdLong());
|
||||
switch (shuffled)
|
||||
{
|
||||
case 0:
|
||||
event.replyError("You don't have any music in the queue to shuffle!");
|
||||
break;
|
||||
case 1:
|
||||
event.replyWarning("You only have one song in the queue!");
|
||||
break;
|
||||
default:
|
||||
event.replySuccess("You successfully shuffled your " + shuffled + " entries.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.music;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.MusicCommand;
|
||||
import com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters.SimpleOutputAdapter;
|
||||
import com.jagrosh.jmusicbot.service.MusicService;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SkipCmd extends MusicCommand
|
||||
{
|
||||
private final MusicService musicService;
|
||||
|
||||
public SkipCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.musicService = bot.getMusicService();
|
||||
this.name = "skip";
|
||||
this.help = "votes to skip the current song";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.beListening = true;
|
||||
this.bePlaying = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(CommandEvent event)
|
||||
{
|
||||
int listeners = (int) event.getSelfMember().getVoiceState().getChannel().getMembers().stream()
|
||||
.filter(m -> !m.getUser().isBot() && !m.getVoiceState().isDeafened()).count();
|
||||
|
||||
musicService.skipWithVote(event.getGuild(), event.getMember(), listeners, new SimpleOutputAdapter(event));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class AutoplaylistCmd extends OwnerCommand
|
||||
{
|
||||
private final Bot bot;
|
||||
|
||||
public AutoplaylistCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.guildOnly = true;
|
||||
this.name = "autoplaylist";
|
||||
this.arguments = "<name|NONE>";
|
||||
this.help = "sets the default playlist for the server";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Please include a playlist name or NONE");
|
||||
return;
|
||||
}
|
||||
if(event.getArgs().equalsIgnoreCase("none"))
|
||||
{
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
settings.setDefaultPlaylist(null);
|
||||
event.reply(event.getClient().getSuccess()+" Cleared the default playlist for **"+event.getGuild().getName()+"**");
|
||||
return;
|
||||
}
|
||||
String pname = event.getArgs().replaceAll("\\s+", "_");
|
||||
if(bot.getPlaylistLoader().getPlaylist(pname)==null)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Could not find `"+pname+".txt`!");
|
||||
}
|
||||
else
|
||||
{
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
settings.setDefaultPlaylist(pname);
|
||||
event.reply(event.getClient().getSuccess()+" The default playlist for **"+event.getGuild().getName()+"** is now `"+pname+"`");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import com.jagrosh.jmusicbot.utils.ChannelClearHelper;
|
||||
|
||||
/**
|
||||
* Owner-only prefix command to clear all messages in the guild's configured text channel (set via settc).
|
||||
*/
|
||||
public class ClearchannelCmd extends OwnerCommand
|
||||
{
|
||||
private final Bot bot;
|
||||
|
||||
public ClearchannelCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.name = "clearchannel";
|
||||
this.help = "clears all messages in the command channel set with settc";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
ChannelClearHelper.ClearPolicy clearPolicy = ChannelClearHelper.ClearPolicy.of(
|
||||
bot.getConfig().getClearChannelDeleteLimit(),
|
||||
bot.getConfig().getClearChannelAgeDays()
|
||||
);
|
||||
ChannelClearHelper.clearConfiguredTextChannel(
|
||||
event.getGuild(),
|
||||
event.getClient().getSettingsFor(event.getGuild()),
|
||||
clearPolicy,
|
||||
new ChannelClearHelper.ClearChannelCallback()
|
||||
{
|
||||
@Override
|
||||
public void onNoChannelConfigured()
|
||||
{
|
||||
event.reply(event.getClient().getError() + " No text channel is configured for this server. An admin can set one with `settc`.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNoPermission()
|
||||
{
|
||||
event.reply(event.getClient().getError() + " I don't have permission to manage messages in that channel.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClearingStarted()
|
||||
{
|
||||
event.reply(event.getClient().getWarning() + " Clearing channel...");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCleared(int count)
|
||||
{
|
||||
event.getChannel().sendMessage(event.getClient().getSuccess() + " Cleared " + count + " messages.").queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t)
|
||||
{
|
||||
String msg = t.getMessage() != null ? t.getMessage() : t.toString();
|
||||
event.getChannel().sendMessage(event.getClient().getError() + " Error: " + msg).queue();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright 2017 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jdautilities.commons.JDAUtilitiesInfo;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.BotConfig;
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.audio.AudioSource;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import com.jagrosh.jmusicbot.playlist.PlaylistLoader;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.PlayerLibrary;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.JDAInfo;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.utils.FileUpload;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh ([email protected])
|
||||
*/
|
||||
public class DebugCmd extends OwnerCommand
|
||||
{
|
||||
private static final String[] PROPERTIES = {
|
||||
"java.version", "java.vm.name", "java.vm.specification.version",
|
||||
"java.runtime.name", "java.runtime.version", "java.specification.version",
|
||||
"os.arch", "os.name"
|
||||
};
|
||||
|
||||
private static final String SECTION_SEPARATOR = "=";
|
||||
private static final int SECTION_WIDTH = 40;
|
||||
|
||||
private final Bot bot;
|
||||
|
||||
public DebugCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.name = "debug";
|
||||
this.help = "shows debug info";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("```\n");
|
||||
|
||||
appendSystemSection(sb);
|
||||
appendJMusicBotSection(sb);
|
||||
appendConfigurationSection(sb);
|
||||
appendAudioSourcesSection(sb);
|
||||
appendPlaylistsSection(sb);
|
||||
appendDependenciesSection(sb);
|
||||
appendRuntimeSection(sb, event.getJDA());
|
||||
appendDiscordSection(sb, event.getJDA());
|
||||
appendPlaybackSection(sb, event.getJDA());
|
||||
|
||||
sb.append("```");
|
||||
|
||||
if (event.isFromType(ChannelType.PRIVATE)
|
||||
|| event.getSelfMember().hasPermission((TextChannel) event.getChannel(), Permission.MESSAGE_ATTACH_FILES))
|
||||
event.getChannel().sendFiles(FileUpload.fromData(sb.toString().getBytes(), "debug_information.txt")).queue();
|
||||
else
|
||||
event.reply("Debug Information: " + sb.toString());
|
||||
}
|
||||
|
||||
// ========== Section Builders ==========
|
||||
|
||||
private void appendSystemSection(StringBuilder sb)
|
||||
{
|
||||
appendSectionHeader(sb, "SYSTEM");
|
||||
for (String key : PROPERTIES)
|
||||
{
|
||||
sb.append(" ").append(key).append(" = ").append(System.getProperty(key)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private void appendJMusicBotSection(StringBuilder sb)
|
||||
{
|
||||
appendSectionHeader(sb, "JMUSICBOT");
|
||||
|
||||
String currentVersion = OtherUtil.getCurrentVersion();
|
||||
// Use proxy-aware version check if proxy is configured for GitHub
|
||||
String latestVersion = OtherUtil.getLatestVersion(bot.getConfig());
|
||||
sb.append(" Version: ").append(currentVersion);
|
||||
if (latestVersion != null)
|
||||
{
|
||||
if (OtherUtil.isNewerVersion(currentVersion, latestVersion))
|
||||
sb.append(" (UPDATE AVAILABLE: ").append(latestVersion).append(")");
|
||||
else
|
||||
sb.append(" (latest)");
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
sb.append(" Uptime: ").append(formatUptime(bot.getStartTime())).append("\n");
|
||||
sb.append(" Config: ").append(bot.getConfig().getConfigLocation()).append("\n");
|
||||
}
|
||||
|
||||
private void appendConfigurationSection(StringBuilder sb)
|
||||
{
|
||||
BotConfig config = bot.getConfig();
|
||||
appendSectionHeader(sb, "CONFIGURATION");
|
||||
|
||||
sb.append(" Owner: ").append(config.getOwnerId()).append("\n");
|
||||
sb.append(" Prefix: ").append(config.getPrefix()).append("\n");
|
||||
sb.append(" Alt Prefix: ").append(config.getAltPrefix() == null ? "NONE" : config.getAltPrefix()).append("\n");
|
||||
sb.append(" Log Level: ").append(config.getLogLevel()).append("\n");
|
||||
sb.append(" Max Track Length: ").append(config.getMaxSeconds() <= 0 ? "unlimited" : config.getMaxTime()).append("\n");
|
||||
sb.append(" Max YT Playlist Pages: ").append(config.getMaxYTPlaylistPages()).append("\n");
|
||||
sb.append(" Skip Ratio: ").append(config.getSkipRatio()).append("\n");
|
||||
sb.append(" Stay In Channel: ").append(config.getStay()).append("\n");
|
||||
sb.append(" Alone Timeout: ").append(config.getAloneTimeUntilStop() <= 0 ? "disabled" : config.getAloneTimeUntilStop() + "s").append("\n");
|
||||
sb.append(" Song In Status: ").append(config.getSongInStatus()).append("\n");
|
||||
sb.append(" NP Images: ").append(config.useNPImages()).append("\n");
|
||||
sb.append(" Update Alerts: ").append(config.useUpdateAlerts()).append("\n");
|
||||
sb.append(" Eval Enabled: ").append(config.useEval()).append("\n");
|
||||
}
|
||||
|
||||
private void appendAudioSourcesSection(StringBuilder sb)
|
||||
{
|
||||
BotConfig config = bot.getConfig();
|
||||
appendSectionHeader(sb, "AUDIO SOURCES");
|
||||
|
||||
Set<AudioSource> enabledSources = config.getEnabledAudioSources();
|
||||
Set<AudioSource> allSources = Arrays.stream(AudioSource.values()).collect(Collectors.toSet());
|
||||
|
||||
String enabled = enabledSources.stream()
|
||||
.map(AudioSource::getConfigName)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append(" Enabled: ").append(enabled.isEmpty() ? "none" : enabled).append("\n");
|
||||
|
||||
Set<AudioSource> disabledSources = allSources.stream()
|
||||
.filter(source -> !enabledSources.contains(source))
|
||||
.collect(Collectors.toSet());
|
||||
String disabled = disabledSources.stream()
|
||||
.map(AudioSource::getConfigName)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append(" Disabled: ").append(disabled.isEmpty() ? "none" : disabled).append("\n");
|
||||
|
||||
sb.append(" YouTube OAuth: ").append(formatYouTubeOAuthStatus(config)).append("\n");
|
||||
}
|
||||
|
||||
private void appendPlaylistsSection(StringBuilder sb)
|
||||
{
|
||||
PlaylistLoader loader = bot.getPlaylistLoader();
|
||||
appendSectionHeader(sb, "PLAYLISTS");
|
||||
|
||||
PlaylistLoader.PlaylistResult<java.nio.file.Path> storage = loader.checkStorageReady();
|
||||
boolean folderExists = storage.isSuccess();
|
||||
sb.append(" Folder: ").append(bot.getConfig().getPlaylistsFolder()).append("\n");
|
||||
sb.append(" Folder Exists: ").append(folderExists).append("\n");
|
||||
|
||||
if (!storage.isSuccess())
|
||||
{
|
||||
PlaylistLoader.PlaylistError error = storage.getError();
|
||||
sb.append(" Storage Status: ").append(error.getType()).append("\n");
|
||||
sb.append(" Storage Error: ").append(error.getMessage()).append("\n");
|
||||
}
|
||||
|
||||
loader.getLastStorageError().ifPresent(error ->
|
||||
{
|
||||
sb.append(" Last Failure Type: ").append(error.getType()).append("\n");
|
||||
sb.append(" Last Failure: ").append(error.getMessage()).append("\n");
|
||||
});
|
||||
|
||||
if (folderExists)
|
||||
{
|
||||
var playlistsResult = loader.getPlaylistNamesResult();
|
||||
if (playlistsResult.isSuccess())
|
||||
{
|
||||
sb.append(" Playlists: ").append(playlistsResult.getValue().size()).append("\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append(" Playlists: unavailable\n");
|
||||
sb.append(" List Error: ").append(playlistsResult.getError().getMessage()).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void appendDependenciesSection(StringBuilder sb)
|
||||
{
|
||||
appendSectionHeader(sb, "DEPENDENCIES");
|
||||
sb.append(" JDA: ").append(JDAInfo.VERSION).append("\n");
|
||||
sb.append(" JDA-Utilities: ").append(JDAUtilitiesInfo.VERSION).append("\n");
|
||||
sb.append(" Lavaplayer: ").append(PlayerLibrary.VERSION).append("\n");
|
||||
}
|
||||
|
||||
private void appendRuntimeSection(StringBuilder sb, JDA jda)
|
||||
{
|
||||
appendSectionHeader(sb, "RUNTIME");
|
||||
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
long totalMb = runtime.totalMemory() / 1024 / 1024;
|
||||
long usedMb = totalMb - (runtime.freeMemory() / 1024 / 1024);
|
||||
long maxMb = runtime.maxMemory() / 1024 / 1024;
|
||||
|
||||
sb.append(" Memory: ").append(usedMb).append(" MB used / ").append(totalMb).append(" MB total\n");
|
||||
sb.append(" Max Memory: ").append(maxMb).append(" MB\n");
|
||||
sb.append(" Processors: ").append(runtime.availableProcessors()).append("\n");
|
||||
sb.append(" Working Dir: ").append(System.getProperty("user.dir")).append("\n");
|
||||
sb.append(" Gateway Ping: ").append(jda.getGatewayPing()).append(" ms\n");
|
||||
}
|
||||
|
||||
private void appendDiscordSection(StringBuilder sb, JDA jda)
|
||||
{
|
||||
appendSectionHeader(sb, "DISCORD");
|
||||
|
||||
sb.append(" Bot ID: ").append(jda.getSelfUser().getId()).append("\n");
|
||||
sb.append(" Guilds: ").append(formatNumber(jda.getGuildCache().size())).append("\n");
|
||||
sb.append(" Users: ").append(formatNumber(jda.getUserCache().size())).append("\n");
|
||||
|
||||
long voiceConnections = jda.getGuilds().stream()
|
||||
.filter(guild -> guild.getAudioManager().isConnected())
|
||||
.count();
|
||||
sb.append(" Voice Connections: ").append(voiceConnections).append("\n");
|
||||
}
|
||||
|
||||
private void appendPlaybackSection(StringBuilder sb, JDA jda)
|
||||
{
|
||||
appendSectionHeader(sb, "PLAYBACK");
|
||||
|
||||
int guildsPlaying = 0;
|
||||
int totalQueued = 0;
|
||||
|
||||
for (var guild : jda.getGuilds())
|
||||
{
|
||||
AudioHandler handler = (AudioHandler) guild.getAudioManager().getSendingHandler();
|
||||
if (handler != null)
|
||||
{
|
||||
if (handler.getPlayer().getPlayingTrack() != null)
|
||||
guildsPlaying++;
|
||||
totalQueued += handler.getQueue().size();
|
||||
}
|
||||
}
|
||||
|
||||
sb.append(" Guilds Playing: ").append(guildsPlaying).append("\n");
|
||||
sb.append(" Total Queued Tracks: ").append(formatNumber(totalQueued)).append("\n");
|
||||
}
|
||||
|
||||
// ========== Formatting Helpers ==========
|
||||
|
||||
private void appendSectionHeader(StringBuilder sb, String title)
|
||||
{
|
||||
int padding = (SECTION_WIDTH - title.length() - 2) / 2;
|
||||
String paddingStr = SECTION_SEPARATOR.repeat(padding);
|
||||
sb.append("\n").append(paddingStr).append(" ").append(title).append(" ").append(paddingStr);
|
||||
if ((SECTION_WIDTH - title.length()) % 2 != 0)
|
||||
sb.append(SECTION_SEPARATOR);
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
private String formatUptime(Instant startTime)
|
||||
{
|
||||
Duration uptime = Duration.between(startTime, Instant.now());
|
||||
long days = uptime.toDays();
|
||||
long hours = uptime.toHours() % 24;
|
||||
long minutes = uptime.toMinutes() % 60;
|
||||
long seconds = uptime.getSeconds() % 60;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (days > 0) sb.append(days).append("d ");
|
||||
if (hours > 0 || days > 0) sb.append(hours).append("h ");
|
||||
if (minutes > 0 || hours > 0 || days > 0) sb.append(minutes).append("m ");
|
||||
sb.append(seconds).append("s");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String formatYouTubeOAuthStatus(BotConfig config)
|
||||
{
|
||||
if (!config.useYouTubeOauth())
|
||||
return "disabled";
|
||||
|
||||
boolean tokenExists = Files.exists(OtherUtil.getPath("youtubetoken.txt"));
|
||||
return "enabled (" + (tokenExists ? "token present" : "no token") + ")";
|
||||
}
|
||||
|
||||
private String formatNumber(long number)
|
||||
{
|
||||
return String.format("%,d", number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh (jagrosh).
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
|
||||
import javax.script.ScriptEngine;
|
||||
import javax.script.ScriptEngineManager;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh (jagrosh)
|
||||
*/
|
||||
public class EvalCmd extends OwnerCommand
|
||||
{
|
||||
private final Bot bot;
|
||||
private final String engine;
|
||||
|
||||
public EvalCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.name = "eval";
|
||||
this.help = "evaluates nashorn code";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.engine = bot.getConfig().getEvalEngine();
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
ScriptEngine se = new ScriptEngineManager().getEngineByName(engine);
|
||||
if(se == null)
|
||||
{
|
||||
event.replyError("The eval engine provided in the config (`"+engine+"`) doesn't exist. This could be due to an invalid "
|
||||
+ "engine name, or the engine not existing in your version of java (`"+System.getProperty("java.version")+"`).");
|
||||
return;
|
||||
}
|
||||
se.put("bot", bot);
|
||||
se.put("event", event);
|
||||
se.put("jda", event.getJDA());
|
||||
if (event.getChannelType() != ChannelType.PRIVATE) {
|
||||
se.put("guild", event.getGuild());
|
||||
se.put("channel", event.getChannel());
|
||||
}
|
||||
try
|
||||
{
|
||||
event.reply(event.getClient().getSuccess()+" Evaluated Successfully:\n```\n"+se.eval(event.getArgs())+" ```");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" An exception was thrown:\n```\n"+e+" ```");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2016 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.Command;
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class PlaylistCmd extends OwnerCommand
|
||||
{
|
||||
private final Bot bot;
|
||||
public PlaylistCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.guildOnly = false;
|
||||
this.name = "playlist";
|
||||
this.arguments = "<append|delete|make|setdefault>";
|
||||
this.help = "playlist management";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.children = new OwnerCommand[]{
|
||||
new ListCmd(),
|
||||
new AppendlistCmd(),
|
||||
new DeletelistCmd(),
|
||||
new MakelistCmd(),
|
||||
new DefaultlistCmd(bot)
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(CommandEvent event)
|
||||
{
|
||||
StringBuilder builder = new StringBuilder(event.getClient().getWarning()+" Playlist Management Commands:\n");
|
||||
for(Command cmd: this.children)
|
||||
builder.append("\n`").append(event.getClient().getPrefix()).append(name).append(" ").append(cmd.getName())
|
||||
.append(" ").append(cmd.getArguments()==null ? "" : cmd.getArguments()).append("` - ").append(cmd.getHelp());
|
||||
event.reply(builder.toString());
|
||||
}
|
||||
|
||||
public class MakelistCmd extends OwnerCommand
|
||||
{
|
||||
public MakelistCmd()
|
||||
{
|
||||
this.name = "make";
|
||||
this.aliases = new String[]{"create"};
|
||||
this.help = "makes a new playlist";
|
||||
this.arguments = "<name>";
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String pname = event.getArgs().replaceAll("\\s+", "_");
|
||||
pname = pname.replaceAll("[*?|\\/\":<>]", "");
|
||||
if(pname == null || pname.isEmpty())
|
||||
{
|
||||
event.replyError("Please provide a name for the playlist!");
|
||||
}
|
||||
else if(bot.getPlaylistLoader().getPlaylist(pname) == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
bot.getPlaylistLoader().createPlaylist(pname);
|
||||
event.reply(event.getClient().getSuccess()+" Successfully created playlist `"+pname+"`!");
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" I was unable to create the playlist: "+e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
else
|
||||
event.reply(event.getClient().getError()+" Playlist `"+pname+"` already exists!");
|
||||
}
|
||||
}
|
||||
|
||||
public class DeletelistCmd extends OwnerCommand
|
||||
{
|
||||
public DeletelistCmd()
|
||||
{
|
||||
this.name = "delete";
|
||||
this.aliases = new String[]{"remove"};
|
||||
this.help = "deletes an existing playlist";
|
||||
this.arguments = "<name>";
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String pname = event.getArgs().replaceAll("\\s+", "_");
|
||||
if(bot.getPlaylistLoader().getPlaylist(pname)==null)
|
||||
event.reply(event.getClient().getError()+" Playlist `"+pname+"` doesn't exist!");
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
bot.getPlaylistLoader().deletePlaylist(pname);
|
||||
event.reply(event.getClient().getSuccess()+" Successfully deleted playlist `"+pname+"`!");
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" I was unable to delete the playlist: "+e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AppendlistCmd extends OwnerCommand
|
||||
{
|
||||
public AppendlistCmd()
|
||||
{
|
||||
this.name = "append";
|
||||
this.aliases = new String[]{"add"};
|
||||
this.help = "appends songs to an existing playlist";
|
||||
this.arguments = "<name> <URL> | <URL> | ...";
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String[] parts = event.getArgs().split("\\s+", 2);
|
||||
if(parts.length<2)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Please include a playlist name and URLs to add!");
|
||||
return;
|
||||
}
|
||||
String pname = parts[0];
|
||||
Playlist playlist = bot.getPlaylistLoader().getPlaylist(pname);
|
||||
if(playlist==null)
|
||||
event.reply(event.getClient().getError()+" Playlist `"+pname+"` doesn't exist!");
|
||||
else
|
||||
{
|
||||
StringBuilder builder = new StringBuilder();
|
||||
playlist.getItems().forEach(item -> builder.append("\r\n").append(item));
|
||||
String[] urls = parts[1].split("\\|");
|
||||
for(String url: urls)
|
||||
{
|
||||
String u = url.trim();
|
||||
if(u.startsWith("<") && u.endsWith(">"))
|
||||
u = u.substring(1, u.length()-1);
|
||||
builder.append("\r\n").append(u);
|
||||
}
|
||||
try
|
||||
{
|
||||
bot.getPlaylistLoader().writePlaylist(pname, builder.toString());
|
||||
event.reply(event.getClient().getSuccess()+" Successfully added "+urls.length+" items to playlist `"+pname+"`!");
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" I was unable to append to the playlist: "+e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class DefaultlistCmd extends AutoplaylistCmd
|
||||
{
|
||||
public DefaultlistCmd(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.name = "setdefault";
|
||||
this.aliases = new String[]{"default"};
|
||||
this.arguments = "<playlistname|NONE>";
|
||||
this.guildOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
public class ListCmd extends OwnerCommand
|
||||
{
|
||||
public ListCmd()
|
||||
{
|
||||
this.name = "all";
|
||||
this.aliases = new String[]{"available","list"};
|
||||
this.help = "lists all available playlists";
|
||||
this.guildOnly = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
if(!bot.getPlaylistLoader().folderExists())
|
||||
bot.getPlaylistLoader().createFolder();
|
||||
if(!bot.getPlaylistLoader().folderExists())
|
||||
{
|
||||
event.reply(event.getClient().getWarning()+" Playlists folder does not exist and could not be created!");
|
||||
return;
|
||||
}
|
||||
List<String> list = bot.getPlaylistLoader().getPlaylistNames();
|
||||
if(list==null)
|
||||
event.reply(event.getClient().getError()+" Failed to load available playlists!");
|
||||
else if(list.isEmpty())
|
||||
event.reply(event.getClient().getWarning()+" There are no playlists in the Playlists folder!");
|
||||
else
|
||||
{
|
||||
StringBuilder builder = new StringBuilder(event.getClient().getSuccess()+" Available playlists:\n");
|
||||
list.forEach(str -> builder.append("`").append(str).append("` "));
|
||||
event.reply(builder.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2017 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import com.jagrosh.jmusicbot.utils.OtherUtil;
|
||||
import net.dv8tion.jda.api.entities.Icon;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SetavatarCmd extends OwnerCommand
|
||||
{
|
||||
public SetavatarCmd(Bot bot)
|
||||
{
|
||||
this.name = "setavatar";
|
||||
this.help = "sets the avatar of the bot";
|
||||
this.arguments = "<url>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String url;
|
||||
if(event.getArgs().isEmpty())
|
||||
if(!event.getMessage().getAttachments().isEmpty() && event.getMessage().getAttachments().get(0).isImage())
|
||||
url = event.getMessage().getAttachments().get(0).getUrl();
|
||||
else
|
||||
url = null;
|
||||
else
|
||||
url = event.getArgs();
|
||||
InputStream s = OtherUtil.imageFromUrl(url);
|
||||
if(s==null)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Invalid or missing URL");
|
||||
}
|
||||
else
|
||||
{
|
||||
try {
|
||||
event.getSelfUser().getManager().setAvatar(Icon.from(s)).queue(
|
||||
v -> event.reply(event.getClient().getSuccess()+" Successfully changed avatar."),
|
||||
t -> event.reply(event.getClient().getError()+" Failed to set avatar."));
|
||||
} catch(IOException e) {
|
||||
event.reply(event.getClient().getError()+" Could not load from provided URL.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2017 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import net.dv8tion.jda.api.entities.Activity;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SetgameCmd extends OwnerCommand
|
||||
{
|
||||
public SetgameCmd(Bot bot)
|
||||
{
|
||||
this.name = "setgame";
|
||||
this.help = "sets the game the bot is playing";
|
||||
this.arguments = "[action] [game]";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = false;
|
||||
this.children = new OwnerCommand[]{
|
||||
new SetlistenCmd(),
|
||||
new SetstreamCmd(),
|
||||
new SetwatchCmd()
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String title = event.getArgs().toLowerCase().startsWith("playing") ? event.getArgs().substring(7).trim() : event.getArgs();
|
||||
try
|
||||
{
|
||||
event.getJDA().getPresence().setActivity(title.isEmpty() ? null : Activity.playing(title));
|
||||
event.reply(event.getClient().getSuccess()+" **"+event.getSelfUser().getName()
|
||||
+"** is "+(title.isEmpty() ? "no longer playing anything." : "now playing `"+title+"`"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" The game could not be set!");
|
||||
}
|
||||
}
|
||||
|
||||
private class SetstreamCmd extends OwnerCommand
|
||||
{
|
||||
private SetstreamCmd()
|
||||
{
|
||||
this.name = "stream";
|
||||
this.aliases = new String[]{"twitch","streaming"};
|
||||
this.help = "sets the game the bot is playing to a stream";
|
||||
this.arguments = "<username> <game>";
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
String[] parts = event.getArgs().split("\\s+", 2);
|
||||
if(parts.length<2)
|
||||
{
|
||||
event.replyError("Please include a twitch username and the name of the game to 'stream'");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
event.getJDA().getPresence().setActivity(Activity.streaming(parts[1], "https://twitch.tv/"+parts[0]));
|
||||
event.replySuccess("**"+event.getSelfUser().getName()
|
||||
+"** is now streaming `"+parts[1]+"`");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" The game could not be set!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SetlistenCmd extends OwnerCommand
|
||||
{
|
||||
private SetlistenCmd()
|
||||
{
|
||||
this.name = "listen";
|
||||
this.aliases = new String[]{"listening"};
|
||||
this.help = "sets the game the bot is listening to";
|
||||
this.arguments = "<title>";
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.replyError("Please include a title to listen to!");
|
||||
return;
|
||||
}
|
||||
String title = event.getArgs().toLowerCase().startsWith("to") ? event.getArgs().substring(2).trim() : event.getArgs();
|
||||
try
|
||||
{
|
||||
event.getJDA().getPresence().setActivity(Activity.listening(title));
|
||||
event.replySuccess("**"+event.getSelfUser().getName()+"** is now listening to `"+title+"`");
|
||||
} catch(Exception e) {
|
||||
event.reply(event.getClient().getError()+" The game could not be set!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SetwatchCmd extends OwnerCommand
|
||||
{
|
||||
private SetwatchCmd()
|
||||
{
|
||||
this.name = "watch";
|
||||
this.aliases = new String[]{"watching"};
|
||||
this.help = "sets the game the bot is watching";
|
||||
this.arguments = "<title>";
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
if(event.getArgs().isEmpty())
|
||||
{
|
||||
event.replyError("Please include a title to watch!");
|
||||
return;
|
||||
}
|
||||
String title = event.getArgs();
|
||||
try
|
||||
{
|
||||
event.getJDA().getPresence().setActivity(Activity.watching(title));
|
||||
event.replySuccess("**"+event.getSelfUser().getName()+"** is now watching `"+title+"`");
|
||||
} catch(Exception e) {
|
||||
event.reply(event.getClient().getError()+" The game could not be set!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2018 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import net.dv8tion.jda.api.exceptions.RateLimitedException;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SetnameCmd extends OwnerCommand
|
||||
{
|
||||
public SetnameCmd(Bot bot)
|
||||
{
|
||||
this.name = "setname";
|
||||
this.help = "sets the name of the bot";
|
||||
this.arguments = "<name>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
try
|
||||
{
|
||||
String oldname = event.getSelfUser().getName();
|
||||
event.getSelfUser().getManager().setName(event.getArgs()).complete(false);
|
||||
event.reply(event.getClient().getSuccess()+" Name changed from `"+oldname+"` to `"+event.getArgs()+"`");
|
||||
}
|
||||
catch(RateLimitedException e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" Name can only be changed twice per hour!");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
event.reply(event.getClient().getError()+" That name is not valid!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2017 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
import net.dv8tion.jda.api.OnlineStatus;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class SetstatusCmd extends OwnerCommand
|
||||
{
|
||||
public SetstatusCmd(Bot bot)
|
||||
{
|
||||
this.name = "setstatus";
|
||||
this.help = "sets the status the bot displays";
|
||||
this.arguments = "<status>";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
try {
|
||||
OnlineStatus status = OnlineStatus.fromKey(event.getArgs());
|
||||
if(status==OnlineStatus.UNKNOWN)
|
||||
{
|
||||
event.replyError("Please include one of the following statuses: `ONLINE`, `IDLE`, `DND`, `INVISIBLE`");
|
||||
}
|
||||
else
|
||||
{
|
||||
event.getJDA().getPresence().setStatus(status);
|
||||
event.replySuccess("Set the status to `"+status.getKey().toUpperCase()+"`");
|
||||
}
|
||||
} catch(Exception e) {
|
||||
event.reply(event.getClient().getError()+" The status could not be set!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2017 John Grosh <[email protected]>.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v1.owner;
|
||||
|
||||
import com.jagrosh.jdautilities.command.CommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.OwnerCommand;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author John Grosh <[email protected]>
|
||||
*/
|
||||
public class ShutdownCmd extends OwnerCommand
|
||||
{
|
||||
private final Bot bot;
|
||||
|
||||
public ShutdownCmd(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.name = "shutdown";
|
||||
this.help = "safely shuts down";
|
||||
this.aliases = bot.getConfig().getAliases(this.name);
|
||||
this.guildOnly = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(CommandEvent event)
|
||||
{
|
||||
event.replyWarning("Shutting down...");
|
||||
bot.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v2;
|
||||
|
||||
import com.jagrosh.jdautilities.command.SlashCommand;
|
||||
import com.jagrosh.jdautilities.command.SlashCommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
|
||||
/**
|
||||
* Base class for Admin-level slash commands.
|
||||
* Requires MANAGE_SERVER permission or being the bot owner.
|
||||
*/
|
||||
public abstract class AdminSlashCommand extends SlashCommand
|
||||
{
|
||||
protected final Bot bot;
|
||||
|
||||
public AdminSlashCommand(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.guildOnly = true;
|
||||
this.category = new Category("Admin");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(SlashCommandEvent event)
|
||||
{
|
||||
// Check if user is owner or has MANAGE_SERVER permission
|
||||
boolean isOwner = event.getUser().getId().equals(event.getClient().getOwnerId());
|
||||
boolean hasPermission = event.getMember() != null && event.getMember().hasPermission(Permission.MANAGE_SERVER);
|
||||
|
||||
if (!isOwner && !hasPermission)
|
||||
{
|
||||
event.reply(event.getClient().getError() + " You need the Manage Server permission to use this command!")
|
||||
.setEphemeral(true).queue();
|
||||
return;
|
||||
}
|
||||
|
||||
doAdminCommand(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to implement the admin command logic.
|
||||
*/
|
||||
public abstract void doAdminCommand(SlashCommandEvent event);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v2;
|
||||
|
||||
import com.jagrosh.jdautilities.command.SlashCommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.v1.DJCommand;
|
||||
|
||||
/**
|
||||
* Base class for DJ-level slash commands.
|
||||
* Extends MusicSlashCommand and adds DJ permission checking.
|
||||
*/
|
||||
public abstract class DJSlashCommand extends MusicSlashCommand
|
||||
{
|
||||
public DJSlashCommand(Bot bot)
|
||||
{
|
||||
super(bot);
|
||||
this.category = new Category("DJ");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doCommand(SlashCommandEvent event)
|
||||
{
|
||||
if (!DJCommand.checkDJPermission(bot, event.getGuild(), event.getMember()))
|
||||
{
|
||||
event.reply(event.getClient().getError() + " You need to be a DJ to use this command!")
|
||||
.setEphemeral(true).queue();
|
||||
return;
|
||||
}
|
||||
doDJCommand(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to implement the DJ command logic.
|
||||
*/
|
||||
public abstract void doDJCommand(SlashCommandEvent event);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.jagrosh.jmusicbot.commands.v2;
|
||||
|
||||
import com.jagrosh.jdautilities.command.SlashCommand;
|
||||
import com.jagrosh.jdautilities.command.SlashCommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
import com.jagrosh.jmusicbot.commands.MusicCommandValidator;
|
||||
import com.jagrosh.jmusicbot.settings.Settings;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||
|
||||
public abstract class MusicSlashCommand extends SlashCommand
|
||||
{
|
||||
protected final Bot bot;
|
||||
protected boolean bePlaying;
|
||||
protected boolean beListening;
|
||||
|
||||
public MusicSlashCommand(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.guildOnly = true;
|
||||
this.category = new Category("Music");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(SlashCommandEvent event)
|
||||
{
|
||||
Settings settings = event.getClient().getSettingsFor(event.getGuild());
|
||||
String errorEmoji = event.getClient().getError();
|
||||
|
||||
boolean valid = MusicCommandValidator.validate(
|
||||
event.getGuild(),
|
||||
event.getMember(),
|
||||
event.getTextChannel(),
|
||||
settings,
|
||||
bot,
|
||||
event.getJDA(),
|
||||
bePlaying,
|
||||
beListening,
|
||||
new MusicCommandValidator.ErrorHandler()
|
||||
{
|
||||
@Override
|
||||
public void onTextChannelError(TextChannel requiredChannel)
|
||||
{
|
||||
event.reply(errorEmoji + " You can only use that command in " + requiredChannel.getAsMention() + "!")
|
||||
.setEphemeral(true).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotPlayingError()
|
||||
{
|
||||
event.reply(errorEmoji + " There must be music playing to use that!")
|
||||
.setEphemeral(true).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotListeningError(AudioChannel requiredChannel)
|
||||
{
|
||||
String channelName = requiredChannel == null ? "a voice channel" : requiredChannel.getAsMention();
|
||||
event.reply(errorEmoji + " You must be listening in " + channelName + " to use that!")
|
||||
.setEphemeral(true).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAfkChannelError()
|
||||
{
|
||||
event.reply(errorEmoji + " You cannot use that command in an AFK channel!")
|
||||
.setEphemeral(true).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceConnectError(AudioChannel channel)
|
||||
{
|
||||
event.reply(errorEmoji + " I am unable to connect to " + channel.getAsMention() + "!")
|
||||
.setEphemeral(true).queue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (valid)
|
||||
{
|
||||
doCommand(event);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract void doCommand(SlashCommandEvent event);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v2;
|
||||
|
||||
import com.jagrosh.jdautilities.command.SlashCommand;
|
||||
import com.jagrosh.jdautilities.command.SlashCommandEvent;
|
||||
import com.jagrosh.jmusicbot.Bot;
|
||||
|
||||
/**
|
||||
* Base class for owner-only slash commands.
|
||||
* Only the bot owner can use these commands (no MANAGE_SERVER fallback).
|
||||
*/
|
||||
public abstract class OwnerSlashCommand extends SlashCommand
|
||||
{
|
||||
protected final Bot bot;
|
||||
|
||||
public OwnerSlashCommand(Bot bot)
|
||||
{
|
||||
this.bot = bot;
|
||||
this.guildOnly = true;
|
||||
this.category = new Category("Owner");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(SlashCommandEvent event)
|
||||
{
|
||||
if (!event.getUser().getId().equals(event.getClient().getOwnerId()))
|
||||
{
|
||||
event.reply(event.getClient().getError() + " Only the bot owner can use this command!")
|
||||
.setEphemeral(true).queue();
|
||||
return;
|
||||
}
|
||||
|
||||
doOwnerCommand(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to implement the owner command logic.
|
||||
*/
|
||||
public abstract void doOwnerCommand(SlashCommandEvent event);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright 2026 Arif Banai (arif-banai)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.jagrosh.jmusicbot.commands.v2;
|
||||
|
||||
import com.jagrosh.jdautilities.command.SlashCommandEvent;
|
||||
import com.jagrosh.jmusicbot.audio.AudioHandler;
|
||||
import com.jagrosh.jmusicbot.commands.BaseOutputAdapter;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.interactions.InteractionHook;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageEditBuilder;
|
||||
import net.dv8tion.jda.api.utils.messages.MessageEditData;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Reusable OutputAdapter implementations for slash commands.
|
||||
*/
|
||||
public final class SlashOutputAdapters
|
||||
{
|
||||
private SlashOutputAdapters() {} // Utility class
|
||||
|
||||
/**
|
||||
* OutputAdapter for direct SlashCommandEvent replies (before any response is sent).
|
||||
* Errors and warnings are sent as ephemeral messages.
|
||||
*/
|
||||
public static class SlashEventOutputAdapter extends BaseOutputAdapter
|
||||
{
|
||||
private final SlashCommandEvent event;
|
||||
|
||||
public SlashEventOutputAdapter(SlashCommandEvent event)
|
||||
{
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replySuccess(String content)
|
||||
{
|
||||
event.reply(content).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyError(String content)
|
||||
{
|
||||
event.reply(content).setEphemeral(true).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyWarning(String content)
|
||||
{
|
||||
event.reply(content).setEphemeral(true).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content)
|
||||
{
|
||||
event.reply(content).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content, Consumer<Message> onSuccess)
|
||||
{
|
||||
event.reply(content).queue(hook -> hook.retrieveOriginal().queue(onSuccess));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editNowPlaying(AudioHandler handler)
|
||||
{
|
||||
event.reply(handler.getNowPlaying(event.getJDA())).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editNoMusic(AudioHandler handler)
|
||||
{
|
||||
event.reply(handler.getNoMusicPlaying(event.getJDA())).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowHelp()
|
||||
{
|
||||
event.reply(event.getClient().getWarning() + " Please include a song title or URL!").setEphemeral(true).queue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OutputAdapter for editing an existing interaction response via InteractionHook.
|
||||
* Used after a loading message has already been sent.
|
||||
*/
|
||||
public static class InteractionHookOutputAdapter extends BaseOutputAdapter
|
||||
{
|
||||
private final InteractionHook hook;
|
||||
private final JDA jda;
|
||||
private final String warningEmoji;
|
||||
|
||||
public InteractionHookOutputAdapter(InteractionHook hook, JDA jda, String warningEmoji)
|
||||
{
|
||||
this.hook = hook;
|
||||
this.jda = jda;
|
||||
this.warningEmoji = warningEmoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replySuccess(String content)
|
||||
{
|
||||
hook.editOriginal(content).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyError(String content)
|
||||
{
|
||||
hook.editOriginal(content).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void replyWarning(String content)
|
||||
{
|
||||
hook.editOriginal(content).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content)
|
||||
{
|
||||
hook.editOriginal(content).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editMessage(String content, Consumer<Message> onSuccess)
|
||||
{
|
||||
hook.editOriginal(content).queue(onSuccess);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editNowPlaying(AudioHandler handler)
|
||||
{
|
||||
hook.editOriginal(asEditData(handler.getNowPlaying(jda))).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editNoMusic(AudioHandler handler)
|
||||
{
|
||||
hook.editOriginal(asEditData(handler.getNoMusicPlaying(jda))).queue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowHelp()
|
||||
{
|
||||
hook.editOriginal(warningEmoji + " Please include a song title or URL!").queue();
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageEditData asEditData(MessageCreateData source)
|
||||
{
|
||||
return new MessageEditBuilder()
|
||||
.setContent(source.getContent() == null ? "" : source.getContent())
|
||||
.setEmbeds(source.getEmbeds())
|
||||
.setComponents(source.getComponents())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user