commit 0941987234b1117a4328b95ff0fd20134e185ded Author: purnell569 <47968295+purnell569@users.noreply.github.com> Date: Fri Sep 11 13:47:20 2026 +0930 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//...". 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. diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..4b86898 --- /dev/null +++ b/.circleci/config.yml @@ -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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2121596 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..57f6859 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8302894 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -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 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5e5b2d6 --- /dev/null +++ b/.github/FUNDING.yml @@ -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'] diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..a038a87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..98ca952 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..ff2d6b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -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: ![Release](https://img.shields.io/github/release/arif-banai/MusicBot.svg)" + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..5e35412 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 #... diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml new file mode 100644 index 0000000..64d5651 --- /dev/null +++ b/.github/actions/docker-build/action.yml @@ -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 }}" diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..56613f2 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -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 }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..85678cb --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 '' pom.xml | sed 's/.*\([^<]*\)<\/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 }}" diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml new file mode 100644 index 0000000..0169815 --- /dev/null +++ b/.github/workflows/make-release.yml @@ -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) diff --git a/.github/workflows/publish-preview-image.yml b/.github/workflows/publish-preview-image.yml new file mode 100644 index 0000000..53c9cbc --- /dev/null +++ b/.github/workflows/publish-preview-image.yml @@ -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 '' pom.xml | sed 's/.*\([^<]*\)<\/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- tag + # - sha- 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 }}" diff --git a/.github/workflows/validate-branch-naming.yml b/.github/workflows/validate-branch-naming.yml new file mode 100644 index 0000000..c140385 --- /dev/null +++ b/.github/workflows/validate-branch-naming.yml @@ -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/ + if [[ "$BRANCH_NAME" =~ ^feature/[a-z0-9-]+$ ]]; then + VALID=true + echo "✓ Branch name matches pattern: feature/" + # fix/ + elif [[ "$BRANCH_NAME" =~ ^fix/[a-z0-9-]+$ ]]; then + VALID=true + echo "✓ Branch name matches pattern: fix/" + # chore/ + elif [[ "$BRANCH_NAME" =~ ^chore/[a-z0-9-]+$ ]]; then + VALID=true + echo "✓ Branch name matches pattern: chore/" + # deps/ + elif [[ "$BRANCH_NAME" =~ ^deps/[a-z0-9-]+$ ]]; then + VALID=true + echo "✓ Branch name matches pattern: deps/" + # release/ (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/" + fi + + if [[ "$VALID" == "false" ]]; then + echo "❌ Branch name '$BRANCH_NAME' does not match allowed patterns:" + echo "" + echo "Allowed branch patterns:" + echo " - feature/ (e.g., feature/new-player-ui)" + echo " - fix/ (e.g., fix/youtube-oauth)" + echo " - chore/ (e.g., chore/update-dependencies)" + echo " - deps/ (e.g., deps/youtube-source-pr195)" + echo " - release/ (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: /" + echo "" + exit 1 + fi + + echo "✅ Branch name is valid!" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fb8b8c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76616ea --- /dev/null +++ b/Dockerfile @@ -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 +# 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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c350a37 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..fbd53d7 --- /dev/null +++ b/NOTICE @@ -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) + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6169d5 --- /dev/null +++ b/README.md @@ -0,0 +1,336 @@ + + +# 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. + +[![Downloads](https://img.shields.io/github/downloads/arif-banai/MusicBot/total.svg)](https://github.com/arif-banai/MusicBot/releases/latest) +[![Stars](https://img.shields.io/github/stars/arif-banai/MusicBot.svg)](https://github.com/arif-banai/MusicBot/stargazers) +[![Release](https://img.shields.io/github/release/arif-banai/MusicBot.svg)](https://github.com/arif-banai/MusicBot/releases/latest) +[![License](https://img.shields.io/github/license/arif-banai/MusicBot.svg)](https://github.com/arif-banai/MusicBot/blob/master/LICENSE) +[![Discord](https://discordapp.com/api/guilds/1453856673004392634/widget.png?v=1)](https://discord.gg/cyyUxNmmx6)
+[![CircleCI](https://dl.circleci.com/status-badge/img/gh/arif-banai/MusicBot/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/arif-banai/MusicBot/tree/master) +[![Build and Test](https://github.com/arif-banai/MusicBot/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/arif-banai/MusicBot/actions/workflows/build-and-test.yml) +[![CodeFactor](https://www.codefactor.io/repository/github/arif-banai/musicbot/badge)](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.* + + +[![Setup](http://i.imgur.com/VvXYp5j.png)](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 +![Loading Example...](https://i.imgur.com/kVtTKvS.gif) + +## 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/`** - New features (e.g., `feature/new-player-ui`) +- **`fix/`** - Bug fixes (e.g., `fix/youtube-oauth`) +- **`chore/`** - Maintenance tasks (e.g., `chore/update-deps`) +- **`deps/`** - Dependency experiments (e.g., `deps/youtube-source-pr195`) +- **`release/`** - 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. diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..52d66c9 --- /dev/null +++ b/docker-compose.example.yml @@ -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 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..2065398 --- /dev/null +++ b/docker/entrypoint.sh @@ -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 "$@" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6566394 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 { + <> + +getIdentifier() long + } + + class AbstractQueue~T~ { + <> + -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 { + <> + #musicService: MusicService + #bot: Bot + +execute(CommandEvent) + #doCommand(CommandEvent)* + } + + class MusicSlashCommand { + <> + #musicService: MusicService + #bot: Bot + +execute(SlashCommandEvent) + #doCommand(SlashCommandEvent)* + } + + class OutputAdapter { + <> + +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`) 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* diff --git a/docs/DEVELOPMENT_WORKFLOW.md b/docs/DEVELOPMENT_WORKFLOW.md new file mode 100644 index 0000000..0aae00e --- /dev/null +++ b/docs/DEVELOPMENT_WORKFLOW.md @@ -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/`** - New features and enhancements + - Examples: `feature/new-player-ui`, `feature/playlist-import` + - Use for: Adding new functionality, UI improvements, major enhancements + +2. **`fix/`** - Bug fixes + - Examples: `fix/youtube-oauth`, `fix/memory-leak` + - Use for: Fixing bugs, resolving issues, patching vulnerabilities + +3. **`chore/`** - Maintenance tasks + - Examples: `chore/update-dependencies`, `chore/refactor-audio-handler` + - Use for: Code cleanup, refactoring, dependency updates, documentation + +4. **`deps/`** - 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/`** - 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**: `/` +- **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 `:` (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**: `: ` +- **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/` 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/` 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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..61226e3 --- /dev/null +++ b/pom.xml @@ -0,0 +1,862 @@ + + + 4.0.0 + com.arifbanai + JMusicBot + 0.7.0 + jar + + JMusicBot + A cross-platform Discord music bot with a clean interface + https://jmusicbot.com + 2017 + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + + + + + JMusicBot + https://jmusicbot.com + + + + + jagrosh + John Grosh + https://github.com/jagrosh + + original author + + + + arif-banai + Arif Banai + https://github.com/arif-banai + + maintainer + + + + + + scm:git:https://github.com/arif-banai/MusicBot.git + scm:git:git@github.com:arif-banai/MusicBot.git + https://github.com/arif-banai/MusicBot + HEAD + + + + GitHub Issues + https://github.com/arif-banai/MusicBot/issues + + + + GitHub Actions + https://github.com/arif-banai/MusicBot/actions + + + + + dv8tion + m2-dv8tion + https://m2.dv8tion.net/releases + + + jitpack.io + https://jitpack.io + + true + always + + + + arbjergDev + Lavalink Repository + https://maven.lavalink.dev/releases + + + lavalinkSnapshots + Lavalink Snapshot Repository + https://maven.lavalink.dev/snapshots + + true + + + + chew + m2-chew + https://m2.chew.pro/releases + + + + + + + + com.fasterxml.jackson + jackson-bom + ${jackson-bom.version} + pom + import + + + + org.junit + junit-bom + ${junit-bom.version} + pom + import + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin-stdlib.version} + + + + org.jetbrains + annotations + ${jetbrains-annotations.version} + + + + org.json + json + ${org-json.version} + + + + org.jsoup + jsoup + ${jsoup.version} + + + + org.mozilla + rhino-engine + ${rhino-engine.version} + + + + dev.arbjerg + lava-common + ${lava-common.version} + + + + + + + + net.dv8tion + JDA + ${jda.version} + + + pw.chew + jda-chewtils + ${jda-chewtils.version} + compile + pom + + + + org.slf4j + slf4j-api + + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlin + kotlin-stdlib-common + + + + com.squareup.okhttp3 + okhttp + + + + com.squareup.okio + okio + + + + + + + + club.minnced + jdave-api + ${jdave.version} + + + club.minnced + jdave-native-linux-x86-64 + ${jdave.version} + + + club.minnced + jdave-native-linux-aarch64 + ${jdave.version} + + + club.minnced + jdave-native-win-x86-64 + ${jdave.version} + + + club.minnced + jdave-native-darwin + ${jdave.version} + + + + + + club.minnced + udpqueue-native-linux-x86-64 + ${udpqueue.version} + + + club.minnced + udpqueue-native-linux-x86 + ${udpqueue.version} + + + club.minnced + udpqueue-native-linux-aarch64 + ${udpqueue.version} + + + club.minnced + udpqueue-native-linux-arm + ${udpqueue.version} + + + club.minnced + udpqueue-native-linux-musl-x86-64 + ${udpqueue.version} + + + club.minnced + udpqueue-native-linux-musl-aarch64 + ${udpqueue.version} + + + club.minnced + udpqueue-native-win-x86-64 + ${udpqueue.version} + + + club.minnced + udpqueue-native-win-x86 + ${udpqueue.version} + + + club.minnced + udpqueue-native-win-aarch64 + ${udpqueue.version} + + + club.minnced + udpqueue-native-darwin + ${udpqueue.version} + + + + + dev.arbjerg + lavaplayer + ${lavaplayer.version} + + + + dev.lavalink.youtube + v2 + ${youtube-source.version} + + + + + + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + com.typesafe + config + ${typesafe-config.version} + + + + + org.jsoup + jsoup + + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + com.formdev + flatlaf + ${flatlaf.version} + + + + com.formdev + flatlaf-extras + ${flatlaf.version} + + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + org.hamcrest + hamcrest-core + ${hamcrest.version} + test + + + com.squareup.okhttp3 + mockwebserver + 5.3.2 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-surefire-plugin.version} + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven-enforcer-plugin.version} + + + org.apache.maven.plugins + maven-dependency-plugin + ${maven-dependency-plugin.version} + + + org.codehaus.mojo + versions-maven-plugin + ${versions-maven-plugin.version} + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-maven-plugin.version} + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + true + All + + + *:* + + + + + + club.minnced:* + + ** + + + + + ch.qos.logback:* + + ** + + + + org.slf4j:* + + ** + + + + + com.formdev:* + + ** + + + + + + reference.conf + + + + com.jagrosh.jmusicbot.JMusicBot + ${project.artifactId} + ${project.version} + ${project.artifactId} + ${project.version} + ${project.groupId} + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/unit/**/*Test.java + **/unit/**/*Tests.java + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + **/integration/**/*Test.java + **/integration/**/*Tests.java + **/integration/**/*IT.java + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.release} + true + true + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + true + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-versions + + enforce + + + + + [3.8.0,) + + + [25,) + + + + + + + + + + + + + UTF-8 + UTF-8 + 25 + 25 + 25 + + + 2026-07-14T05:29:10Z + + + 6.4.2 + 2.2.1 + 0.1.7 + 0.2.12 + 2.2.7 + + f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT + 1.5.25 + 1.4.3 + 1.22.1 + 5.21.0 + 3.0 + 3.7 + + + 2.20.1 + 5.14.2 + + + 2.0.17 + 2.2.21 + 24.0.0 + 20240303 + 1.7.15 + 2.2.7 + + + 3.13.0 + 3.6.1 + 3.5.4 + 3.4.2 + 3.5.0 + 3.8.1 + 2.18.0 + 4.9.3.0 + 3.6.0 + 0.8.13 + + + + + + checkstyle + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + google_checks.xml + true + true + + + + checkstyle-check + verify + + check + + + + + + + + + + + spotbugs + + + + com.github.spotbugs + spotbugs-maven-plugin + + + spotbugs-check + verify + + check + + + + + + + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + prepare-agent-integration + + prepare-agent-integration + + + + report + test + + report + + + + report-integration + post-integration-test + + report-integration + + + + + + + + + + + dependency-check + + + + org.apache.maven.plugins + maven-dependency-plugin + + + org.codehaus.mojo + versions-maven-plugin + + + + + + + + e2e + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + e2e-tests + + integration-test + verify + + + + **/e2e/**/*Test.java + **/e2e/**/*Tests.java + **/e2e/**/*IT.java + + + + + + + + + + + + + docker + + + + org.apache.maven.plugins + maven-shade-plugin + + + default + package + + shade + + + true + All + + + *:* + + + + + club.minnced:jdave-native-win-x86-64 + club.minnced:jdave-native-darwin + + club.minnced:udpqueue-native-win-x86-64 + club.minnced:udpqueue-native-win-x86 + club.minnced:udpqueue-native-win-aarch64 + club.minnced:udpqueue-native-darwin + club.minnced:udpqueue-native-linux-x86 + club.minnced:udpqueue-native-linux-arm + club.minnced:udpqueue-native-linux-musl-x86-64 + club.minnced:udpqueue-native-linux-musl-aarch64 + + + + + + club.minnced:* + + ** + + + + + ch.qos.logback:* + + ** + + + + org.slf4j:* + + ** + + + + + com.formdev:* + + ** + + + + + + reference.conf + + + + com.jagrosh.jmusicbot.JMusicBot + ${project.artifactId} + ${project.version} + ${project.artifactId} + ${project.version} + ${project.groupId} + + + + + + + + + + + + diff --git a/scripts/run_jmusicbot.sh b/scripts/run_jmusicbot.sh new file mode 100644 index 0000000..49af457 --- /dev/null +++ b/scripts/run_jmusicbot.sh @@ -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 diff --git a/src/main/java/com/jagrosh/jmusicbot/Bot.java b/src/main/java/com/jagrosh/jmusicbot/Bot.java new file mode 100644 index 0000000..177a401 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/Bot.java @@ -0,0 +1,269 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/BotConfig.java b/src/main/java/com/jagrosh/jmusicbot/BotConfig.java new file mode 100644 index 0000000..d3843fe --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/BotConfig.java @@ -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 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 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 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. + * + *

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 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; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/DiscordService.java b/src/main/java/com/jagrosh/jmusicbot/DiscordService.java new file mode 100644 index 0000000..5122bb4 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/DiscordService.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java b/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java new file mode 100644 index 0000000..105f4a6 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java @@ -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); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/AloneInVoiceHandler.java b/src/main/java/com/jagrosh/jmusicbot/audio/AloneInVoiceHandler.java new file mode 100644 index 0000000..d1873f8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/AloneInVoiceHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright 2021 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. + */ +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 (mysteriouscursor+git@protonmail.com) + */ +public class AloneInVoiceHandler +{ + private final Bot bot; + private final HashMap 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 toRemove = new HashSet<>(); + for(Map.Entry 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()); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/AudioHandler.java b/src/main/java/com/jagrosh/jmusicbot/audio/AudioHandler.java new file mode 100644 index 0000000..8436123 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/AudioHandler.java @@ -0,0 +1,618 @@ +/* + * Copyright 2016 John Grosh . + * + * 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 + */ +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 defaultQueue = new LinkedList<>(); + private final Set 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 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 getPreviousTracks() + { + return queue.getHistory().getList(); + } + + public AbstractQueue 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 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. + * + *

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. + * + *

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 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; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/AudioLoadWrapper.java b/src/main/java/com/jagrosh/jmusicbot/audio/AudioLoadWrapper.java new file mode 100644 index 0000000..4ceb8b3 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/AudioLoadWrapper.java @@ -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; +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/AudioMetricsListener.java b/src/main/java/com/jagrosh/jmusicbot/audio/AudioMetricsListener.java new file mode 100644 index 0000000..eb79095 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/AudioMetricsListener.java @@ -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. + * + *

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() {} + }; +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/AudioSource.java b/src/main/java/com/jagrosh/jmusicbot/audio/AudioSource.java new file mode 100644 index 0000000..64ef898 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/AudioSource.java @@ -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. + * + *

Registration Order Matters: 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: + *

    + *
  • Platform-specific sources (YouTube, SoundCloud, etc.) should have LOW priority numbers + * so they are registered FIRST and get first chance to claim their URLs
  • + *
  • Catch-all sources (HTTP, LOCAL) should have HIGH priority numbers so they are + * registered LAST and only claim URLs that no specific source wanted
  • + *
+ * + *

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 registrationAction; + + AudioSource(String configName, String description, int registrationPriority, + BiConsumer 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 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 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. + * + *

The remote cipher is applied whenever one is configured, independently of + * OAuth. 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. + * + *

Order matters. Clients are tried in sequence, so the most reliable one goes + * first and the most restricted one goes last: + *

    + *
  • AndroidWithThumbnail - 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.
  • + *
  • AndroidVrWithThumbnail - second opinion when ANDROID is refused.
  • + *
  • MWebWithThumbnail - needs a working signature cipher, see + * {@link #buildYoutubeOptions(BotConfig)}.
  • + *
  • TvHtml5SimplyWithThumbnail - frequently hits the bot check, so it sits late.
  • + *
  • WebWithThumbnail - 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.
  • + *
+ * + *

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; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/GCMonitor.java b/src/main/java/com/jagrosh/jmusicbot/audio/GCMonitor.java new file mode 100644 index 0000000..aff2c58 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/GCMonitor.java @@ -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. + * + *

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 events = new ConcurrentLinkedDeque<>(); + private final List 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 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 + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingHandler.java b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingHandler.java new file mode 100644 index 0000000..058e49e --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingHandler.java @@ -0,0 +1,466 @@ +/* + * Copyright 2018 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. + */ +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 (john.a.grosh@gmail.com) + */ +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 guildStates; // guild -> now playing reconcile state + private final Set 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(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingInfo.java b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingInfo.java new file mode 100644 index 0000000..4620f14 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingInfo.java @@ -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; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/PerformanceMetrics.java b/src/main/java/com/jagrosh/jmusicbot/audio/PerformanceMetrics.java new file mode 100644 index 0000000..2510028 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/PerformanceMetrics.java @@ -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). + * + *

JDA requests audio frames every ~20ms. This class tracks: + *

    + *
  • Frame availability (provided vs missed - the key stutter indicator)
  • + *
  • Frame latency (time to get frame from Lavaplayer)
  • + *
  • Consecutive frame streaks (buffer health indicator)
  • + *
  • Stutter events (consecutive missed frames)
  • + *
+ * + * @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 stutterEvents = new ConcurrentLinkedDeque<>(); + + // Stuck events (Lavaplayer track stuck) - rare, allocation OK + private final ConcurrentLinkedDeque stuckEvents = new ConcurrentLinkedDeque<>(); + + // Track events (start/end/exception/stuck) - rare, allocation OK + private final ConcurrentLinkedDeque 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 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 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 + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/PlayerManager.java b/src/main/java/com/jagrosh/jmusicbot/audio/PlayerManager.java new file mode 100644 index 0000000..e3828a7 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/PlayerManager.java @@ -0,0 +1,105 @@ +/* + * Copyright 2018 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. + */ +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 (john.a.grosh@gmail.com) + */ +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; + } + +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/QueuedTrack.java b/src/main/java/com/jagrosh/jmusicbot/audio/QueuedTrack.java new file mode 100644 index 0000000..6dabe3b --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/QueuedTrack.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021 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. + */ +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 + */ +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() + ">"; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/RequestMetadata.java b/src/main/java/com/jagrosh/jmusicbot/audio/RequestMetadata.java new file mode 100644 index 0000000..150dd32 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/RequestMetadata.java @@ -0,0 +1,184 @@ +/* + * Copyright 2021 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. + */ +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 (john.a.grosh@gmail.com) + */ +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("\"", "\\\"") : "") + "\"}"; + } + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/SystemHealthMonitor.java b/src/main/java/com/jagrosh/jmusicbot/audio/SystemHealthMonitor.java new file mode 100644 index 0000000..516546c --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/SystemHealthMonitor.java @@ -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. + * + *

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 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 + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/TrackLoadingMonitor.java b/src/main/java/com/jagrosh/jmusicbot/audio/TrackLoadingMonitor.java new file mode 100644 index 0000000..a201c94 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/TrackLoadingMonitor.java @@ -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.). + * + *

Tracks for each load request: + *

    + *
  • Load duration (time from loadItem call to result)
  • + *
  • Source manager (which audio source handled the request)
  • + *
  • Load result (success, failure, no match)
  • + *
  • Error messages for failures
  • + *
+ * + *

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 loadEvents = new ConcurrentLinkedDeque<>(); + + // Per-source statistics + private final ConcurrentMap 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 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); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/TransformativeAudioSourceManager.java b/src/main/java/com/jagrosh/jmusicbot/audio/TransformativeAudioSourceManager.java new file mode 100644 index 0000000..e0a26b5 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/TransformativeAudioSourceManager.java @@ -0,0 +1,105 @@ +/* + * Copyright 2021 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. + */ +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 (john.a.grosh@gmail.com) + */ +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 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(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/VoiceConnectionMonitor.java b/src/main/java/com/jagrosh/jmusicbot/audio/VoiceConnectionMonitor.java new file mode 100644 index 0000000..274d5ed --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/audio/VoiceConnectionMonitor.java @@ -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. + * + *

Tracks: + *

    + *
  • Voice connects and disconnects
  • + *
  • Channel moves (reconnects)
  • + *
  • Connection durations
  • + *
+ * + * @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> guildEvents = new ConcurrentHashMap<>(); + + // Global event tracking (all guilds) + private final ConcurrentLinkedDeque globalEvents = new ConcurrentLinkedDeque<>(); + + // Track current connection state per guild + private final ConcurrentMap 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 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 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; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/BaseOutputAdapter.java b/src/main/java/com/jagrosh/jmusicbot/commands/BaseOutputAdapter.java new file mode 100644 index 0000000..6f0a981 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/BaseOutputAdapter.java @@ -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 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 + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/MusicCommandValidator.java b/src/main/java/com/jagrosh/jmusicbot/commands/MusicCommandValidator.java new file mode 100644 index 0000000..df8d39e --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/MusicCommandValidator.java @@ -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; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/SlashCommandRegistry.java b/src/main/java/com/jagrosh/jmusicbot/commands/SlashCommandRegistry.java new file mode 100644 index 0000000..84bfe29 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/SlashCommandRegistry.java @@ -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 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 = 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 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 = 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 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()); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/AdminCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/AdminCommand.java new file mode 100644 index 0000000..fa9f8b5 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/AdminCommand.java @@ -0,0 +1,39 @@ +/* + * Copyright 2018 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. + */ +package com.jagrosh.jmusicbot.commands.v1; + +import com.jagrosh.jdautilities.command.Command; +import net.dv8tion.jda.api.Permission; + +/** + * + * @author John Grosh (john.a.grosh@gmail.com) + */ +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; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/CommandFactory.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/CommandFactory.java new file mode 100644 index 0000000..39e6968 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/CommandFactory.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/DJCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/DJCommand.java new file mode 100644 index 0000000..2e54fb4 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/DJCommand.java @@ -0,0 +1,63 @@ +/* + * Copyright 2018 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. + */ +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 (john.a.grosh@gmail.com) + */ +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()); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/MusicCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/MusicCommand.java new file mode 100644 index 0000000..4037906 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/MusicCommand.java @@ -0,0 +1,107 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/OwnerCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/OwnerCommand.java new file mode 100644 index 0000000..cdcea01 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/OwnerCommand.java @@ -0,0 +1,31 @@ +/* + * Copyright 2018 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. + */ +package com.jagrosh.jmusicbot.commands.v1; + +import com.jagrosh.jdautilities.command.Command; + +/** + * + * @author John Grosh (john.a.grosh@gmail.com) + */ +public abstract class OwnerCommand extends Command +{ + public OwnerCommand() + { + this.category = new Category("Owner"); + this.ownerCommand = true; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/TextOutputAdapters.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/TextOutputAdapters.java new file mode 100644 index 0000000..c1b9fca --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/TextOutputAdapters.java @@ -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(" ` - plays the first result from Youtube"); + builder.append("\n`").append(event.getClient().getPrefix()).append(commandName).append(" ` - 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 onSuccess) + { + message.editMessage(content).queue(onSuccess); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/NpbuttonsCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/NpbuttonsCmd.java new file mode 100644 index 0000000..a220b6b --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/NpbuttonsCmd.java @@ -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 = ""; + 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") + "`)."); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/NplayoutCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/NplayoutCmd.java new file mode 100644 index 0000000..3bd3008 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/NplayoutCmd.java @@ -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 = ""; + 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") + "`)."); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/PrefixCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/PrefixCmd.java new file mode 100644 index 0000000..ffe7290 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/PrefixCmd.java @@ -0,0 +1,58 @@ +/* + * Copyright 2018 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. + */ +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 (john.a.grosh@gmail.com) + */ +public class PrefixCmd extends AdminCommand +{ + public PrefixCmd(Bot bot) + { + this.name = "prefix"; + this.help = "sets a server-specific prefix"; + this.arguments = ""; + 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() + "*"); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/QueueTypeCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/QueueTypeCmd.java new file mode 100644 index 0000000..dce1584 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/QueueTypeCmd.java @@ -0,0 +1,75 @@ +/* + * Copyright 2022 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. + */ +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() + "`."); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetdjCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetdjCmd.java new file mode 100644 index 0000000..014434f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetdjCmd.java @@ -0,0 +1,71 @@ +/* + * Copyright 2018 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. + */ +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 + */ +public class SetdjCmd extends AdminCommand +{ + public SetdjCmd(Bot bot) + { + this.name = "setdj"; + this.help = "sets the DJ role for certain music commands"; + this.arguments = ""; + 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 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."); + } + } + } + +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SettcCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SettcCmd.java new file mode 100644 index 0000000..14a5a2c --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SettcCmd.java @@ -0,0 +1,71 @@ +/* + * Copyright 2018 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. + */ +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 + */ +public class SettcCmd extends AdminCommand +{ + public SettcCmd(Bot bot) + { + this.name = "settc"; + this.help = "sets the text channel for music commands"; + this.arguments = ""; + 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 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()+">"); + } + } + } + +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetvcCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetvcCmd.java new file mode 100644 index 0000000..1c4b602 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetvcCmd.java @@ -0,0 +1,70 @@ +/* + * Copyright 2018 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. + */ +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 + */ +public class SetvcCmd extends AdminCommand +{ + public SetvcCmd(Bot bot) + { + this.name = "setvc"; + this.help = "sets the voice channel for playing music"; + this.arguments = ""; + 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 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()); + } + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SkipratioCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SkipratioCmd.java new file mode 100644 index 0000000..06d1b17 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SkipratioCmd.java @@ -0,0 +1,57 @@ +/* + * Copyright 2018 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. + */ +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 (john.a.grosh@gmail.com) + */ +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."); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceRemoveCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceRemoveCmd.java new file mode 100644 index 0000000..4a827dd --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceRemoveCmd.java @@ -0,0 +1,114 @@ +/* + * Copyright 2019 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. + */ +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 = ""; + 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 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) + "."); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceskipCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceskipCmd.java new file mode 100644 index 0000000..df66a78 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceskipCmd.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016 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. + */ +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 + */ +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); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/MoveTrackCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/MoveTrackCmd.java new file mode 100644 index 0000000..e358046 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/MoveTrackCmd.java @@ -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 = " "; + 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 + "`."); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PauseCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PauseCmd.java new file mode 100644 index 0000000..3101dba --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PauseCmd.java @@ -0,0 +1,53 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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!"); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PlaynextCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PlaynextCmd.java new file mode 100644 index 0000000..fc45ead --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PlaynextCmd.java @@ -0,0 +1,63 @@ +/* + * Copyright 2018 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. + */ +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 (john.a.grosh@gmail.com) + */ +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 = ""; + 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))); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/RepeatCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/RepeatCmd.java new file mode 100644 index 0000000..7f41bfd --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/RepeatCmd.java @@ -0,0 +1,79 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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 */ } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/SkiptoCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/SkiptoCmd.java new file mode 100644 index 0000000..a4657c7 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/SkiptoCmd.java @@ -0,0 +1,69 @@ +/* + * Copyright 2016 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. + */ +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 + */ +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 = ""; + 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 + "**"); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/StopCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/StopCmd.java new file mode 100644 index 0000000..d055a12 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/StopCmd.java @@ -0,0 +1,47 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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."); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/VolumeCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/VolumeCmd.java new file mode 100644 index 0000000..99f4765 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/VolumeCmd.java @@ -0,0 +1,74 @@ +/* + * Copyright 2016 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. + */ +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 + */ +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 + "`"); + } + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/general/SettingsCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/general/SettingsCmd.java new file mode 100644 index 0000000..247d3df --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/general/SettingsCmd.java @@ -0,0 +1,101 @@ +/* + * 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. + */ +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 + */ +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(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/HistoryCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/HistoryCmd.java new file mode 100644 index 0000000..d98cbc3 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/HistoryCmd.java @@ -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 components = + HistorySlashCmd.buildHistoryComponents(page, totalPages, tracksOnPage, 0, userId); + + MessageCreateData message = new MessageCreateBuilder() + .addEmbeds(embed) + .setComponents(components) + .build(); + event.reply(message); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/LyricsCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/LyricsCmd.java new file mode 100644 index 0000000..2a47f7e --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/LyricsCmd.java @@ -0,0 +1,108 @@ +/* + * Copyright 2018 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. + */ +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 (john.a.grosh@gmail.com) + */ +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()); + }); + */ + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/NowPlayingCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/NowPlayingCmd.java new file mode 100644 index 0000000..18742cc --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/NowPlayingCmd.java @@ -0,0 +1,63 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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)); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlayCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlayCmd.java new file mode 100644 index 0000000..f5e1ac0 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlayCmd.java @@ -0,0 +1,131 @@ +/* + * Copyright 2016 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. + */ +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 + */ +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 = ""; + 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 = ""; + 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(); + }); + }); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlaylistsCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlaylistsCmd.java new file mode 100644 index 0000000..f510f46 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlaylistsCmd.java @@ -0,0 +1,71 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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 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); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/QueueCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/QueueCmd.java new file mode 100644 index 0000000..6745be1 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/QueueCmd.java @@ -0,0 +1,112 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/RemoveCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/RemoveCmd.java new file mode 100644 index 0000000..b3e3daf --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/RemoveCmd.java @@ -0,0 +1,73 @@ +/* + * Copyright 2016 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. + */ +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 + */ +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 = ""; + 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); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SCSearchCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SCSearchCmd.java new file mode 100644 index 0000000..37db828 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SCSearchCmd.java @@ -0,0 +1,34 @@ +/* + * Copyright 2016 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. + */ +package com.jagrosh.jmusicbot.commands.v1.music; + +import com.jagrosh.jmusicbot.Bot; + +/** + * + * @author John Grosh + */ +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); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SearchCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SearchCmd.java new file mode 100644 index 0000000..7a6c040 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SearchCmd.java @@ -0,0 +1,157 @@ +/* + * Copyright 2016 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. + */ +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 + */ +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 = ""; + 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. + * + * 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 = "[+ | -] |<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)); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/ShuffleCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/ShuffleCmd.java new file mode 100644 index 0000000..9f19f88 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/ShuffleCmd.java @@ -0,0 +1,59 @@ +/* + * Copyright 2018 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. + */ +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 + */ +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; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SkipCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SkipCmd.java new file mode 100644 index 0000000..34f444f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SkipCmd.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016 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. + */ +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 + */ +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)); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/AutoplaylistCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/AutoplaylistCmd.java new file mode 100644 index 0000000..98b7100 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/AutoplaylistCmd.java @@ -0,0 +1,68 @@ +/* + * Copyright 2016 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. + */ +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 + */ +public class AutoplaylistCmd extends OwnerCommand +{ + private final Bot bot; + + public AutoplaylistCmd(Bot bot) + { + this.bot = bot; + this.guildOnly = true; + this.name = "autoplaylist"; + this.arguments = ""; + 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+"`"); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ClearchannelCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ClearchannelCmd.java new file mode 100644 index 0000000..a03006d --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ClearchannelCmd.java @@ -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(); + } + }); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/DebugCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/DebugCmd.java new file mode 100644 index 0000000..6b92358 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/DebugCmd.java @@ -0,0 +1,309 @@ +/* + * 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. + */ +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 (john.a.grosh@gmail.com) + */ +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 enabledSources = config.getEnabledAudioSources(); + Set 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 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 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); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/EvalCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/EvalCmd.java new file mode 100644 index 0000000..de329c7 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/EvalCmd.java @@ -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+" ```"); + } + } + +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/PlaylistCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/PlaylistCmd.java new file mode 100644 index 0000000..ddf0d92 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/PlaylistCmd.java @@ -0,0 +1,224 @@ +/* + * Copyright 2016 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. + */ +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 + */ +public class PlaylistCmd extends OwnerCommand +{ + private final Bot bot; + public PlaylistCmd(Bot bot) + { + this.bot = bot; + this.guildOnly = false; + this.name = "playlist"; + this.arguments = ""; + 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 = ""; + 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 = ""; + 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 = " | | ..."; + 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 = ""; + 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 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()); + } + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetavatarCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetavatarCmd.java new file mode 100644 index 0000000..d0a852b --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetavatarCmd.java @@ -0,0 +1,69 @@ +/* + * 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. + */ +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 + */ +public class SetavatarCmd extends OwnerCommand +{ + public SetavatarCmd(Bot bot) + { + this.name = "setavatar"; + this.help = "sets the avatar of the bot"; + this.arguments = ""; + 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."); + } + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetgameCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetgameCmd.java new file mode 100644 index 0000000..a6a5fbb --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetgameCmd.java @@ -0,0 +1,151 @@ +/* + * 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. + */ +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 + */ +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 = " "; + 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 = ""; + 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!"); + } + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetnameCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetnameCmd.java new file mode 100644 index 0000000..35432db --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetnameCmd.java @@ -0,0 +1,56 @@ +/* + * Copyright 2018 John Grosh <john.a.grosh@gmail.com>. + * + * 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 <john.a.grosh@gmail.com> + */ +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!"); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetstatusCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetstatusCmd.java new file mode 100644 index 0000000..d9a2627 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetstatusCmd.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 John Grosh <john.a.grosh@gmail.com>. + * + * 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 <john.a.grosh@gmail.com> + */ +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!"); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ShutdownCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ShutdownCmd.java new file mode 100644 index 0000000..cb276ba --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ShutdownCmd.java @@ -0,0 +1,45 @@ +/* + * Copyright 2017 John Grosh <john.a.grosh@gmail.com>. + * + * 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 <john.a.grosh@gmail.com> + */ +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(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/AdminSlashCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/AdminSlashCommand.java new file mode 100644 index 0000000..cf9ce46 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/AdminSlashCommand.java @@ -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); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/DJSlashCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/DJSlashCommand.java new file mode 100644 index 0000000..741c3e7 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/DJSlashCommand.java @@ -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); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/MusicSlashCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/MusicSlashCommand.java new file mode 100644 index 0000000..06a2020 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/MusicSlashCommand.java @@ -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); +} \ No newline at end of file diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/OwnerSlashCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/OwnerSlashCommand.java new file mode 100644 index 0000000..38d652f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/OwnerSlashCommand.java @@ -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); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/SlashOutputAdapters.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/SlashOutputAdapters.java new file mode 100644 index 0000000..1f24b8e --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/SlashOutputAdapters.java @@ -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(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/ClearchannelSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/ClearchannelSlashCmd.java new file mode 100644 index 0000000..a1b9048 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/ClearchannelSlashCmd.java @@ -0,0 +1,79 @@ +/* + * 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.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.OwnerSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.utils.ChannelClearHelper; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +/** + * Owner-only slash command to clear all messages in the guild's configured text channel (set via /settc). + */ +public class ClearchannelSlashCmd extends OwnerSlashCommand +{ + public ClearchannelSlashCmd(Bot bot) + { + super(bot); + this.name = "clearchannel"; + this.help = "clears all messages in the command channel set with /settc (owner only)"; + } + + @Override + public void doOwnerCommand(SlashCommandEvent event) + { + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + TextChannel channel = settings.getTextChannel(event.getGuild()); + ChannelClearHelper.ClearPolicy clearPolicy = ChannelClearHelper.ClearPolicy.of( + bot.getConfig().getClearChannelDeleteLimit(), + bot.getConfig().getClearChannelAgeDays() + ); + + if (channel == null) + { + event.reply(event.getClient().getError() + " No text channel is configured for this server. An admin can set one with `/settc`.") + .setEphemeral(true).queue(); + return; + } + + if (!event.getGuild().getSelfMember().hasPermission(channel, Permission.MESSAGE_MANAGE)) + { + event.reply(event.getClient().getError() + " I don't have permission to manage messages in that channel.") + .setEphemeral(true).queue(); + return; + } + + event.deferReply(true).queue(hook -> + ChannelClearHelper.purgeChannel(channel, new ChannelClearHelper.PurgeCallback() + { + @Override + public void onCleared(int count) + { + hook.editOriginal("Cleared " + count + " messages.").queue(); + } + + @Override + public void onError(Throwable t) + { + String msg = t.getMessage() != null ? t.getMessage() : t.toString(); + hook.editOriginal(event.getClient().getError() + " Error: " + msg).queue(); + } + }, clearPolicy)); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/NpButtonsSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/NpButtonsSlashCmd.java new file mode 100644 index 0000000..0299218 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/NpButtonsSlashCmd.java @@ -0,0 +1,47 @@ +package com.jagrosh.jmusicbot.commands.v2.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.NowPlayingButtonsMode; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +public class NpButtonsSlashCmd extends AdminSlashCommand +{ + public NpButtonsSlashCmd(Bot bot) + { + super(bot); + this.name = "npbuttons"; + this.help = "sets whether now-playing messages include interactive buttons for this server"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "mode", "on, off, or inherit", true) + .addChoice("on", "on") + .addChoice("off", "off") + .addChoice("inherit", "inherit") + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + String value = event.getOption("mode").getAsString(); + NowPlayingButtonsMode mode = NowPlayingButtonsMode.fromInput(value); + if (mode == null) + { + event.reply(event.getClient().getError() + " Valid values are `on`, `off`, or `inherit`.") + .setEphemeral(true).queue(); + return; + } + + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + settings.setNowPlayingButtonsMode(mode); + boolean effectiveButtons = settings.showNowPlayingButtons(bot.getConfig()); + event.reply(event.getClient().getSuccess() + " Now-playing buttons mode set to `" + + mode.getUserInputValue() + "` (effective: `" + (effectiveButtons ? "on" : "off") + "`)").queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/NpLayoutSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/NpLayoutSlashCmd.java new file mode 100644 index 0000000..f81b682 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/NpLayoutSlashCmd.java @@ -0,0 +1,47 @@ +package com.jagrosh.jmusicbot.commands.v2.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.NowPlayingLayoutMode; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +public class NpLayoutSlashCmd extends AdminSlashCommand +{ + public NpLayoutSlashCmd(Bot bot) + { + super(bot); + this.name = "nplayout"; + this.help = "sets the now-playing layout mode for this server"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "mode", "full, minimal, or inherit", true) + .addChoice("full", "full") + .addChoice("minimal", "minimal") + .addChoice("inherit", "inherit") + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + String value = event.getOption("mode").getAsString(); + NowPlayingLayoutMode mode = NowPlayingLayoutMode.fromInput(value); + if (mode == null) + { + event.reply(event.getClient().getError() + " Valid values are `full`, `minimal`, or `inherit`.") + .setEphemeral(true).queue(); + return; + } + + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + settings.setNowPlayingLayoutMode(mode); + boolean effectiveMinimal = settings.useMinimalNowPlayingMessage(bot.getConfig()); + event.reply(event.getClient().getSuccess() + " Now-playing layout mode set to `" + + mode.getUserInputValue() + "` (effective: `" + (effectiveMinimal ? "minimal" : "full") + "`)").queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/PrefixSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/PrefixSlashCmd.java new file mode 100644 index 0000000..5dfec7e --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/PrefixSlashCmd.java @@ -0,0 +1,60 @@ +/* + * 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.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Admin slash command to set a server-specific prefix. + */ +public class PrefixSlashCmd extends AdminSlashCommand +{ + public PrefixSlashCmd(Bot bot) + { + super(bot); + this.name = "prefix"; + this.help = "sets a server-specific prefix"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "prefix", "the new prefix (leave empty to clear)", false) + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + + if (event.getOption("prefix") == null) + { + settings.setPrefix(null); + event.reply(event.getClient().getSuccess() + " Prefix cleared.").queue(); + } + else + { + String prefix = event.getOption("prefix").getAsString(); + settings.setPrefix(prefix); + event.reply(event.getClient().getSuccess() + " Custom prefix set to `" + prefix + "` on *" + event.getGuild().getName() + "*").queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/QueuetypeSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/QueuetypeSlashCmd.java new file mode 100644 index 0000000..9f39ea6 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/QueuetypeSlashCmd.java @@ -0,0 +1,83 @@ +/* + * 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.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Admin slash command to change the queue type. + */ +public class QueuetypeSlashCmd extends AdminSlashCommand +{ + public QueuetypeSlashCmd(Bot bot) + { + super(bot); + this.name = "queuetype"; + this.help = "changes the queue type"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "type", "queue type", false) + .addChoice("linear", "LINEAR") + .addChoice("fair", "FAIR") + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + + if (event.getOption("type") == null) + { + QueueType currentType = settings.getQueueType(); + event.reply(currentType.getEmoji() + " Current queue type is: `" + currentType.getUserFriendlyName() + "`.").queue(); + return; + } + + String typeArg = event.getOption("type").getAsString(); + QueueType value; + try + { + value = QueueType.valueOf(typeArg.toUpperCase()); + } + catch (IllegalArgumentException e) + { + event.reply(event.getClient().getError() + " Invalid queue type. Valid types are: linear, fair") + .setEphemeral(true).queue(); + 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() + "`.").queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SetdjSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SetdjSlashCmd.java new file mode 100644 index 0000000..a5ebcd1 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SetdjSlashCmd.java @@ -0,0 +1,61 @@ +/* + * 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.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Admin slash command to set the DJ role. + */ +public class SetdjSlashCmd extends AdminSlashCommand +{ + public SetdjSlashCmd(Bot bot) + { + super(bot); + this.name = "setdj"; + this.help = "sets the DJ role for certain music commands"; + this.options = Collections.singletonList( + new OptionData(OptionType.ROLE, "role", "the DJ role (leave empty to clear)", false) + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + + if (event.getOption("role") == null) + { + settings.setDJRole(null); + event.reply(event.getClient().getSuccess() + " DJ role cleared; Only Admins can use the DJ commands.").queue(); + } + else + { + Role role = event.getOption("role").getAsRole(); + settings.setDJRole(role); + event.reply(event.getClient().getSuccess() + " DJ commands can now be used by users with the **" + role.getName() + "** role.").queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettcSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettcSlashCmd.java new file mode 100644 index 0000000..7aa8204 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettcSlashCmd.java @@ -0,0 +1,69 @@ +/* + * 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.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Admin slash command to set the text channel for music commands. + */ +public class SettcSlashCmd extends AdminSlashCommand +{ + public SettcSlashCmd(Bot bot) + { + super(bot); + this.name = "settc"; + this.help = "sets the text channel for music commands"; + this.options = Collections.singletonList( + new OptionData(OptionType.CHANNEL, "channel", "the text channel for music commands (leave empty to clear)", false) + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + + if (event.getOption("channel") == null) + { + settings.setTextChannel(null); + event.reply(event.getClient().getSuccess() + " Music commands can now be used in any channel").queue(); + } + else + { + GuildChannel channel = event.getOption("channel").getAsChannel(); + if (!(channel instanceof TextChannel)) + { + event.reply(event.getClient().getError() + " Please select a text channel!") + .setEphemeral(true).queue(); + return; + } + TextChannel textChannel = (TextChannel) channel; + settings.setTextChannel(textChannel); + event.reply(event.getClient().getSuccess() + " Music commands can now only be used in " + textChannel.getAsMention()).queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettingsPanelRenderer.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettingsPanelRenderer.java new file mode 100644 index 0000000..093019f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettingsPanelRenderer.java @@ -0,0 +1,326 @@ +package com.jagrosh.jmusicbot.commands.v2.admin; + +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.utils.FormatUtil; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.MessageTopLevelComponent; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.components.container.ContainerChildComponent; +import net.dv8tion.jda.api.components.container.Container; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.components.section.Section; +import net.dv8tion.jda.api.components.separator.Separator; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; +import net.dv8tion.jda.api.components.textdisplay.TextDisplay; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.channel.ChannelType; +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.MessageChannel; +import net.dv8tion.jda.api.components.selections.EntitySelectMenu; +import net.dv8tion.jda.api.components.selections.EntitySelectMenu.SelectTarget; +import net.dv8tion.jda.api.modals.Modal; + +import java.util.List; +import java.util.ArrayList; + +public final class SettingsPanelRenderer +{ + public static final String ACTION_ENUM = "enum"; + public static final String ACTION_TOGGLE = "toggle"; + public static final String ACTION_OPEN = "open"; + public static final String ACTION_CLEAR = "clear"; + public static final String ACTION_REFRESH = "refresh"; + public static final String ACTION_CLOSE = "close"; + + private SettingsPanelRenderer() + { + } + + public static String buttonId(String action, String key, String value, long userId) + { + if (value == null || value.isBlank()) + return "settings_" + action + "_" + key + "_" + userId; + return "settings_" + action + "_" + key + "_" + value + "_" + userId; + } + + public static String modalId(String key, long userId) + { + return "settings_modal_" + key + "_" + userId; + } + + public static String entitySelectId(String key, long userId) + { + return "settings_entity_" + key + "_" + userId; + } + + public static String entitySelectId(String key, long originalPanelMessageId, long userId) + { + return "settings_entity_" + key + "_" + originalPanelMessageId + "_" + userId; + } + + public static net.dv8tion.jda.api.entities.MessageEmbed buildSettingsEmbed( + Guild guild, Settings settings, BotConfig config, java.awt.Color color) + { + return buildSettingsEmbed(guild, settings, config, color, null); + } + + public static net.dv8tion.jda.api.entities.MessageEmbed buildSettingsEmbed( + Guild guild, Settings settings, BotConfig config, java.awt.Color color, String invokerName) + { + TextChannel textChannel = settings.getTextChannel(guild); + VoiceChannel voiceChannel = settings.getVoiceChannel(guild); + Role djRole = settings.getRole(guild); + + String prefix = settings.getPrefix() == null ? "None" : "`" + settings.getPrefix() + "`"; + int skipRatio = (int) Math.round(settings.getSkipRatio() * 100); + String skipRatioDisplay = settings.getSkipRatio() < 0 + ? "Default (`55%`)" + : "`" + skipRatio + "%`"; + String textChannelDisplay = textChannel == null ? "Any" : textChannel.getAsMention(); + String voiceChannelDisplay = voiceChannel == null ? "Any" : voiceChannel.getAsMention(); + String djRoleDisplay = djRole == null ? "None" : "**" + djRole.getName() + "**"; + + boolean effectiveMinimal = settings.useMinimalNowPlayingMessage(config); + boolean effectiveButtons = settings.showNowPlayingButtons(config); + String nowPlayingLayout = "`" + settings.getNowPlayingLayoutMode().getUserInputValue() + + "` (effective: `" + (effectiveMinimal ? "minimal" : "full") + "`)"; + String nowPlayingButtons = "`" + settings.getNowPlayingButtonsMode().getUserInputValue() + + "` (effective: `" + (effectiveButtons ? "on" : "off") + "`)"; + String nowPlayingValue = "Layout: " + nowPlayingLayout + "\nButtons: " + nowPlayingButtons; + + String footer = invokerName != null && !invokerName.isBlank() + ? "Only " + invokerName + " can use these controls." + : "Only the command invoker can use these controls."; + + return new EmbedBuilder() + .setTitle("MusicBot Settings") + .setColor(color) + .setDescription("Settings panel for **" + guild.getName() + "**.\n" + + "Use the buttons below to update values.") + .addField("Text Channel", textChannelDisplay, true) + .addField("Voice Channel", voiceChannelDisplay, true) + .addField("DJ Role", djRoleDisplay, true) + .addField("Prefix", prefix, true) + .addField("Skip Ratio", skipRatioDisplay, true) + .addField("Queue Type", "`" + settings.getQueueType().getUserFriendlyName() + "`", true) + .addField("Now-Playing", nowPlayingValue, false) + .setFooter(footer) + .build(); + } + + public static List<ActionRow> buildSettingsComponents(Settings settings, long userId) + { + Button queueToggle = toggleButton("queue", "Toggle Queue", userId); + Button layoutToggle = toggleButton("layout", "Toggle Layout", userId); + Button npButtonsToggle = toggleButton("npbuttons", "Toggle Buttons", userId); + + Button setTextChannel = Button.secondary(buttonId(ACTION_OPEN, "settc", null, userId), "Set Text Channel"); + Button setVoiceChannel = Button.secondary(buttonId(ACTION_OPEN, "setvc", null, userId), "Set Voice Channel"); + Button setDjRole = Button.secondary(buttonId(ACTION_OPEN, "setdj", null, userId), "Set DJ Role"); + Button setPrefix = Button.secondary(buttonId(ACTION_OPEN, "prefix", null, userId), "Set Prefix"); + Button setSkip = Button.secondary(buttonId(ACTION_OPEN, "setskip", null, userId), "Set Skip %"); + + Button close = Button.danger(buttonId(ACTION_CLOSE, "main", null, userId), "Close"); + + return List.of( + ActionRow.of(queueToggle), + ActionRow.of(layoutToggle, npButtonsToggle), + ActionRow.of(setTextChannel, setVoiceChannel, setDjRole, setPrefix, setSkip), + ActionRow.of(close) + ); + } + + public static List<MessageTopLevelComponent> buildSettingsMessageComponents( + Guild guild, Settings settings, BotConfig config, long userId, String invokerName) + { + List<ContainerChildComponent> children = new ArrayList<>(buildSettingsDisplayChildren(guild, settings, config, invokerName, userId)); + return List.of(Container.of(children)); + } + + public static Modal buildModal(String key, long userId) + { + return switch (key) + { + case "prefix" -> buildSingleInputModal( + modalId("prefix", userId), + "Set prefix", + "prefix_value", + "Custom prefix (empty clears)" + ); + case "setskip" -> buildSingleInputModal( + modalId("setskip", userId), + "Set skip percentage", + "setskip_value", + "Integer from 0 to 100" + ); + default -> null; + }; + } + + public static EntitySelectMenu buildEntitySelectMenu(String key, long userId) + { + return buildEntitySelectMenu(key, userId, -1L); + } + + public static EntitySelectMenu buildEntitySelectMenu(String key, long userId, long originalPanelMessageId) + { + String id = originalPanelMessageId > 0 + ? entitySelectId(key, originalPanelMessageId, userId) + : entitySelectId(key, userId); + return switch (key) + { + case "settc" -> EntitySelectMenu.create(id, SelectTarget.CHANNEL) + .setChannelTypes(ChannelType.TEXT) + .setPlaceholder("Choose a text channel") + .setRequiredRange(1, 1) + .build(); + case "setvc" -> EntitySelectMenu.create(id, SelectTarget.CHANNEL) + .setChannelTypes(ChannelType.VOICE) + .setPlaceholder("Choose a voice channel") + .setRequiredRange(1, 1) + .build(); + case "setdj" -> EntitySelectMenu.create(id, SelectTarget.ROLE) + .setPlaceholder("Choose the DJ role") + .setRequiredRange(1, 1) + .build(); + default -> null; + }; + } + + public static Button buildEntityClearButton(String key, long userId) + { + return buildEntityClearButton(key, userId, -1L); + } + + public static Button buildEntityClearButton(String key, long userId, long originalPanelMessageId) + { + String value = originalPanelMessageId > 0 ? String.valueOf(originalPanelMessageId) : null; + return switch (key) + { + case "settc", "setvc" -> Button.secondary(buttonId(ACTION_CLEAR, key, value, userId), "Any"); + case "setdj" -> Button.secondary(buttonId(ACTION_CLEAR, key, value, userId), "None"); + default -> null; + }; + } + + private static Modal buildSingleInputModal(String modalId, String title, String inputId, String placeholder) + { + TextInput input = TextInput.create(inputId, TextInputStyle.SHORT) + .setRequired(false) + .setPlaceholder(placeholder) + .setMaxLength(100) + .build(); + return Modal.create(modalId, title) + .addComponents(Label.of("Value", input)) + .build(); + } + + private static Button toggleButton(String key, String label, long userId) + { + String id = buttonId(ACTION_TOGGLE, key, null, userId); + return Button.secondary(id, label); + } + + public static String modalValue(net.dv8tion.jda.api.events.interaction.ModalInteractionEvent event, String inputId) + { + return event.getValues().stream() + .filter(m -> inputId.equals(m.getCustomId())) + .findFirst() + .map(net.dv8tion.jda.api.interactions.modals.ModalMapping::getAsString) + .orElse("") + .trim(); + } + + public static MessageChannel asMessageChannel(net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent event) + { + return event.getChannel(); + } + + private static List<ContainerChildComponent> buildSettingsDisplayChildren( + Guild guild, Settings settings, BotConfig config, String invokerName, long userId) + { + TextChannel textChannel = settings.getTextChannel(guild); + VoiceChannel voiceChannel = settings.getVoiceChannel(guild); + Role djRole = settings.getRole(guild); + + String prefix = settings.getPrefix() == null ? "`None`" : "`" + settings.getPrefix() + "`"; + int skipRatio = (int) Math.round(settings.getSkipRatio() * 100); + String skipRatioDisplay = settings.getSkipRatio() < 0 ? "Default (`55%`)" : "`" + skipRatio + "%`"; + String textChannelDisplay = textChannel == null ? "Any" : textChannel.getAsMention(); + String voiceChannelDisplay = voiceChannel == null ? "Any" : voiceChannel.getAsMention(); + String djRoleDisplay = djRole == null ? "None" : "**" + FormatUtil.filter(djRole.getName()) + "**"; + + boolean effectiveMinimal = settings.useMinimalNowPlayingMessage(config); + boolean effectiveButtons = settings.showNowPlayingButtons(config); + String nowPlayingLayout = "`" + settings.getNowPlayingLayoutMode().getUserInputValue() + + "` (effective: `" + (effectiveMinimal ? "minimal" : "full") + "`)"; + String nowPlayingButtons = "`" + settings.getNowPlayingButtonsMode().getUserInputValue() + + "` (effective: `" + (effectiveButtons ? "on" : "off") + "`)"; + String queueType = "`" + settings.getQueueType().getUserFriendlyName() + "`"; + + String footer = invokerName != null && !invokerName.isBlank() + ? "Only **" + FormatUtil.filter(invokerName) + "** can use these controls." + : "Only the command invoker can use these controls."; + + Button queueToggle = toggleButton("queue", "Toggle Queue", userId); + Button layoutToggle = toggleButton("layout", "Toggle Layout", userId); + Button npButtonsToggle = toggleButton("npbuttons", "Toggle Buttons", userId); + Button setPrefix = Button.secondary(buttonId(ACTION_OPEN, "prefix", null, userId), "Set Prefix"); + Button setSkip = Button.secondary(buttonId(ACTION_OPEN, "setskip", null, userId), "Set Skip %"); + Button close = Button.danger(buttonId(ACTION_CLOSE, "main", null, userId), "Close"); + Button clearText = Button.secondary(buttonId(ACTION_CLEAR, "settc", null, userId), "Clear Text"); + Button clearVoice = Button.secondary(buttonId(ACTION_CLEAR, "setvc", null, userId), "Clear Voice"); + Button clearDj = Button.secondary(buttonId(ACTION_CLEAR, "setdj", null, userId), "Clear DJ"); + + EntitySelectMenu textChannelSelect = buildEntitySelectMenu("settc", userId); + EntitySelectMenu voiceChannelSelect = buildEntitySelectMenu("setvc", userId); + EntitySelectMenu djRoleSelect = buildEntitySelectMenu("setdj", userId); + + return List.of( + TextDisplay.of("## MusicBot Settings"), + TextDisplay.of("Interactive settings panel for **" + FormatUtil.filter(guild.getName()) + "**.\n" + + footer), + Separator.createDivider(Separator.Spacing.SMALL), + Section.of( + clearText, + TextDisplay.of("Text Channel: " + textChannelDisplay) + ), + ActionRow.of(textChannelSelect), + Section.of( + clearVoice, + TextDisplay.of("Voice Channel: " + voiceChannelDisplay) + ), + ActionRow.of(voiceChannelSelect), + Section.of( + clearDj, + TextDisplay.of("DJ Role: " + djRoleDisplay) + ), + ActionRow.of(djRoleSelect), + Separator.createDivider(Separator.Spacing.SMALL), + TextDisplay.of("Prefix: " + prefix + + "\nSkip Ratio: " + skipRatioDisplay), + ActionRow.of(setPrefix, setSkip), + Separator.createDivider(Separator.Spacing.SMALL), + Section.of( + queueToggle, + TextDisplay.of("Queue Type: " + queueType) + ), + Section.of( + layoutToggle, + TextDisplay.of("Layout: " + nowPlayingLayout + ) + ), + Section.of( + npButtonsToggle, + TextDisplay.of("Buttons: " + nowPlayingButtons) + ), + Separator.createDivider(Separator.Spacing.SMALL), + ActionRow.of(close) + ); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettingsSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettingsSlashCmd.java new file mode 100644 index 0000000..c5ed8f0 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SettingsSlashCmd.java @@ -0,0 +1,33 @@ +package com.jagrosh.jmusicbot.commands.v2.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; + +public class SettingsSlashCmd extends AdminSlashCommand +{ + public SettingsSlashCmd(Bot bot) + { + super(bot); + this.name = "settings"; + this.help = "opens an interactive panel for server settings"; + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + Settings settings = bot.getSettingsManager().getSettings(event.getGuild()); + long userId = event.getUser().getIdLong(); + String invokerName = event.getMember() != null ? event.getMember().getEffectiveName() : event.getUser().getName(); + event.reply(new MessageCreateBuilder() + .setComponents(SettingsPanelRenderer.buildSettingsMessageComponents( + event.getGuild(), settings, bot.getConfig(), userId, invokerName)) + .useComponentsV2() + .build()) + .setEphemeral(true) + .queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SetvcSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SetvcSlashCmd.java new file mode 100644 index 0000000..d38ef50 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SetvcSlashCmd.java @@ -0,0 +1,70 @@ +/* + * 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.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Admin slash command to set the voice channel for playing music. + */ +public class SetvcSlashCmd extends AdminSlashCommand +{ + public SetvcSlashCmd(Bot bot) + { + super(bot); + this.name = "setvc"; + this.help = "sets the voice channel for playing music"; + this.options = Collections.singletonList( + new OptionData(OptionType.CHANNEL, "channel", "the voice channel for music (leave empty to clear)", false) + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + + if (event.getOption("channel") == null) + { + settings.setVoiceChannel(null); + event.reply(event.getClient().getSuccess() + " Music can now be played in any channel").queue(); + } + else + { + GuildChannel channel = event.getOption("channel").getAsChannel(); + if (!(channel instanceof AudioChannel)) + { + event.reply(event.getClient().getError() + " Please select a voice channel!") + .setEphemeral(true).queue(); + return; + } + VoiceChannel voiceChannel = (VoiceChannel) channel; + settings.setVoiceChannel(voiceChannel); + event.reply(event.getClient().getSuccess() + " Music can now only be played in " + voiceChannel.getAsMention()).queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SkipratioSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SkipratioSlashCmd.java new file mode 100644 index 0000000..a305f5c --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SkipratioSlashCmd.java @@ -0,0 +1,55 @@ +/* + * 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.admin; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.AdminSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Admin slash command to set the skip vote percentage. + */ +public class SkipratioSlashCmd extends AdminSlashCommand +{ + public SkipratioSlashCmd(Bot bot) + { + super(bot); + this.name = "setskip"; + this.help = "sets a server-specific skip percentage"; + this.options = Collections.singletonList( + new OptionData(OptionType.INTEGER, "percentage", "skip percentage (0-100)", true) + .setMinValue(0) + .setMaxValue(100) + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doAdminCommand(SlashCommandEvent event) + { + int value = (int) event.getOption("percentage").getAsLong(); + + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + settings.setSkipRatio(value / 100.0); + + event.reply(event.getClient().getSuccess() + " Skip percentage has been set to `" + value + "%` of listeners on *" + event.getGuild().getName() + "*").queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/ForceremoveSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/ForceremoveSlashCmd.java new file mode 100644 index 0000000..6ff3549 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/ForceremoveSlashCmd.java @@ -0,0 +1,73 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.utils.FormatUtil; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * DJ slash command to remove all entries by a user from the queue. + */ +public class ForceremoveSlashCmd extends DJSlashCommand +{ + private final MusicService musicService; + + public ForceremoveSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.name = "forceremove"; + this.help = "removes all entries by a user from the queue"; + this.options = Collections.singletonList( + new OptionData(OptionType.USER, "user", "the user whose entries to remove", true) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.bePlaying = true; + } + + @Override + public void doDJCommand(SlashCommandEvent event) + { + if (musicService.isQueueEmpty(event.getGuild())) + { + event.reply(event.getClient().getError() + " There is nothing in the queue!") + .setEphemeral(true).queue(); + return; + } + + User target = event.getOption("user").getAsUser(); + int count = musicService.removeAllTracksByUser(event.getGuild(), target.getIdLong()); + + if (count == 0) + { + event.reply(event.getClient().getWarning() + " **" + target.getName() + "** doesn't have any songs in the queue!") + .setEphemeral(true).queue(); + } + else + { + event.reply(event.getClient().getSuccess() + " Successfully removed `" + count + "` entries from " + FormatUtil.formatUsername(target) + ".") + .queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/ForceskipSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/ForceskipSlashCmd.java new file mode 100644 index 0000000..14893f3 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/ForceskipSlashCmd.java @@ -0,0 +1,53 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.service.MusicService; + +/** + * DJ slash command to force skip the current song. + */ +public class ForceskipSlashCmd extends DJSlashCommand +{ + private final MusicService musicService; + + public ForceskipSlashCmd(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 doDJCommand(SlashCommandEvent event) + { + MusicService.ForceSkipResult result = musicService.forceSkip(event.getGuild()); + if (result != null) + { + event.reply(event.getClient().getSuccess() + " Skipped **" + result.trackTitle + "** " + result.requesterInfo).queue(); + } + else + { + event.reply(event.getClient().getWarning() + " Nothing is currently playing!").setEphemeral(true).queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/MovetrackSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/MovetrackSlashCmd.java new file mode 100644 index 0000000..2598e88 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/MovetrackSlashCmd.java @@ -0,0 +1,81 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Arrays; + +/** + * DJ slash command to move a track in the queue. + */ +public class MovetrackSlashCmd extends DJSlashCommand +{ + private final MusicService musicService; + + public MovetrackSlashCmd(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.options = Arrays.asList( + new OptionData(OptionType.INTEGER, "from", "current position of the track", true).setMinValue(1), + new OptionData(OptionType.INTEGER, "to", "new position for the track", true).setMinValue(1) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.bePlaying = true; + } + + @Override + public void doDJCommand(SlashCommandEvent event) + { + int from = (int) event.getOption("from").getAsLong(); + int to = (int) event.getOption("to").getAsLong(); + + if (from == to) + { + event.reply(event.getClient().getError() + " Can't move a track to the same position.") + .setEphemeral(true).queue(); + return; + } + + if (!musicService.isValidQueuePosition(event.getGuild(), from)) + { + event.reply(event.getClient().getError() + " `" + from + "` is not a valid position in the queue!") + .setEphemeral(true).queue(); + return; + } + if (!musicService.isValidQueuePosition(event.getGuild(), to)) + { + event.reply(event.getClient().getError() + " `" + to + "` is not a valid position in the queue!") + .setEphemeral(true).queue(); + return; + } + + String trackTitle = musicService.moveTrackPosition(event.getGuild(), from, to); + if (trackTitle != null) + { + event.reply(event.getClient().getSuccess() + " Moved **" + trackTitle + "** from position `" + from + "` to `" + to + "`.") + .queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/PauseSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/PauseSlashCmd.java new file mode 100644 index 0000000..ee9bd05 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/PauseSlashCmd.java @@ -0,0 +1,55 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.utils.FormatUtil; + +/** + * Slash command to pause the current song. + */ +public class PauseSlashCmd extends DJSlashCommand +{ + public PauseSlashCmd(Bot bot) + { + super(bot); + this.name = "pause"; + this.help = "pauses the current song"; + this.aliases = bot.getConfig().getAliases(this.name); + this.bePlaying = true; + } + + @Override + public void doDJCommand(SlashCommandEvent event) + { + AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); + + if (handler.getPlayer().isPaused()) + { + event.reply(event.getClient().getWarning() + " The player is already paused! Use `/play` to unpause!") + .setEphemeral(true).queue(); + return; + } + + handler.getPlayer().setPaused(true); + String trackTitle = FormatUtil.getTrackTitle(handler.getPlayer().getPlayingTrack()); + event.reply(event.getClient().getSuccess() + " Paused **" + trackTitle + + "**. Use `/play` to unpause!").queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/PlaynextSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/PlaynextSlashCmd.java new file mode 100644 index 0000000..af48306 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/PlaynextSlashCmd.java @@ -0,0 +1,62 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters.InteractionHookOutputAdapter; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * DJ slash command to play a single song next in the queue. + */ +public class PlaynextSlashCmd extends DJSlashCommand +{ + private final MusicService musicService; + private final String loadingEmoji; + + public PlaynextSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.loadingEmoji = bot.getConfig().getLoading(); + this.name = "playnext"; + this.help = "plays a single song next"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "query", "song title or URL", true) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.beListening = true; + this.bePlaying = false; + } + + @Override + public void doDJCommand(SlashCommandEvent event) + { + String query = event.getOption("query").getAsString(); + + event.reply(loadingEmoji + " Loading... `[" + query + "]`").queue(hook -> + { + musicService.playNext(event.getGuild(), event.getMember(), query, event.getTextChannel(), + new InteractionHookOutputAdapter(hook, event.getJDA(), event.getClient().getWarning())); + }); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/RepeatSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/RepeatSlashCmd.java new file mode 100644 index 0000000..b415150 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/RepeatSlashCmd.java @@ -0,0 +1,85 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * DJ slash command to set the repeat mode. + */ +public class RepeatSlashCmd extends DJSlashCommand +{ + private final MusicService musicService; + + public RepeatSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.name = "repeat"; + this.help = "re-adds music to the queue when finished"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "mode", "repeat mode", false) + .addChoice("off", "off") + .addChoice("all", "all") + .addChoice("single", "single") + ); + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doDJCommand(SlashCommandEvent event) + { + RepeatMode currentMode = musicService.getRepeatMode(event.getGuild()); + RepeatMode newMode; + + if (event.getOption("mode") == null) + { + // Toggle between off and all + newMode = (currentMode == RepeatMode.OFF) ? RepeatMode.ALL : RepeatMode.OFF; + } + else + { + String modeArg = event.getOption("mode").getAsString(); + switch (modeArg.toLowerCase()) + { + case "off": + newMode = RepeatMode.OFF; + break; + case "all": + newMode = RepeatMode.ALL; + break; + case "single": + newMode = RepeatMode.SINGLE; + break; + default: + event.reply(event.getClient().getError() + " Valid options are `off`, `all` or `single`") + .setEphemeral(true).queue(); + return; + } + } + + musicService.setRepeatMode(event.getGuild(), newMode); + event.reply(event.getClient().getSuccess() + " Repeat mode is now `" + newMode.getUserFriendlyName() + "`").queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/SkiptoSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/SkiptoSlashCmd.java new file mode 100644 index 0000000..af7c0a9 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/SkiptoSlashCmd.java @@ -0,0 +1,67 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * DJ slash command to skip to a specific position in the queue. + */ +public class SkiptoSlashCmd extends DJSlashCommand +{ + private final MusicService musicService; + + public SkiptoSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.name = "skipto"; + this.help = "skips to the specified song"; + this.options = Collections.singletonList( + new OptionData(OptionType.INTEGER, "position", "queue position to skip to", true) + .setMinValue(1) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.bePlaying = true; + } + + @Override + public void doDJCommand(SlashCommandEvent event) + { + int position = (int) event.getOption("position").getAsLong(); + int queueSize = musicService.getQueueSize(event.getGuild()); + + if (position < 1 || position > queueSize) + { + event.reply(event.getClient().getError() + " Position must be a valid integer between 1 and " + queueSize + "!") + .setEphemeral(true).queue(); + return; + } + + String trackTitle = musicService.skipToPosition(event.getGuild(), position); + if (trackTitle != null) + { + event.reply(event.getClient().getSuccess() + " Skipped to **" + trackTitle + "**").queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/StopSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/StopSlashCmd.java new file mode 100644 index 0000000..dd3a142 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/StopSlashCmd.java @@ -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.commands.v2.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; + +/** + * Slash command to stop the player and clear the queue. + */ +public class StopSlashCmd extends DJSlashCommand +{ + public StopSlashCmd(Bot bot) + { + super(bot); + 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 doDJCommand(SlashCommandEvent event) + { + bot.getMusicService().stopAndClear(event.getGuild()); + event.reply(event.getClient().getSuccess() + " The player has stopped and the queue has been cleared.").queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/VolumeSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/VolumeSlashCmd.java new file mode 100644 index 0000000..cebfce8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/VolumeSlashCmd.java @@ -0,0 +1,67 @@ +/* + * 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.dj; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.utils.FormatUtil; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Slash command to get or set the player volume. + */ +public class VolumeSlashCmd extends DJSlashCommand +{ + public VolumeSlashCmd(Bot bot) + { + super(bot); + this.name = "volume"; + this.help = "sets or shows the player volume"; + this.aliases = bot.getConfig().getAliases(this.name); + this.options = Collections.singletonList( + new OptionData(OptionType.INTEGER, "level", "Volume level (0-150)", false) + .setMinValue(0) + .setMaxValue(150) + ); + } + + @Override + public void doDJCommand(SlashCommandEvent event) + { + AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); + Settings settings = event.getClient().getSettingsFor(event.getGuild()); + int currentVolume = handler.getPlayer().getVolume(); + + if (event.getOption("level") == null) + { + // Show current volume + event.reply(FormatUtil.volumeIcon(currentVolume) + " Current volume is `" + currentVolume + "`").queue(); + } + else + { + int newVolume = (int) event.getOption("level").getAsLong(); + handler.getPlayer().setVolume(newVolume); + settings.setVolume(newVolume); + event.reply(FormatUtil.volumeIcon(newVolume) + " Volume changed from `" + currentVolume + "` to `" + newVolume + "`").queue(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/HistorySlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/HistorySlashCmd.java new file mode 100644 index 0000000..ba7db5d --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/HistorySlashCmd.java @@ -0,0 +1,168 @@ +/* + * 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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.listener.interaction.PaginatedListComponents; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.utils.PaginatedListEmbedUtil; +import com.jagrosh.jmusicbot.utils.TimeUtil; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Slash command to show playback history (recently played tracks) with actions to queue, play, or save as playlist. + */ +public class HistorySlashCmd extends MusicSlashCommand +{ + public static final int TRACKS_PER_PAGE = 10; + private final MusicService musicService; + + public HistorySlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.name = "history"; + this.help = "shows recently played tracks (history queue)"; + this.options = Collections.singletonList( + new OptionData(OptionType.INTEGER, "page", "page number to display", false) + .setMinValue(1) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.bePlaying = false; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + int page = event.getOption("page") != null ? (int) event.getOption("page").getAsLong() : 1; + + MusicService.HistoryInfo historyInfo = musicService.getHistoryInfo(event.getGuild(), event.getJDA()); + if (historyInfo == null) + { + event.reply(event.getClient().getWarning() + " Playback history is empty!").setEphemeral(true).queue(); + return; + } + if (historyInfo.isDisabled()) + { + event.reply(event.getClient().getWarning() + " " + MusicService.HISTORY_DISABLED_MESSAGE).setEphemeral(true).queue(); + return; + } + if (historyInfo.isEmpty()) + { + event.reply(event.getClient().getWarning() + " Playback history is empty!").setEphemeral(true).queue(); + return; + } + + int totalPages = (int) Math.ceil((double) historyInfo.tracks.length / TRACKS_PER_PAGE); + if (page > totalPages) + { + page = totalPages; + } + + long userId = event.getUser().getIdLong(); + int tracksOnPage = getTracksOnPage(page, historyInfo.tracks.length); + + MessageEmbed embed = buildHistoryEmbed(historyInfo, page, totalPages, 0, event.getMember().getColor()); + List<ActionRow> components = buildHistoryComponents(page, totalPages, tracksOnPage, 0, userId); + + event.replyEmbeds(embed).setComponents(components).queue(); + } + + /** + * Builds the history embed with paginated track list. + */ + public static MessageEmbed buildHistoryEmbed(MusicService.HistoryInfo historyInfo, int page, int totalPages, + int selectedTrack, Color memberColor) + { + int startIndex = (page - 1) * TRACKS_PER_PAGE; + int endIndex = Math.min(startIndex + TRACKS_PER_PAGE, historyInfo.tracks.length); + List<String> pageLines = Arrays.asList(historyInfo.tracks).subList(startIndex, endIndex); + + String description = PaginatedListEmbedUtil.buildNumberedListSection( + "**Recently played** *(select a track below to queue or play)*", pageLines, selectedTrack, startIndex + 1); + + EmbedBuilder embed = new EmbedBuilder() + .setTitle("History Queue") + .setDescription(description) + .addField("Entries", String.valueOf(historyInfo.tracks.length), true) + .addField("Duration", TimeUtil.formatTime(historyInfo.totalDuration), true) + .addField("Max history", String.valueOf(historyInfo.maxSize), true); + PaginatedListEmbedUtil.applyStandardEmbedOptions(embed, + "Page " + page + " of " + totalPages + " | 1 = most recent", memberColor); + + return embed.build(); + } + + /** + * Builds the interactive button components for history. + * Component ID format: history_{action}_{page}_{selectedTrack}_{userId} + */ + public static List<ActionRow> buildHistoryComponents(int page, int totalPages, int tracksOnPage, + int selectedTrack, long userId) + { + List<ActionRow> rows = new ArrayList<>(); + String baseId = PaginatedListComponents.baseId("history", page, selectedTrack, userId); + rows.addAll(PaginatedListComponents.buildSelectRows(baseId, page, TRACKS_PER_PAGE, tracksOnPage, selectedTrack)); + + // Row 3: Pagination + ActionRow paginationRow = PaginatedListComponents.buildPaginationRow(baseId, page, totalPages); + if (paginationRow != null) + { + rows.add(paginationRow); + } + + // Row 4: Save as playlist always; Queue and Play Now only when a track is selected; Add all to queue only when no track selected + Button saveBtn = Button.primary(String.format(baseId, "save"), "Save as playlist").withEmoji(Emoji.fromUnicode("💾")); + if (selectedTrack > 0) + { + Button queueBtn = Button.secondary(String.format(baseId, "queue"), "Queue").withEmoji(Emoji.fromUnicode("➕")); + Button playNowBtn = Button.success(String.format(baseId, "playnow"), "Play Now").withEmoji(Emoji.fromUnicode("▶️")); + rows.add(ActionRow.of(queueBtn, playNowBtn, saveBtn)); + } + else + { + Button queueAllBtn = Button.secondary(String.format(baseId, "queueall"), "Add all to queue").withEmoji(Emoji.fromUnicode("📋")); + rows.add(ActionRow.of(queueAllBtn, saveBtn)); + } + + return rows; + } + + public static int getTracksOnPage(int page, int totalTracks) + { + int startIndex = (page - 1) * TRACKS_PER_PAGE; + return Math.min(TRACKS_PER_PAGE, totalTracks - startIndex); + } + + public static int getTotalPages(int totalTracks) + { + return (int) Math.ceil((double) totalTracks / TRACKS_PER_PAGE); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NowPlayingSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NowPlayingSlashCmd.java new file mode 100644 index 0000000..81e27a8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NowPlayingSlashCmd.java @@ -0,0 +1,45 @@ +package com.jagrosh.jmusicbot.commands.v2.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; + +public class NowPlayingSlashCmd extends MusicSlashCommand +{ + public NowPlayingSlashCmd(Bot bot) + { + this(bot, "nowplaying", "shows the song that is currently playing"); + } + + protected NowPlayingSlashCmd(Bot bot, String commandName, String helpText) + { + super(bot); + this.name = commandName; + this.help = helpText; + this.aliases = bot.getConfig().getAliases(this.name); + } + + @Override + public void doCommand(SlashCommandEvent event) + { + AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); + if (handler == null) + { + event.reply(event.getClient().getWarning() + " There is no music playing in this server.").setEphemeral(true).queue(); + return; + } + + MessageCreateData nowPlayingMsg = handler.getNowPlaying(event.getJDA()); + if (nowPlayingMsg == null) + { + event.reply(handler.getNoMusicPlaying(event.getJDA())).setEphemeral(true).queue(); + bot.getNowplayingHandler().clearLastNPMessage(event.getGuild()); + } + else + { + event.reply(nowPlayingMsg).queue(hook -> hook.retrieveOriginal().queue(msg -> bot.getNowplayingHandler().setLastNPMessage(msg))); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NpSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NpSlashCmd.java new file mode 100644 index 0000000..b7a0397 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NpSlashCmd.java @@ -0,0 +1,14 @@ +package com.jagrosh.jmusicbot.commands.v2.music; + +import com.jagrosh.jmusicbot.Bot; + +/** + * Slash alias for now-playing output. + */ +public class NpSlashCmd extends NowPlayingSlashCmd +{ + public NpSlashCmd(Bot bot) + { + super(bot, "np", "shows the song that is currently playing"); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaySlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaySlashCmd.java new file mode 100644 index 0000000..31aa7fa --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaySlashCmd.java @@ -0,0 +1,138 @@ +package com.jagrosh.jmusicbot.commands.v2.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters.InteractionHookOutputAdapter; +import com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters.SlashEventOutputAdapter; +import com.jagrosh.jmusicbot.service.MusicService; +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 net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.Command; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class PlaySlashCmd extends MusicSlashCommand +{ + private final String loadingEmoji; + private final MusicService musicService; + + public PlaySlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.loadingEmoji = bot.getConfig().getLoading(); + this.name = "play"; + this.help = "plays the provided song"; + this.options = Collections.singletonList(new OptionData(OptionType.STRING, "query", "path to song OR song title OR URL", false).setAutoComplete(true)); + this.aliases = bot.getConfig().getAliases(this.name); + this.beListening = true; + this.bePlaying = false; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + if (event.getOption("query") == null) + { + musicService.play(event.getGuild(), event.getMember(), "", event.getTextChannel(), + new SlashEventOutputAdapter(event)); + return; + } + + String args = event.getOption("query").getAsString(); + event.reply(loadingEmoji + " Loading... `[" + args + "]`").queue(hook -> { + musicService.play(event.getGuild(), event.getMember(), args, event.getTextChannel(), + new InteractionHookOutputAdapter(hook, event.getJDA(), event.getClient().getWarning())); + }); + } + + @Override + public void onAutoComplete(CommandAutoCompleteInteractionEvent event) + { + String input = event.getFocusedOption().getValue(); + if(input.isEmpty()) + { + event.replyChoices().queue(); + return; + } + + if(isUrlOrPath(input)) + { + event.replyChoices(new Command.Choice(input, input)).queue(); + return; + } + + bot.getPlayerManager().loadItemOrdered(event.getGuild(), "ytsearch:" + input, + bot.getAudioLoadWrapper().wrap("ytsearch:" + input, new AudioLoadResultHandler() + { + @Override + public void trackLoaded(AudioTrack track) + { + String title = track.getInfo().title; + event.replyChoices(new Command.Choice(truncateTitle(title), track.getInfo().uri)).queue(); + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) + { + event.replyChoices(buildChoicesFromPlaylist(playlist)).queue(); + } + + @Override + public void noMatches() + { + event.replyChoices().queue(); + } + + @Override + public void loadFailed(FriendlyException exception) + { + event.replyChoices().queue(); + } + })); + } + + /** + * Checks if the input looks like a URL or file path (skip searching in that case). + */ + private static boolean isUrlOrPath(String input) + { + return input.startsWith("http://") || input.startsWith("https://") + || input.contains(":\\") || input.startsWith("/") || input.contains("\\"); + } + + /** + * Builds autocomplete choices from a playlist, limited to 10 results. + * Truncates titles longer than 100 characters (Discord's limit). + */ + private static List<Command.Choice> buildChoicesFromPlaylist(AudioPlaylist playlist) + { + List<Command.Choice> choices = new ArrayList<>(); + int limit = Math.min(playlist.getTracks().size(), 10); + for(int i = 0; i < limit; i++) + { + AudioTrack track = playlist.getTracks().get(i); + String title = track.getInfo().title; + choices.add(new Command.Choice(truncateTitle(title), track.getInfo().uri)); + } + return choices; + } + + /** + * Truncates a title to fit Discord's 100 character limit for choice names. + */ + private static String truncateTitle(String title) + { + if(title.length() > 100) + return title.substring(0, 97) + "..."; + return title; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaylistsSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaylistsSlashCmd.java new file mode 100644 index 0000000..3731490 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaylistsSlashCmd.java @@ -0,0 +1,367 @@ +/* + * 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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.listener.interaction.PaginatedListComponents; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.utils.FormatUtil; +import com.jagrosh.jmusicbot.utils.PaginatedListEmbedUtil; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.emoji.Emoji; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Slash command to show available playlists with interactive pagination and actions. + */ +public class PlaylistsSlashCmd extends MusicSlashCommand +{ + public static final int PLAYLISTS_PER_PAGE = 10; + public static final int PLAYLIST_TRACKS_PER_PAGE = 10; + private static final String FAVORITES_PLAYLIST_NAME = "favorites"; + private static final String FAVORITES_DISPLAY_LABEL = "⭐ favorites ⭐"; + + public PlaylistsSlashCmd(Bot bot) + { + super(bot); + this.name = "playlists"; + this.help = "shows the available playlists"; + this.aliases = bot.getConfig().getAliases(this.name); + this.beListening = false; + this.bePlaying = false; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + MusicService.PlaylistNamesInfo playlistNamesInfo = bot.getMusicService().getAvailablePlaylistNames(); + if (playlistNamesInfo.hasError()) + { + event.reply(event.getClient().getError() + " " + playlistNamesInfo.errorMessage) + .setEphemeral(true).queue(); + } + else if (playlistNamesInfo.names.isEmpty()) + { + event.reply(event.getClient().getWarning() + " There are no playlists in the Playlists folder!") + .setEphemeral(true).queue(); + } + else + { + List<String> list = playlistNamesInfo.names; + int totalPages = getTotalPages(list.size()); + int page = 1; + long userId = event.getUser().getIdLong(); + int playlistsOnPage = getPlaylistsOnPage(page, list.size()); + Color color = event.getMember() == null ? null : event.getMember().getColor(); + + MessageEmbed embed = buildPlaylistsEmbed(list, page, totalPages, 0, color); + List<ActionRow> components = buildPlaylistsComponents(page, totalPages, playlistsOnPage, 0, userId); + event.replyEmbeds(embed).setComponents(components).queue(); + } + } + + /** + * Builds the playlists embed with paginated playlist list. + */ + public static MessageEmbed buildPlaylistsEmbed(List<String> playlists, int page, int totalPages, + int selectedIndex, Color memberColor) + { + int startIndex = (page - 1) * PLAYLISTS_PER_PAGE; + int endIndex = Math.min(startIndex + PLAYLISTS_PER_PAGE, playlists.size()); + List<String> lineContents = playlists.subList(startIndex, endIndex).stream() + .map(name -> "`" + formatPlaylistDisplayLabel(name) + "`") + .collect(Collectors.toList()); + + String description = PaginatedListEmbedUtil.buildNumberedListSection( + "**Available playlists** *(select one below to queue or play now)*", lineContents, selectedIndex, startIndex + 1); + + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Playlists") + .setDescription(description) + .addField("Entries", String.valueOf(playlists.size()), true); + PaginatedListEmbedUtil.applyStandardEmbedOptions(embed, "Page " + page + " of " + totalPages, memberColor); + return embed.build(); + } + + /** + * Builds the playlist details embed with preview lines in the same style as Queue/History. + * Preview items are shown as markdown links to avoid Discord URL unfurling and misalignment. + */ + public static MessageEmbed buildPlaylistDetailsEmbed(MusicService.PlaylistDetailsInfo details, Color memberColor) + { + int previewSize = details.previewItems.size(); + List<String> lineContents = new ArrayList<>(previewSize); + for (String url : details.previewItems) + { + String label = formatPreviewLinkLabel(url); + lineContents.add("[**" + label + "**](" + url + ")"); + } + + StringBuilder description = new StringBuilder(); + if (previewSize > 0) + { + description.append(PaginatedListEmbedUtil.buildNumberedListSection( + "**Preview** *(first " + previewSize + " entries)*", lineContents, 0, 1)); + if (details.hasMore) + { + description.append("..."); + } + } + + String footer = previewSize > 0 ? "Preview: first " + previewSize + " entries" : null; + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Playlist: " + details.playlistName) + .addField("Entries", String.valueOf(details.totalItems), true); + if (description.length() > 0) + { + embed.setDescription(description.toString()); + } + PaginatedListEmbedUtil.applyStandardEmbedOptions(embed, footer, memberColor); + return embed.build(); + } + + /** + * Builds the playlist details embed from pre-formatted track lines (e.g. from async load), in the same + * style as Queue/History. Use this when lines are already in the format {@code `[MM:SS]` [**Title**](url)}. + */ + public static MessageEmbed buildPlaylistDetailsEmbed(String playlistName, int totalItems, + List<String> formattedLineContents, boolean hasMore, + Color memberColor) + { + int previewSize = formattedLineContents != null ? formattedLineContents.size() : 0; + StringBuilder description = new StringBuilder(); + if (previewSize > 0) + { + description.append(PaginatedListEmbedUtil.buildNumberedListSection( + "**Preview** *(first " + previewSize + " entries)*", formattedLineContents, 0, 1)); + if (hasMore) + { + description.append("..."); + } + } + + String footer = previewSize > 0 ? "Preview: first " + previewSize + " entries" : null; + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Playlist: " + playlistName) + .addField("Entries", String.valueOf(totalItems), true); + if (description.length() > 0) + { + embed.setDescription(description.toString()); + } + PaginatedListEmbedUtil.applyStandardEmbedOptions(embed, footer, memberColor); + return embed.build(); + } + + /** + * Builds an interactive, paginated playlist details embed (Queue-style). + */ + public static MessageEmbed buildPlaylistTracksEmbed(String playlistName, int totalItems, + int page, int totalPages, + List<String> formattedTrackLines, + int selectedTrack, boolean draftDirty, + Color memberColor) + { + int startIndex = (page - 1) * PLAYLIST_TRACKS_PER_PAGE; + String description = PaginatedListEmbedUtil.buildNumberedListSection( + "**Tracks** *(select one below)*", + formattedTrackLines, + selectedTrack, + startIndex + 1 + ); + + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Playlist: " + playlistName) + .setDescription(description) + .addField("Entries", String.valueOf(totalItems), true); + String footer = "Page " + page + " of " + totalPages; + if (draftDirty) + { + footer += " • Unsaved changes"; + } + PaginatedListEmbedUtil.applyStandardEmbedOptions(embed, footer, memberColor); + return embed.build(); + } + + /** + * Returns a short label for a preview link. Extracts YouTube video ID when possible; otherwise "Link". + */ + private static String formatPreviewLinkLabel(String url) + { + if (url == null) + { + return "Link"; + } + // YouTube: ...?v=VIDEO_ID or youtu.be/VIDEO_ID + int v = url.indexOf("?v="); + if (v >= 0) + { + int end = url.indexOf('&', v + 3); + String id = end >= 0 ? url.substring(v + 3, end) : url.substring(v + 3); + if (!id.isEmpty()) + return FormatUtil.filter(id.length() <= 20 ? id : id.substring(0, 17) + "..."); + } + int be = url.indexOf("youtu.be/"); + if (be >= 0) + { + int start = be + 9; + int end = url.indexOf('?', start); + String id = end >= 0 ? url.substring(start, end) : url.substring(start); + if (!id.isEmpty()) + return FormatUtil.filter(id.length() <= 20 ? id : id.substring(0, 17) + "..."); + } + return "Link"; + } + + private static String formatPlaylistDisplayLabel(String playlistName) + { + if (playlistName != null && playlistName.equalsIgnoreCase(FAVORITES_PLAYLIST_NAME)) + { + return FAVORITES_DISPLAY_LABEL; + } + return playlistName; + } + + /** + * Builds interactive button rows for playlist list navigation and actions. + * Component ID format: playlists_{action}_{page}_{selectedIndex}_{userId} + */ + public static List<ActionRow> buildPlaylistsComponents(int page, int totalPages, int playlistsOnPage, + int selectedIndex, long userId) + { + List<ActionRow> rows = new ArrayList<>(); + String baseId = PaginatedListComponents.baseId("playlists", page, selectedIndex, userId); + rows.addAll(PaginatedListComponents.buildSelectRows(baseId, page, PLAYLISTS_PER_PAGE, playlistsOnPage, selectedIndex)); + ActionRow paginationRow = PaginatedListComponents.buildPaginationRow(baseId, page, totalPages); + if (paginationRow != null) + { + rows.add(paginationRow); + } + + Button refreshBtn = Button.secondary(String.format(baseId, "refresh"), "Refresh").withEmoji(Emoji.fromUnicode("🔄")); + if (selectedIndex > 0) + { + Button queueBtn = Button.secondary(String.format(baseId, "queue"), "Queue").withEmoji(Emoji.fromUnicode("➕")); + Button playNowBtn = Button.success(String.format(baseId, "playnow"), "Play Now").withEmoji(Emoji.fromUnicode("▶️")); + Button detailsBtn = Button.primary(String.format(baseId, "details"), "Details").withEmoji(Emoji.fromUnicode("ℹ️")); + rows.add(ActionRow.of(queueBtn, playNowBtn, detailsBtn, refreshBtn)); + } + else + { + rows.add(ActionRow.of(refreshBtn)); + } + + return rows; + } + + /** + * Builds interactive components for playlist track-details sub-view. + */ + public static List<ActionRow> buildPlaylistDetailsComponents(int playlistIndex, int listPage, + int detailsPage, int totalDetailsPages, + int tracksOnPage, int selectedTrack, + long userId, boolean canEdit, + boolean draftDirty) + { + List<ActionRow> rows = new ArrayList<>(); + String baseId = "playlistdetails_" + playlistIndex + "_" + listPage + "_%s_" + detailsPage + + "_" + selectedTrack + "_" + userId; + List<Button> finalRowButtons = new ArrayList<>(); + if (canEdit && draftDirty) + { + finalRowButtons.add(Button.success(String.format(baseId, "save"), "Save")); + finalRowButtons.add(Button.secondary(String.format(baseId, "discard"), "Discard")); + } + if (selectedTrack > 0) + { + // Selected mode: grouped 4-row layout (playback, move, utility, final state). + Button moveTopBtn = Button.secondary(String.format(baseId, "movetop"), "Move top") + .withEmoji(Emoji.fromUnicode("⏫")); + Button moveUpBtn = Button.secondary(String.format(baseId, "moveup"), "Up 1") + .withEmoji(Emoji.fromUnicode("⬆️")); + Button moveBottomBtn = Button.secondary(String.format(baseId, "movebottom"), "Move bottom") + .withEmoji(Emoji.fromUnicode("⏬")); + Button moveDownBtn = Button.secondary(String.format(baseId, "movedown"), "Down 1") + .withEmoji(Emoji.fromUnicode("⬇️")); + Button moveToBtn = Button.secondary(String.format(baseId, "move"), "Move to") + .withEmoji(Emoji.fromUnicode("↕️")); + + Button queueBtn = Button.primary(String.format(baseId, "queue"), "Queue Track").withEmoji(Emoji.fromUnicode("➕")); + Button playNextBtn = Button.primary(String.format(baseId, "playnext"), "Play Next").withEmoji(Emoji.fromUnicode("⏭️")); + Button playNowBtn = Button.success(String.format(baseId, "playnow"), "Play Now").withEmoji(Emoji.fromUnicode("▶️")); + Button removeBtn = Button.danger(String.format(baseId, "remove"), "Remove") + .withEmoji(Emoji.fromUnicode("🗑️")); + + rows.add(ActionRow.of(queueBtn, playNextBtn, playNowBtn)); + if (canEdit) + { + rows.add(ActionRow.of(moveTopBtn, moveUpBtn)); + rows.add(ActionRow.of(moveBottomBtn, moveDownBtn)); + rows.add(ActionRow.of(moveToBtn, removeBtn)); + } + finalRowButtons.add(0, Button.danger(String.format(baseId, "unselect"), "Unselect song")); + rows.add(ActionRow.of(finalRowButtons)); + return rows; + } + + // Unselected mode: browsing-focused layout. + rows.addAll(PaginatedListComponents.buildSelectRows( + baseId, detailsPage, PLAYLIST_TRACKS_PER_PAGE, tracksOnPage, selectedTrack)); + ActionRow paginationRow = PaginatedListComponents.buildPaginationRow(baseId, detailsPage, totalDetailsPages); + if (paginationRow != null) + { + rows.add(paginationRow); + } + Button queueAllBtn = Button.secondary(String.format(baseId, "queueall"), "Queue Playlist") + .withEmoji(Emoji.fromUnicode("🎵")); + Button playAllBtn = Button.success(String.format(baseId, "playall"), "Play Playlist") + .withEmoji(Emoji.fromUnicode("📀")); + rows.add(ActionRow.of(queueAllBtn, playAllBtn)); + finalRowButtons.add(0, Button.danger("playlistdetails_back_" + listPage + "_" + playlistIndex + "_" + userId, "Back")); + rows.add(ActionRow.of(finalRowButtons)); + return rows; + } + + public static int getPlaylistsOnPage(int page, int totalPlaylists) + { + int startIndex = (page - 1) * PLAYLISTS_PER_PAGE; + return Math.min(PLAYLISTS_PER_PAGE, totalPlaylists - startIndex); + } + + public static int getTotalPages(int totalPlaylists) + { + return (int) Math.ceil((double) totalPlaylists / PLAYLISTS_PER_PAGE); + } + + public static int getPlaylistTracksOnPage(int page, int totalTracks) + { + int startIndex = (page - 1) * PLAYLIST_TRACKS_PER_PAGE; + return Math.min(PLAYLIST_TRACKS_PER_PAGE, totalTracks - startIndex); + } + + public static int getPlaylistTrackTotalPages(int totalTracks) + { + return (int) Math.ceil((double) totalTracks / PLAYLIST_TRACKS_PER_PAGE); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/QueueSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/QueueSlashCmd.java new file mode 100644 index 0000000..3a2b6c6 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/QueueSlashCmd.java @@ -0,0 +1,197 @@ +/* + * 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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.listener.interaction.PaginatedListComponents; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.utils.PaginatedListEmbedUtil; +import com.jagrosh.jmusicbot.utils.TimeUtil; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Slash command to show the current queue. + */ +public class QueueSlashCmd extends MusicSlashCommand +{ + public static final int TRACKS_PER_PAGE = 10; + private final MusicService musicService; + + public QueueSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.name = "queue"; + this.help = "shows the current queue"; + this.options = Collections.singletonList( + new OptionData(OptionType.INTEGER, "page", "page number to display", false) + .setMinValue(1) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.bePlaying = true; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + int page = event.getOption("page") != null ? (int) event.getOption("page").getAsLong() : 1; + + MusicService.QueueInfo queueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA()); + if (queueInfo == null || queueInfo.isEmpty()) + { + MusicService.NowPlayingInfo npInfo = musicService.getNowPlayingInfo(event.getGuild(), event.getJDA()); + if (npInfo != null) + { + MessageCreateData embed = npInfo.isPlaying ? npInfo.nowPlayingMessage : npInfo.noMusicMessage; + 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).queue(hook -> + { + if (npInfo.isPlaying) + hook.retrieveOriginal().queue(msg -> bot.getNowplayingHandler().setLastNPMessage(msg)); + }); + return; + } + } + event.reply(event.getClient().getWarning() + " There is no music in the queue!").setEphemeral(true).queue(); + return; + } + + // Build paginated response + int totalPages = (int) Math.ceil((double) queueInfo.tracks.length / TRACKS_PER_PAGE); + if (page > totalPages) + { + page = totalPages; + } + + long userId = event.getUser().getIdLong(); + int tracksOnPage = getTracksOnPage(page, queueInfo.tracks.length); + + MessageEmbed embed = buildQueueEmbed(queueInfo, page, totalPages, 0, event.getMember().getColor()); + List<ActionRow> components = buildQueueComponents(page, totalPages, tracksOnPage, 0, userId); + + event.replyEmbeds(embed).setComponents(components).queue(); + } + + /** + * Builds the queue embed with all relevant information. + */ + public static MessageEmbed buildQueueEmbed(MusicService.QueueInfo queueInfo, int page, int totalPages, + int selectedTrack, java.awt.Color memberColor) + { + int startIndex = (page - 1) * TRACKS_PER_PAGE; + int endIndex = Math.min(startIndex + TRACKS_PER_PAGE, queueInfo.tracks.length); + List<String> pageLines = Arrays.asList(queueInfo.tracks).subList(startIndex, endIndex); + + StringBuilder sb = new StringBuilder(); + if (queueInfo.nowPlayingTitle != null) + { + sb.append(queueInfo.statusEmoji).append(" **").append(queueInfo.nowPlayingTitle).append("**\n\n"); + } + sb.append(PaginatedListEmbedUtil.buildNumberedListSection( + "**Up Next** *(select a track below)*", pageLines, selectedTrack, startIndex + 1)); + + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Current Queue") + .setDescription(sb.toString()) + .addField("Entries", String.valueOf(queueInfo.tracks.length), true) + .addField("Duration", TimeUtil.formatTime(queueInfo.totalDuration), true) + .addField("Mode", queueInfo.queueType.getEmoji() + " " + queueInfo.queueType.getUserFriendlyName(), true); + PaginatedListEmbedUtil.applyStandardEmbedOptions(embed, "Page " + page + " of " + totalPages, memberColor); + + if (queueInfo.repeatMode != RepeatMode.OFF) + { + embed.addField("Repeat", queueInfo.repeatMode.getEmoji() + " " + queueInfo.repeatMode.getUserFriendlyName(), true); + } + + return embed.build(); + } + + /** + * Builds the interactive button components for the queue. + * + * @param page Current page number (1-based) + * @param totalPages Total number of pages + * @param tracksOnPage Number of tracks displayed on current page + * @param selectedTrack Currently selected track position (0 if none) + * @param userId User ID who initiated the command + * @return List of ActionRows containing the buttons + */ + public static List<ActionRow> buildQueueComponents(int page, int totalPages, int tracksOnPage, + int selectedTrack, long userId) + { + List<ActionRow> rows = new ArrayList<>(); + String baseId = PaginatedListComponents.baseId("queue", page, selectedTrack, userId); + rows.addAll(PaginatedListComponents.buildSelectRows(baseId, page, TRACKS_PER_PAGE, tracksOnPage, selectedTrack)); + + // Row 3: Pagination and Shuffle + Button shuffleBtn = Button.secondary(String.format(baseId, "shuffle"), "Shuffle").withEmoji(Emoji.fromUnicode("🔀")); + ActionRow paginationRow = PaginatedListComponents.buildPaginationRow(baseId, page, totalPages, shuffleBtn); + if (paginationRow != null) + { + rows.add(paginationRow); + } + + // Row 4: Track actions (only shown when a track is selected) + if (selectedTrack > 0) + { + Button removeBtn = Button.danger(String.format(baseId, "remove"), "Remove").withEmoji(Emoji.fromUnicode("🗑️")); + Button playNextBtn = Button.primary(String.format(baseId, "playnext"), "Play Next").withEmoji(Emoji.fromUnicode("⏭️")); + Button moveBtn = Button.secondary(String.format(baseId, "move"), "Move").withEmoji(Emoji.fromUnicode("↕️")); + Button playNowBtn = Button.success(String.format(baseId, "playnow"), "Play Now").withEmoji(Emoji.fromUnicode("▶️")); + + rows.add(ActionRow.of(removeBtn, playNextBtn, moveBtn, playNowBtn)); + } + + return rows; + } + + /** + * Calculates the number of tracks displayed on a given page. + */ + public static int getTracksOnPage(int page, int totalTracks) + { + int startIndex = (page - 1) * TRACKS_PER_PAGE; + return Math.min(TRACKS_PER_PAGE, totalTracks - startIndex); + } + + /** + * Calculates the total number of pages for a given track count. + */ + public static int getTotalPages(int totalTracks) + { + return (int) Math.ceil((double) totalTracks / TRACKS_PER_PAGE); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/RemoveSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/RemoveSlashCmd.java new file mode 100644 index 0000000..ca1c16b --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/RemoveSlashCmd.java @@ -0,0 +1,80 @@ +/* + * 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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters.SlashEventOutputAdapter; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Slash command to remove a track from the queue. + */ +public class RemoveSlashCmd extends MusicSlashCommand +{ + private final MusicService musicService; + + public RemoveSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.name = "remove"; + this.help = "removes a song from the queue"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "position", "queue position to remove, or 'all' to remove all your songs", true) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.beListening = true; + this.bePlaying = true; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + String positionArg = event.getOption("position").getAsString(); + SlashEventOutputAdapter output = new SlashEventOutputAdapter(event); + + if (musicService.isQueueEmpty(event.getGuild())) + { + output.replyError("There is nothing in the queue!"); + return; + } + + if (positionArg.equalsIgnoreCase("all")) + { + musicService.removeAllTracks(event.getGuild(), event.getMember(), output); + return; + } + + int pos; + try + { + pos = Integer.parseInt(positionArg); + } + catch (NumberFormatException e) + { + output.replyError("Please provide a valid position number or 'all'."); + return; + } + + musicService.removeTrack(event.getGuild(), event.getMember(), pos, output); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SearchSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SearchSlashCmd.java new file mode 100644 index 0000000..58896fc --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SearchSlashCmd.java @@ -0,0 +1,204 @@ +/* + * 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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.service.SearchService; +import com.jagrosh.jmusicbot.utils.FormatUtil; +import com.jagrosh.jmusicbot.utils.TimeUtil; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +/** + * Slash command to search for tracks and select from results. + */ +public class SearchSlashCmd extends MusicSlashCommand +{ + protected String searchPrefix = "ytsearch:"; + protected String searchPlatform = "YouTube"; + private final MusicService musicService; + private final String searchingEmoji; + + public SearchSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.searchingEmoji = bot.getConfig().getSearching(); + this.name = "search"; + this.help = "searches YouTube for a provided query"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "query", "the search query", true) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.beListening = true; + this.bePlaying = false; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + String query = event.getOption("query").getAsString(); + + event.reply(searchingEmoji + " Searching " + searchPlatform + " for `" + query + "`...").queue(hook -> + { + bot.getSearchService().search(event.getGuild(), event.getMember(), query, searchPrefix, + event.getTextChannel(), new SearchService.SearchCallback() + { + @Override + public void onTrackLoaded(AudioTrack track, int queuePosition, String formattedMessage) + { + hook.editOriginal(event.getClient().getSuccess() + " " + formattedMessage).queue(); + } + + @Override + public void onSearchResults(AudioPlaylist playlist, String[] formattedChoices) + { + if (playlist.getTracks().isEmpty()) + { + hook.editOriginal(event.getClient().getWarning() + " No results found for `" + query + "`.").queue(); + return; + } + + // Build select menu for search results + StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("search_select_" + event.getUser().getId()) + .setPlaceholder("Select a track to play") + .setMinValues(1) + .setMaxValues(1); + + int limit = Math.min(5, playlist.getTracks().size()); + StringBuilder description = new StringBuilder(); + for (int i = 0; i < limit; i++) + { + AudioTrack track = playlist.getTracks().get(i); + String title = FormatUtil.getTrackTitle(track); + if (title == null) { + title = ""; + } + if (title.length() > 80) + { + title = title.substring(0, 77) + "..."; + } + menuBuilder.addOption( + title, + track.getInfo().uri, + "Duration: " + TimeUtil.formatTime(track.getDuration()) + ); + description.append("`").append(i + 1).append(".` ") + .append("[**").append(FormatUtil.filter(title)).append("**](") + .append(track.getInfo().uri).append(") `[") + .append(TimeUtil.formatTime(track.getDuration())).append("]`\n"); + } + + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Search Results for: " + query) + .setDescription(description.toString()) + .setColor(event.getMember().getColor()) + .setFooter("Select a track from the menu below"); + + hook.editOriginalEmbeds(embed.build()) + .setContent("") + .setComponents(ActionRow.of(menuBuilder.build())) + .queue(msg -> + { + // Wait for selection + bot.getWaiter().waitForEvent( + StringSelectInteractionEvent.class, + e -> e.getMessageId().equals(msg.getId()) && + e.getUser().getIdLong() == event.getUser().getIdLong(), + e -> + { + String selectedUri = e.getValues().get(0); + e.deferEdit().queue(); + + // Find the selected track + AudioTrack selectedTrack = null; + for (AudioTrack track : playlist.getTracks()) + { + if (track.getInfo().uri.equals(selectedUri)) + { + selectedTrack = track; + break; + } + } + + if (selectedTrack != null) + { + MusicService.TrackAddResult result = musicService.addTrackToQueue( + event.getGuild(), + event.getMember(), + selectedTrack, + query, + event.getTextChannel() + ); + + if (result != null) + { + hook.editOriginal(event.getClient().getSuccess() + " " + result.formattedMessage) + .setEmbeds() + .setComponents() + .queue(); + } + else + { + hook.editOriginal(event.getClient().getWarning() + " " + musicService.formatTooLongError(selectedTrack)) + .setEmbeds() + .setComponents() + .queue(); + } + } + }, + 1, TimeUnit.MINUTES, + () -> hook.editOriginal("Search timed out.") + .setEmbeds() + .setComponents() + .queue() + ); + }); + } + + @Override + public void onNoMatches(String searchQuery) + { + hook.editOriginal(event.getClient().getWarning() + " No results found for `" + searchQuery + "`.").queue(); + } + + @Override + public void onLoadFailed(String errorMessage) + { + hook.editOriginal(event.getClient().getError() + " " + errorMessage).queue(); + } + + @Override + public void onError(String message) + { + hook.editOriginal(event.getClient().getError() + " " + message).queue(); + } + }); + }); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SeekSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SeekSlashCmd.java new file mode 100644 index 0000000..07bf0b7 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SeekSlashCmd.java @@ -0,0 +1,55 @@ +/* + * 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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters.SlashEventOutputAdapter; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; + +import java.util.Collections; + +/** + * Slash command to seek to a position in the current track. + */ +public class SeekSlashCmd extends MusicSlashCommand +{ + private final MusicService musicService; + + public SeekSlashCmd(Bot bot) + { + super(bot); + this.musicService = bot.getMusicService(); + this.name = "seek"; + this.help = "seeks the current song"; + this.options = Collections.singletonList( + new OptionData(OptionType.STRING, "time", "[+ | -] <HH:MM:SS | MM:SS | SS> or <0h0m0s>", true) + ); + this.aliases = bot.getConfig().getAliases(this.name); + this.beListening = true; + this.bePlaying = true; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + String timeString = event.getOption("time").getAsString(); + musicService.seek(event.getGuild(), event.getMember(), timeString, new SlashEventOutputAdapter(event)); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/ShuffleSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/ShuffleSlashCmd.java new file mode 100644 index 0000000..6220e48 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/ShuffleSlashCmd.java @@ -0,0 +1,61 @@ +/* + * 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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.service.MusicService; + +/** + * Slash command to shuffle the user's tracks in the queue. + */ +public class ShuffleSlashCmd extends MusicSlashCommand +{ + private final MusicService musicService; + + public ShuffleSlashCmd(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(SlashCommandEvent event) + { + int shuffled = musicService.shuffleUserTracks(event.getGuild(), event.getUser().getIdLong()); + switch (shuffled) + { + case 0: + event.reply(event.getClient().getError() + " You don't have any music in the queue to shuffle!") + .setEphemeral(true).queue(); + break; + case 1: + event.reply(event.getClient().getWarning() + " You only have one song in the queue!") + .setEphemeral(true).queue(); + break; + default: + event.reply(event.getClient().getSuccess() + " You successfully shuffled your " + shuffled + " entries.") + .queue(); + break; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SkipSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SkipSlashCmd.java new file mode 100644 index 0000000..7a7b116 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SkipSlashCmd.java @@ -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.music; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters.SlashEventOutputAdapter; +import com.jagrosh.jmusicbot.service.MusicService; + +/** + * Slash command to vote to skip the current song. + */ +public class SkipSlashCmd extends MusicSlashCommand +{ + private final MusicService musicService; + + public SkipSlashCmd(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(SlashCommandEvent event) + { + int listeners = (int) event.getMember().getVoiceState().getChannel().getMembers().stream() + .filter(m -> !m.getUser().isBot() && !m.getVoiceState().isDeafened()).count(); + + musicService.skipWithVote(event.getGuild(), event.getMember(), listeners, new SlashEventOutputAdapter(event)); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/diagnostics/ConfigDiagnostics.java b/src/main/java/com/jagrosh/jmusicbot/config/diagnostics/ConfigDiagnostics.java new file mode 100644 index 0000000..c848d05 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/diagnostics/ConfigDiagnostics.java @@ -0,0 +1,301 @@ +/* + * 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.config.diagnostics; + +import java.util.HashSet; +import java.util.Set; + +import com.jagrosh.jmusicbot.config.model.ConfigOption; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigValue; + +/** + * Detects missing keys, deprecated keys, and unknown keys in configuration. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigDiagnostics { + private static final String META_CONFIG_VERSION_KEY = "meta.configVersion"; + private static final String LYRICS_PREFIX = "lyrics."; + + /** + * Paths whose nested keys are user-defined and should not be flagged as deprecated. + * For example, playback.transforms allows arbitrary transform names (spotify, youtube, etc.) + * defined by the user. + */ + private static final Set<String> PATHS_WITH_DYNAMIC_NESTED_KEYS = Set.of( + "playback.transforms" + ); + + /** + * Analyzes a user configuration against defaults and returns a diagnostic report. + * + * @param migratedUserConfig the migrated user config (after migration, before merging with defaults) + * @param mergedConfig the merged config (user + defaults) + * @param defaults the default configuration + * @return a diagnostic report + */ + public static Report analyze(Config migratedUserConfig, Config mergedConfig, Config defaults) { + Set<String> missingRequired = detectMissingRequiredKeys(migratedUserConfig, mergedConfig, defaults); + Set<String> missingOptional = detectMissingOptionalKeys(migratedUserConfig, defaults); + Set<String> deprecated = detectDeprecatedKeys(migratedUserConfig, defaults); + + return new Report(missingRequired, missingOptional, deprecated); + } + + /** + * Detects required keys that are missing from the user config. + * + * @param migratedUserConfig the migrated user config (after migration, before merging) + * @param mergedConfig the merged config (to check what's actually available) + * @param defaults the default configuration + * @return set of missing required key paths + */ + private static Set<String> detectMissingRequiredKeys(Config migratedUserConfig, Config mergedConfig, Config defaults) { + Set<String> missing = new HashSet<>(); + + for (ConfigOption option : ConfigOption.values()) { + if (option.isRequired()) { + String key = option.getKey(); + + if (!migratedUserConfig.hasPath(key)) { + missing.add(key); + } + } + } + + return missing; + } + + /** + * Detects optional keys from defaults that are missing from the user config. + * + * @param migratedUserConfig the migrated user config (before merging with defaults) + * @param defaults the default configuration + * @return set of missing optional key paths + */ + private static Set<String> detectMissingOptionalKeys(Config migratedUserConfig, Config defaults) { + Set<String> missing = new HashSet<>(); + Set<String> knownKeys = ConfigOption.getAllKeys(); + + // Traverse all paths in defaults and check if they exist in user config + collectPaths(defaults, "", missing, knownKeys, migratedUserConfig); + + return missing; + } + + /** + * Recursively collects paths from a config that are missing in the user config. + * + * @param config the config to traverse + * @param prefix the current path prefix + * @param missing the set to add missing paths to + * @param knownKeys set of known config option keys + * @param userConfig the user config to check against + */ + private static void collectPaths(Config config, String prefix, Set<String> missing, + Set<String> knownKeys, Config userConfig) { + traverseConfig(config, prefix, (fullPath, key, value) -> { + if (value.valueType() == com.typesafe.config.ConfigValueType.OBJECT) { + try { + Config nestedConfig = config.getConfig(key); + collectPaths(nestedConfig, fullPath, missing, knownKeys, userConfig); + } catch (Exception e) { + // Skip if not a config object + } + } else { + // It's a leaf value - check if it exists in user config + if (!userConfig.hasPath(fullPath)) { + missing.add(fullPath); + } + } + }); + } + + /** + * Detects deprecated/unknown keys in the user config that don't exist in defaults. + * + * @param migratedUserConfig the migrated user config (after migration, before merging) + * @param defaults the default configuration + * @return set of deprecated key paths + */ + private static Set<String> detectDeprecatedKeys(Config migratedUserConfig, Config defaults) { + Set<String> deprecated = new HashSet<>(); + Set<String> knownKeys = ConfigOption.getAllKeys(); + + // Collect all paths from migrated user config + collectUserPaths(migratedUserConfig, "", deprecated, knownKeys, defaults); + + return deprecated; + } + + /** + * Recursively collects paths from user config that don't exist in defaults. + * Skips paths that are under dynamic nested key paths (e.g. playback.transforms.spotify). + * + * @param config the user config to traverse + * @param prefix the current path prefix + * @param deprecated the set to add deprecated paths to + * @param knownKeys set of known config option keys + * @param defaults the default config to check against (may be a nested config) + */ + private static void collectUserPaths(Config config, String prefix, Set<String> deprecated, + Set<String> knownKeys, Config defaults) { + traverseConfig(config, prefix, (fullPath, key, value) -> { + // Skip paths that are under dynamic nested key paths (user-defined nested keys) + if (isUnderDynamicNestedKeyPath(fullPath)) { + return; + } + + if (value.valueType() == com.typesafe.config.ConfigValueType.OBJECT) { + try { + Config nestedConfig = config.getConfig(key); + if (!defaults.hasPath(key)) { + deprecated.add(fullPath); + } else { + try { + Config defaultNested = defaults.getConfig(key); + collectUserPaths(nestedConfig, fullPath, deprecated, knownKeys, defaultNested); + } catch (Exception e) { + deprecated.add(fullPath); + } + } + } catch (Exception e) { + if (!defaults.hasPath(key)) { + deprecated.add(fullPath); + } + } + } else { + if (!defaults.hasPath(key)) { + deprecated.add(fullPath); + } + } + }); + } + + /** + * Checks if a path is under a dynamic nested key path. + * For example, "playback.transforms.spotify" is under "playback.transforms". + * + * @param fullPath the full path to check + * @return true if the path is a child of a dynamic nested key path + */ + private static boolean isUnderDynamicNestedKeyPath(String fullPath) { + for (String dynamicPath : PATHS_WITH_DYNAMIC_NESTED_KEYS) { + if (fullPath.startsWith(dynamicPath + ".")) { + return true; + } + } + return false; + } + + /** + * Traverses a config object, calling the visitor for each key that should be processed. + * Automatically skips meta.configVersion and lyrics.* keys. + * + * @param config the config to traverse + * @param prefix the current path prefix + * @param visitor the visitor to call for each key (receives fullPath, key, value) + */ + private static void traverseConfig(Config config, String prefix, ConfigKeyVisitor visitor) { + for (String key : config.root().keySet()) { + String fullPath = prefix.isEmpty() ? key : prefix + "." + key; + + // Skip meta.configVersion and lyrics keys + if (shouldSkipPath(fullPath)) { + continue; + } + + ConfigValue value = config.getValue(key); + visitor.visit(fullPath, key, value); + } + } + + /** + * Determines if a config path should be skipped during traversal. + */ + private static boolean shouldSkipPath(String fullPath) { + return META_CONFIG_VERSION_KEY.equals(fullPath) || fullPath.startsWith(LYRICS_PREFIX); + } + + /** + * Functional interface for visiting config keys during traversal. + */ + @FunctionalInterface + private interface ConfigKeyVisitor { + void visit(String fullPath, String key, ConfigValue value); + } + + /** + * Diagnostic report containing missing and deprecated keys. + */ + public static class Report { + private final Set<String> missingRequired; + private final Set<String> missingOptional; + private final Set<String> deprecated; + + public Report(Set<String> missingRequired, Set<String> missingOptional, Set<String> deprecated) { + this.missingRequired = new HashSet<>(missingRequired); + this.missingOptional = new HashSet<>(missingOptional); + this.deprecated = new HashSet<>(deprecated); + } + + public Set<String> getMissingRequired() { + return new HashSet<>(missingRequired); + } + + public Set<String> getMissingOptional() { + return new HashSet<>(missingOptional); + } + + public Set<String> getDeprecated() { + return new HashSet<>(deprecated); + } + + public boolean hasIssues() { + return !missingRequired.isEmpty() || !missingOptional.isEmpty() || !deprecated.isEmpty(); + } + + public boolean hasErrors() { + return !missingRequired.isEmpty(); + } + + public boolean hasWarnings() { + return !missingOptional.isEmpty() || !deprecated.isEmpty(); + } + + /** + * Generates a formatted diagnostic message. + */ + public String generateMessage() { + StringBuilder sb = new StringBuilder(); + + if (!missingRequired.isEmpty()) { + sb.append("ERROR: Missing required keys: ").append(missingRequired).append("\n"); + } + + if (!missingOptional.isEmpty()) { + sb.append("WARN: Missing optional keys (new options available): ").append(missingOptional).append("\n"); + } + + if (!deprecated.isEmpty()) { + sb.append("WARN: Deprecated/unknown keys (will be ignored): ").append(deprecated).append("\n"); + } + + return sb.toString(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/io/ConfigIO.java b/src/main/java/com/jagrosh/jmusicbot/config/io/ConfigIO.java new file mode 100644 index 0000000..4eabd25 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/io/ConfigIO.java @@ -0,0 +1,151 @@ +/* + * 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.config.io; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import com.jagrosh.jmusicbot.JMusicBot; +import com.jagrosh.jmusicbot.utils.OtherUtil; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.parser.ConfigDocument; +import com.typesafe.config.parser.ConfigDocumentFactory; + +/** + * Handles all configuration I/O operations including file operations + * and loading resources from the classpath. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigIO { + + // ==================== File Operations ==================== + + /** + * Gets the path to the config file, defaulting to config.txt. + */ + public static Path getConfigPath() { + Path path = OtherUtil.getPath(System.getProperty("config.file", System.getProperty("config", "config.txt"))); + if (path.toFile().exists()) { + if (System.getProperty("config.file") == null) + System.setProperty("config.file", System.getProperty("config", path.toAbsolutePath().toString())); + ConfigFactory.invalidateCaches(); + } + return path; + } + + /** + * Writes content to the config file. + */ + public static void writeConfigFile(Path path, String content) throws IOException { + Files.write(path, content.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Appends content to the config file. + */ + public static void appendToConfigFile(Path path, String content) throws IOException { + Files.write(path, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND); + } + + /** + * Checks if the config file exists. + */ + public static boolean configFileExists(Path path) { + return path.toFile().exists(); + } + + // ==================== Resource Loading ==================== + + /** + * Loads the default configuration from reference.conf in the classpath. + * This explicitly loads the reference.conf resource file. + * + * @return the default configuration + */ + public static Config loadDefaults() { + try { + return ConfigFactory.parseResources("reference.conf"); + } catch (Exception e) { + // Fallback to ConfigFactory.load() which also loads reference.conf + // but may include other sources + return ConfigFactory.load(); + } + } + + /** + * Loads the default config content from reference.conf. + */ + public static String loadDefaultConfig() { + String content = loadReferenceConfigResource(); + if (content == null) { + // Fallback to legacy format if resource not found + return "token = BOT_TOKEN_HERE\r\nowner = 0 // OWNER ID"; + } + return content; + } + + /** + * Loads the reference.conf content as a String (template). + * + * @return the reference.conf content as a string, or null if not found + */ + public static String loadReferenceConfigAsString() { + return loadReferenceConfigResource(); + } + + /** + * Loads and trims the reference.conf resource. + * + * @return the trimmed content, or null if not found + */ + private static String loadReferenceConfigResource() { + String original = OtherUtil.loadResource(JMusicBot.class, "/reference.conf"); + return original != null ? original.trim() : null; + } + + /** + * Loads the reference.conf as a ConfigDocument (template). + * This preserves the original formatting, comments, and structure. + * + * @return the ConfigDocument for reference.conf, or null if not found + */ + public static ConfigDocument loadReferenceConfigAsDocument() { + try { + String content = loadReferenceConfigAsString(); + if (content == null) { + // Fallback: try to load directly from resource + try (InputStream is = JMusicBot.class.getResourceAsStream("/reference.conf")) { + if (is == null) { + return null; + } + return ConfigDocumentFactory.parseReader( + new java.io.InputStreamReader(is, java.nio.charset.StandardCharsets.UTF_8) + ); + } + } + return ConfigDocumentFactory.parseString(content); + } catch (Exception e) { + // If parsing fails, return null + return null; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/loader/ConfigLoader.java b/src/main/java/com/jagrosh/jmusicbot/config/loader/ConfigLoader.java new file mode 100644 index 0000000..a13b4e8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/loader/ConfigLoader.java @@ -0,0 +1,166 @@ +/* + * 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.config.loader; + +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.jagrosh.jmusicbot.config.io.ConfigIO; +import com.jagrosh.jmusicbot.config.migration.ConfigMigration; +import com.jagrosh.jmusicbot.config.migration.ConfigMigrationException; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; +import com.typesafe.config.ConfigFactory; + +/** + * Handles loading and parsing configuration files with migration support. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigLoader { + private static final Logger LOGGER = LoggerFactory.getLogger(ConfigLoader.class); + + /** + * Loads the raw user config file without merging with defaults. + * This is used for version detection and migration. + * + * @param configPath the path to the config file + * @return the raw parsed config, or empty config if file doesn't exist + */ + public static Config loadRawUserConfig(Path configPath) { + if (!configPath.toFile().exists()) { + return ConfigFactory.empty(); + } + try { + return ConfigFactory.parseFile(configPath.toFile()); + } catch (ConfigException.Parse e) { + LOGGER.error("Failed to parse config file at {}: {}", configPath, e.getMessage()); + throw e; + } + } + + /** + * Loads the migrated user config (before merging with defaults). + * This is useful for diagnostics to check what the user actually provided. + * + * @param configPath the path to the config file + * @return the migrated user config (without defaults merged) + */ + public static Config loadMigratedUserConfig(Path configPath) { + Config rawUserConfig = loadRawUserConfig(configPath); + Config defaults = ConfigIO.loadDefaults(); + return migrateIfNeeded(rawUserConfig, defaults, false); + } + + /** + * Loads the migrated user config using already-parsed raw config and defaults. + * This avoids redundant parsing when these are already available. + * + * @param rawUserConfig the already-parsed raw user config + * @param defaults the already-parsed defaults + * @return the migrated user config (without defaults merged) + */ + public static Config loadMigratedUserConfig(Config rawUserConfig, Config defaults) { + return migrateIfNeeded(rawUserConfig, defaults, false); + } + + /** + * Loads the merged config (user config with defaults as fallback). + * This method now handles version detection and migration automatically. + * + * @param configPath the path to the config file + * @return the merged config with migrations applied + */ + public static Config loadMergedConfig(Path configPath) { + Config rawUserConfig = loadRawUserConfig(configPath); + Config defaults = ConfigIO.loadDefaults(); + Config migratedUserConfig = migrateIfNeeded(rawUserConfig, defaults, true); + + // Merge with defaults (migrated user config takes precedence) + return migratedUserConfig.withFallback(defaults).resolve(); + } + + /** + * Applies migrations to the raw config if needed. + * + * @param rawUserConfig the raw user config before migration + * @param defaults the default configuration containing the latest version + * @param logVersionInfo whether to log version detection info + * @return the migrated config, or the original if no migration needed or migration failed + */ + static Config migrateIfNeeded(Config rawUserConfig, Config defaults, boolean logVersionInfo) { + // Empty config means no file exists - this is a fresh install, not a v0 legacy config + if (rawUserConfig.isEmpty()) { + if (logVersionInfo) { + LOGGER.info("No config file found, using defaults"); + } + return rawUserConfig; // Skip migration, will use defaults + } + + int userVersion = ConfigMigration.detectVersion(rawUserConfig); + int latestVersion = ConfigMigration.getLatestVersion(defaults); + + if (logVersionInfo) { + LOGGER.info("Config version detected: {}, latest version: {}", userVersion, latestVersion); + } + + if (userVersion > latestVersion) { + LOGGER.warn("Config version {} is newer than the latest known version {}. " + + "This may indicate a corrupted or manually edited config.", userVersion, latestVersion); + return rawUserConfig; + } + + if (userVersion < latestVersion) { + try { + Config migrated = ConfigMigration.migrate(rawUserConfig, userVersion, latestVersion); + if (logVersionInfo) { + LOGGER.info("Config migrated from version {} to version {}", userVersion, latestVersion); + } + return migrated; + } catch (ConfigMigrationException e) { + LOGGER.error("Config migration failed: {}", e.getMessage()); + throw e; // Re-throw to let caller handle + } + } + return rawUserConfig; + } + + /** + * Loads the merged config using an already-migrated user config. + * This avoids re-running migration when the migrated config is already available. + * + * @param migratedUserConfig the already-migrated user config + * @return the merged config with defaults + */ + public static Config loadMergedConfig(Config migratedUserConfig) { + Config defaults = ConfigIO.loadDefaults(); + return mergeWithDefaults(migratedUserConfig, defaults); + } + + /** + * Merges a migrated user config with already-parsed defaults. + * This is the most efficient method when both configs are already available. + * + * @param migratedUserConfig the already-migrated user config + * @param defaults the already-parsed defaults + * @return the merged config + */ + public static Config mergeWithDefaults(Config migratedUserConfig, Config defaults) { + return migratedUserConfig.withFallback(defaults).resolve(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/migration/ConfigMigration.java b/src/main/java/com/jagrosh/jmusicbot/config/migration/ConfigMigration.java new file mode 100644 index 0000000..b5a4b05 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/migration/ConfigMigration.java @@ -0,0 +1,127 @@ +/* + * 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.config.migration; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.jagrosh.jmusicbot.config.migration.versions.LegacyToV1; +import com.jagrosh.jmusicbot.config.migration.versions.V1ToV2; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; + +/** + * Core migration engine that applies versioned migrations sequentially. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigMigration { + private static final Logger LOGGER = LoggerFactory.getLogger(ConfigMigration.class); + private static final String META_CONFIG_VERSION_KEY = "meta.configVersion"; + + private static final Map<Integer, Migration> MIGRATIONS = new HashMap<>(); + + static { + // Register all available migrations + MIGRATIONS.put(0, new LegacyToV1()); + MIGRATIONS.put(1, new V1ToV2()); + // Future migrations can be added here. + } + + /** + * Detects the version of a configuration. + * Configs without meta.configVersion are assumed to be version 0 (legacy). + * + * @param config the configuration to check + * @return the version number (0 if missing) + */ + public static int detectVersion(Config config) { + try { + if (config.hasPath(META_CONFIG_VERSION_KEY)) { + return config.getInt(META_CONFIG_VERSION_KEY); + } + } catch (ConfigException e) { + LOGGER.warn("Invalid configVersion in config, assuming version 0: {}", e.getMessage()); + } + return 0; // Legacy configs have no version field + } + + /** + * Gets the latest config version from the defaults. + * + * @param defaults the default configuration + * @return the latest version number (2 if missing) + */ + public static int getLatestVersion(Config defaults) { + try { + if (defaults.hasPath(META_CONFIG_VERSION_KEY)) { + return defaults.getInt(META_CONFIG_VERSION_KEY); + } + } catch (ConfigException e) { + LOGGER.warn("Invalid configVersion in defaults, assuming version 2: {}", e.getMessage()); + } + return 2; // Default to version 2 if not specified + } + + /** + * Migrates a configuration from one version to another. + * Applies migrations sequentially (e.g., 0→1, then 1→2). + * + * @param rawUserConfig the raw user configuration to migrate + * @param fromVersion the source version + * @param toVersion the target version + * @return the migrated configuration + * @throws ConfigMigrationException if migration fails + */ + public static Config migrate(Config rawUserConfig, int fromVersion, int toVersion) { + if (fromVersion >= toVersion) { + // No migration needed + return rawUserConfig; + } + + LOGGER.info("Migrating config from version {} → {}", fromVersion, toVersion); + + Config current = rawUserConfig; + + // Apply migrations sequentially + for (int version = fromVersion; version < toVersion; version++) { + Migration migration = MIGRATIONS.get(version); + if (migration == null) { + throw new ConfigMigrationException(version, version + 1, + "No migration found for version " + version); + } + + if (migration.getToVersion() != version + 1) { + throw new ConfigMigrationException(version, version + 1, + "Migration from version " + version + " does not target version " + (version + 1)); + } + + try { + LOGGER.debug("Applying migration {} → {}", migration.getFromVersion(), migration.getToVersion()); + current = migration.migrate(current); + } catch (Exception e) { + throw new ConfigMigrationException(version, version + 1, + "Migration step failed", e); + } + } + + LOGGER.info("Config migration from version {} → {} completed successfully", fromVersion, toVersion); + return current; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/migration/ConfigMigrationException.java b/src/main/java/com/jagrosh/jmusicbot/config/migration/ConfigMigrationException.java new file mode 100644 index 0000000..c056374 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/migration/ConfigMigrationException.java @@ -0,0 +1,46 @@ +/* + * 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.config.migration; + +/** + * Exception thrown when a configuration migration fails. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigMigrationException extends RuntimeException { + private final int fromVersion; + private final int toVersion; + + public ConfigMigrationException(int fromVersion, int toVersion, String message) { + super(String.format("Migration from version %d to %d failed: %s", fromVersion, toVersion, message)); + this.fromVersion = fromVersion; + this.toVersion = toVersion; + } + + public ConfigMigrationException(int fromVersion, int toVersion, String message, Throwable cause) { + super(String.format("Migration from version %d to %d failed: %s", fromVersion, toVersion, message), cause); + this.fromVersion = fromVersion; + this.toVersion = toVersion; + } + + public int getFromVersion() { + return fromVersion; + } + + public int getToVersion() { + return toVersion; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/migration/Migration.java b/src/main/java/com/jagrosh/jmusicbot/config/migration/Migration.java new file mode 100644 index 0000000..66e3651 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/migration/Migration.java @@ -0,0 +1,49 @@ +/* + * 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.config.migration; + +import com.typesafe.config.Config; + +/** + * Interface for versioned configuration migrations. + * Each migration transforms a config from one version to the next. + * + * @author Arif Banai (arif-banai) + */ +public interface Migration { + /** + * Gets the source version for this migration. + * + * @return the version number this migration migrates from + */ + int getFromVersion(); + + /** + * Gets the target version for this migration. + * + * @return the version number this migration migrates to + */ + int getToVersion(); + + /** + * Migrates a configuration from the source version to the target version. + * + * @param source the configuration to migrate + * @return the migrated configuration + * @throws ConfigMigrationException if the migration fails + */ + Config migrate(Config source); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/migration/versions/LegacyToV1.java b/src/main/java/com/jagrosh/jmusicbot/config/migration/versions/LegacyToV1.java new file mode 100644 index 0000000..6accf35 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/migration/versions/LegacyToV1.java @@ -0,0 +1,285 @@ +/* + * 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.config.migration.versions; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.jagrosh.jmusicbot.audio.AudioSource; +import com.jagrosh.jmusicbot.config.migration.Migration; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; +import com.typesafe.config.ConfigFactory; + +/** + * Migration from version 0 (legacy config) to version 1 (new config format). + * + * @author Arif Banai (arif-banai) + */ +public class LegacyToV1 implements Migration { + private static final Logger LOGGER = LoggerFactory.getLogger(LegacyToV1.class); + + @Override + public int getFromVersion() { + return 0; + } + + @Override + public int getToVersion() { + return 1; + } + + @Override + public Config migrate(Config source) { + LOGGER.debug("Starting migration from version 0 to version 1"); + + Map<String, Object> migrated = new HashMap<>(); + + // Build each section + addSection(migrated, "meta", buildMetaSection()); + addSection(migrated, "discord", buildDiscordSection(source)); + addSection(migrated, "commands", buildCommandsSection(source)); + addSection(migrated, "presence", buildPresenceSection(source)); + addSection(migrated, "ui", buildUiSection(source)); + addSection(migrated, "nowPlaying", buildNowPlayingSection(source)); + addSection(migrated, "voice", buildVoiceSection(source)); + addSection(migrated, "playback", buildPlaybackSection(source)); + addSection(migrated, "paths", buildPathsSection(source)); + addSection(migrated, "updates", buildUpdatesSection(source)); + addSection(migrated, "logging", buildLoggingSection(source)); + addSection(migrated, "dangerous", buildDangerousSection(source)); + + Config migratedConfig = ConfigFactory.parseMap(migrated); + LOGGER.debug("Migration from version 0 to version 1 completed"); + + return migratedConfig; + } + + /** Adds a section to the migrated config if not empty. */ + private void addSection(Map<String, Object> migrated, String key, Map<String, Object> section) { + if (!section.isEmpty()) { + migrated.put(key, section); + } + } + + private Map<String, Object> buildMetaSection() { + Map<String, Object> meta = new HashMap<>(); + meta.put("configVersion", 1); + return meta; + } + + private Map<String, Object> buildDiscordSection(Config source) { + Map<String, Object> discord = new HashMap<>(); + if (source.hasPath("token")) { + discord.put("token", source.getString("token")); + } + if (source.hasPath("owner")) { + discord.put("owner", source.getLong("owner")); + } + return discord; + } + + private Map<String, Object> buildCommandsSection(Config source) { + Map<String, Object> commands = new HashMap<>(); + if (source.hasPath("prefix")) { + commands.put("prefix", source.getString("prefix")); + } + if (source.hasPath("altprefix")) { + commands.put("altPrefix", source.getString("altprefix")); + } + if (source.hasPath("help")) { + commands.put("help", source.getString("help")); + } + if (source.hasPath("aliases")) { + commands.put("aliases", source.getConfig("aliases").root().unwrapped()); + } + return commands; + } + + private Map<String, Object> buildPresenceSection(Config source) { + Map<String, Object> presence = new HashMap<>(); + if (source.hasPath("game")) { + presence.put("game", source.getString("game")); + } + if (source.hasPath("status")) { + String status = source.getString("status"); + presence.put("status", status != null ? status.toUpperCase() : null); + } + if (source.hasPath("songinstatus")) { + presence.put("songInStatus", source.getBoolean("songinstatus")); + } + return presence; + } + + private Map<String, Object> buildUiSection(Config source) { + Map<String, Object> ui = new HashMap<>(); + Map<String, Object> emojis = new HashMap<>(); + + if (source.hasPath("success")) { + emojis.put("success", source.getString("success")); + } + if (source.hasPath("warning")) { + emojis.put("warning", source.getString("warning")); + } + if (source.hasPath("error")) { + emojis.put("error", source.getString("error")); + } + if (source.hasPath("loading")) { + emojis.put("loading", source.getString("loading")); + } + if (source.hasPath("searching")) { + emojis.put("searching", source.getString("searching")); + } + + if (!emojis.isEmpty()) { + ui.put("emojis", emojis); + } + return ui; + } + + private Map<String, Object> buildNowPlayingSection(Config source) { + Map<String, Object> nowPlaying = new HashMap<>(); + if (source.hasPath("npimages")) { + nowPlaying.put("images", source.getBoolean("npimages")); + } + return nowPlaying; + } + + private Map<String, Object> buildVoiceSection(Config source) { + Map<String, Object> voice = new HashMap<>(); + if (source.hasPath("stayinchannel")) { + voice.put("stayInChannel", source.getBoolean("stayinchannel")); + } + if (source.hasPath("alonetimeuntilstop")) { + voice.put("aloneTimeUntilStopSeconds", source.getLong("alonetimeuntilstop")); + } + return voice; + } + + private Map<String, Object> buildPlaybackSection(Config source) { + Map<String, Object> playback = new HashMap<>(); + + if (source.hasPath("maxtime")) { + playback.put("maxTrackSeconds", source.getLong("maxtime")); + } + if (source.hasPath("maxytplaylistpages")) { + playback.put("maxYouTubePlaylistPages", source.getInt("maxytplaylistpages")); + } + if (source.hasPath("skipratio")) { + playback.put("skipRatio", source.getDouble("skipratio")); + } + if (source.hasPath("useyoutubeoauth")) { + Map<String, Object> youtube = new HashMap<>(); + youtube.put("useOAuth", source.getBoolean("useyoutubeoauth")); + playback.put("youtube", youtube); + } + + Map<String, Boolean> audioSourcesMap = migrateAudioSources(source); + if (!audioSourcesMap.isEmpty()) { + playback.put("audioSources", audioSourcesMap); + } + + if (source.hasPath("transforms")) { + playback.put("transforms", source.getConfig("transforms").root().unwrapped()); + } + + return playback; + } + + private Map<String, Object> buildPathsSection(Config source) { + Map<String, Object> paths = new HashMap<>(); + if (source.hasPath("playlistsfolder")) { + paths.put("playlistsFolder", source.getString("playlistsfolder")); + } + return paths; + } + + private Map<String, Object> buildUpdatesSection(Config source) { + Map<String, Object> updates = new HashMap<>(); + if (source.hasPath("updatealerts")) { + updates.put("alerts", source.getBoolean("updatealerts")); + } + return updates; + } + + private Map<String, Object> buildLoggingSection(Config source) { + Map<String, Object> logging = new HashMap<>(); + if (source.hasPath("loglevel")) { + logging.put("level", source.getString("loglevel")); + } + return logging; + } + + private Map<String, Object> buildDangerousSection(Config source) { + Map<String, Object> dangerous = new HashMap<>(); + if (source.hasPath("eval")) { + dangerous.put("eval", source.getBoolean("eval")); + } + if (source.hasPath("evalengine")) { + dangerous.put("evalEngine", source.getString("evalengine")); + } + return dangerous; + } + + /** + * Migrates audio sources from legacy list format to new boolean map format. + * + * @param source the legacy config + * @return a map of audio source names to boolean values + */ + private Map<String, Boolean> migrateAudioSources(Config source) { + Map<String, Boolean> audioSourcesMap = new HashMap<>(); + + // Initialize all sources to false + for (AudioSource audioSource : AudioSource.values()) { + audioSourcesMap.put(audioSource.getConfigName(), false); + } + + if (source.hasPath("audiosources")) { + try { + List<String> enabledSources = source.getStringList("audiosources"); + LOGGER.debug("Migrating audio sources list: {}", enabledSources); + + // Set enabled sources to true + for (String sourceName : enabledSources) { + AudioSource.fromConfigName(sourceName).ifPresent(sourceEnum -> { + audioSourcesMap.put(sourceEnum.getConfigName(), true); + LOGGER.debug("Enabled audio source: {}", sourceEnum.getConfigName()); + }); + } + } catch (ConfigException e) { + LOGGER.warn("Failed to read audiosources list, defaulting to all enabled: {}", e.getMessage()); + // If parsing fails, enable all sources (default behavior) + for (AudioSource audioSource : AudioSource.values()) { + audioSourcesMap.put(audioSource.getConfigName(), true); + } + } + } else { + // No audiosources key means all enabled (default behavior) + LOGGER.debug("No audiosources key found, defaulting to all enabled"); + for (AudioSource audioSource : AudioSource.values()) { + audioSourcesMap.put(audioSource.getConfigName(), true); + } + } + + return audioSourcesMap; + } + +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/migration/versions/V1ToV2.java b/src/main/java/com/jagrosh/jmusicbot/config/migration/versions/V1ToV2.java new file mode 100644 index 0000000..6bb3822 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/migration/versions/V1ToV2.java @@ -0,0 +1,63 @@ +/* + * 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.config.migration.versions; + +import com.jagrosh.jmusicbot.config.migration.Migration; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigValueFactory; + +/** + * Migration from version 1 to version 2. + * + * Renames nowPlaying.updateProgressBar to nowPlaying.showProgressBar. + * + * @author Arif Banai (arif-banai) + */ +public class V1ToV2 implements Migration { + private static final String META_CONFIG_VERSION_KEY = "meta.configVersion"; + private static final String OLD_PROGRESS_BAR_KEY = "nowPlaying.updateProgressBar"; + private static final String NEW_PROGRESS_BAR_KEY = "nowPlaying.showProgressBar"; + + @Override + public int getFromVersion() { + return 1; + } + + @Override + public int getToVersion() { + return 2; + } + + @Override + public Config migrate(Config source) { + Config migrated = source; + + // Preserve user intent when only the old key is present. + if (source.hasPath(OLD_PROGRESS_BAR_KEY) && !source.hasPath(NEW_PROGRESS_BAR_KEY)) { + migrated = migrated.withValue( + NEW_PROGRESS_BAR_KEY, + ConfigValueFactory.fromAnyRef(source.getBoolean(OLD_PROGRESS_BAR_KEY)) + ); + } + + // Remove old key to complete rename. + if (migrated.hasPath(OLD_PROGRESS_BAR_KEY)) { + migrated = migrated.withoutPath(OLD_PROGRESS_BAR_KEY); + } + + return migrated.withValue(META_CONFIG_VERSION_KEY, ConfigValueFactory.fromAnyRef(getToVersion())); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java new file mode 100644 index 0000000..1d76f21 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java @@ -0,0 +1,262 @@ +/* + * Copyright 2026 Arif Banai (arif-banai) + * + * Modified 2026: added the YouTube remote cipher config options. + * + * 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.config.model; + +import java.util.List; +import java.util.Set; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; + +/** + * Enumeration of all configuration options for the bot. + * This provides a centralized, type-safe way to manage config options. + * + * @author Arif Banai (arif-banai) + */ +public enum ConfigOption { + // Required options + TOKEN("discord.token", ConfigType.STRING, true, "Bot token for authentication"), + OWNER("discord.owner", ConfigType.LONG, true, "Owner's Discord user ID"), + + // String options + PREFIX("commands.prefix", ConfigType.STRING, false, "Command prefix (use @mention for mention prefix)"), + ALTPREFIX("commands.altPrefix", ConfigType.STRING, false, "Alternative command prefix (use NONE to disable)"), + HELP_WORD("commands.help", ConfigType.STRING, false, "Word used to view help"), + SUCCESS_EMOJI("ui.emojis.success", ConfigType.STRING, false, "Success emoji"), + WARNING_EMOJI("ui.emojis.warning", ConfigType.STRING, false, "Warning emoji"), + ERROR_EMOJI("ui.emojis.error", ConfigType.STRING, false, "Error emoji"), + LOADING_EMOJI("ui.emojis.loading", ConfigType.STRING, false, "Loading emoji"), + SEARCHING_EMOJI("ui.emojis.searching", ConfigType.STRING, false, "Searching emoji"), + GAME("presence.game", ConfigType.STRING, false, "Bot's game status (use DEFAULT for default, NONE for no game)"), + STATUS("presence.status", ConfigType.STRING, false, "Bot's online status (ONLINE, IDLE, DND, INVISIBLE)"), + LOG_LEVEL("logging.level", ConfigType.STRING, false, "Logging verbosity (off, error, warn, info, debug, trace, all)"), + EVAL_ENGINE("dangerous.evalEngine", ConfigType.STRING, false, "Eval engine name"), + PLAYLISTS_FOLDER("paths.playlistsFolder", ConfigType.STRING, false, "Alternative folder for playlists"), + YOUTUBE_REMOTE_CIPHER_URL("playback.youtube.remoteCipher.url", ConfigType.STRING, false, "Remote cipher server used to decipher YouTube stream signatures (empty to use the local extractor)"), + YOUTUBE_REMOTE_CIPHER_PASSWORD("playback.youtube.remoteCipher.password", ConfigType.STRING, false, "Password for the remote cipher server, if it requires one"), + + // Boolean options + STAY_IN_CHANNEL("voice.stayInChannel", ConfigType.BOOLEAN, false, "Whether to stay in voice channel after queue ends"), + SONG_IN_GAME("presence.songInStatus", ConfigType.BOOLEAN, false, "Whether to show current song in bot status"), + NP_IMAGES("nowPlaying.images", ConfigType.BOOLEAN, false, "Whether to show YouTube thumbnails in nowplaying"), + NP_MINIMAL_MESSAGE("nowPlaying.minimalMessage", ConfigType.BOOLEAN, false, "Whether to render now-playing messages in a compact/minimal layout"), + NP_SHOW_BUTTONS("nowPlaying.showButtons", ConfigType.BOOLEAN, false, "Whether to include interactive playback buttons in now-playing messages"), + NP_SHOW_PROGRESS_BAR("nowPlaying.showProgressBar", ConfigType.BOOLEAN, false, "Whether to show the now-playing progress bar"), + UPDATE_ALERTS("updates.alerts", ConfigType.BOOLEAN, false, "Whether to alert owner about updates"), + USE_EVAL("dangerous.eval", ConfigType.BOOLEAN, false, "Whether to enable eval command (DANGEROUS)"), + USE_YOUTUBE_OAUTH("playback.youtube.useOAuth", ConfigType.BOOLEAN, false, "Whether to use YouTube OAuth2 for playback"), + + // Numeric options + MAX_SECONDS("playback.maxTrackSeconds", ConfigType.LONG, false, "Maximum track length in seconds (0 = no limit)"), + MAX_YT_PLAYLIST_PAGES("playback.maxYouTubePlaylistPages", ConfigType.INT, false, "Maximum YouTube playlist pages to load"), + MAX_HISTORY_SIZE("playback.maxHistorySize", ConfigType.INT, false, "Maximum number of tracks to keep in history (0 = disabled)"), + ALONE_TIME_UNTIL_STOP("voice.aloneTimeUntilStopSeconds", ConfigType.LONG, false, "Seconds to wait alone before leaving (0 = never)"), + SKIP_RATIO("playback.skipRatio", ConfigType.DOUBLE, false, "Ratio of users needed to vote skip"), + CLEAR_CHANNEL_DELETE_LIMIT("commands.clearChannel.deleteLimit", ConfigType.INT, false, "Max messages deleted by clearchannel (0 = unlimited)"), + CLEAR_CHANNEL_AGE_DAYS("commands.clearChannel.ageDays", ConfigType.LONG, false, "Max message age in days for clearchannel (0 = unlimited)"), + + // Complex options - Nested configurations + ALIASES("commands.aliases", ConfigType.CONFIG, false, "Command aliases configuration"), + TRANSFORMS("playback.transforms", ConfigType.CONFIG, false, "Audio source transforms configuration"), + AUDIO_SOURCES("playback.audioSources", ConfigType.CONFIG, false, "Audio sources configuration (nested booleans)"), + + // GUI options + GUI_ENABLED("gui.enabled", ConfigType.BOOLEAN, false, "Enable or disable the GUI (default true)"), + GUI_THEME("gui.theme", ConfigType.STRING, false, "GUI theme: light, dark, darcula, intellij"), + GUI_FONT_SIZE("gui.fontSize", ConfigType.INT, false, "Base font size for GUI components (8-24)"), + + // Performance options + NAS_BUFFER_MS("performance.nasBufferMs", ConfigType.INT, false, "NAS buffer duration in ms (protects against GC pauses)"), + FRAME_BUFFER_MS("performance.frameBufferMs", ConfigType.INT, false, "Lavaplayer frame buffer duration in ms"), + + // Proxy options + PROXY_HOST("proxy.host", ConfigType.STRING, false, "Proxy server hostname"), + PROXY_PORT("proxy.port", ConfigType.INT, false, "Proxy server port"), + PROXY_LAVAPLAYER("proxy.lavaplayer", ConfigType.BOOLEAN, false, "Route Lavaplayer audio requests through proxy"), + PROXY_JDA("proxy.jda", ConfigType.BOOLEAN, false, "Route JDA Discord API traffic through proxy"), + PROXY_GITHUB("proxy.github", ConfigType.BOOLEAN, false, "Route GitHub version checks through proxy"); + + private final String key; + private final ConfigType type; + private final boolean required; + private final String description; + + ConfigOption(String key, ConfigType type, boolean required, String description) { + this.key = key; + this.type = type; + this.required = required; + this.description = description; + } + + public String getKey() { + return key; + } + + public ConfigType getType() { + return type; + } + + public boolean isRequired() { + return required; + } + + public String getDescription() { + return description; + } + + /** + * Gets the value of this config option from the provided Config object. + * + * @param config The Config object to read from + * @return The value as an Object (cast appropriately based on type) + * @deprecated Use type-specific getter methods instead for type safety + */ + @Deprecated + public Object getValue(Config config) { + if (!config.hasPath(key)) { + return null; + } + + try { + return switch (type) { + case STRING -> config.getString(key); + case LONG -> config.getLong(key); + case INT -> config.getInt(key); + case DOUBLE -> config.getDouble(key); + case BOOLEAN -> config.getBoolean(key); + case CONFIG -> config.getConfig(key); + case STRING_LIST -> config.getStringList(key); + }; + } catch (Exception e) { + return null; + } + } + + /** + * Type-safe getter for String values. + * @throws ConfigException.Missing if the path doesn't exist + */ + public String getString(Config config) { + return config.getString(key); + } + + /** + * Type-safe getter for Long values. + * @throws ConfigException.Missing if the path doesn't exist + */ + public Long getLong(Config config) { + return config.getLong(key); + } + + /** + * Type-safe getter for Integer values. + * @throws ConfigException.Missing if the path doesn't exist + */ + public Integer getInt(Config config) { + return config.getInt(key); + } + + /** + * Type-safe getter for Double values. + * @throws ConfigException.Missing if the path doesn't exist + */ + public Double getDouble(Config config) { + return config.getDouble(key); + } + + /** + * Type-safe getter for Boolean values. + * @throws ConfigException.Missing if the path doesn't exist + */ + public Boolean getBoolean(Config config) { + return config.getBoolean(key); + } + + /** + * Type-safe getter for Config (nested config) values. + * @throws ConfigException.Missing if the path doesn't exist + */ + public Config getConfig(Config config) { + return config.getConfig(key); + } + + /** + * Type-safe getter for String list values. + * @throws ConfigException.Missing if the path doesn't exist + */ + public List<String> getStringList(Config config) { + return config.getStringList(key); + } + + /** + * Checks if the config has a value for this option. + */ + public boolean hasValue(Config config) { + return config.hasPath(key); + } + + /** + * Gets all config option keys as a set. + */ + public static Set<String> getAllKeys() { + Set<String> keys = new java.util.HashSet<>(); + for (ConfigOption option : values()) { + keys.add(option.key); + } + return keys; + } + + /** + * Gets all optional (non-required) config option keys as a set. + */ + public static Set<String> getOptionalKeys() { + Set<String> keys = new java.util.HashSet<>(); + for (ConfigOption option : values()) { + if (!option.required) { + keys.add(option.key); + } + } + return keys; + } + + /** + * Finds a ConfigOption by its key name. + */ + public static java.util.Optional<ConfigOption> findByKey(String key) { + for (ConfigOption option : values()) { + if (option.key.equals(key)) { + return java.util.Optional.of(option); + } + } + return java.util.Optional.empty(); + } + + /** + * Config value types supported by the configuration system. + */ + public enum ConfigType { + STRING, + LONG, + INT, + DOUBLE, + BOOLEAN, + CONFIG, + STRING_LIST + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigUpdateType.java b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigUpdateType.java new file mode 100644 index 0000000..62eb19d --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigUpdateType.java @@ -0,0 +1,76 @@ +/* + * 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.config.model; + +/** + * Represents the type of config update that occurred. + * Used to provide appropriate messaging in logs and config file comments. + * + * @author Arif Banai (arif-banai) + */ +public enum ConfigUpdateType { + /** + * Migration from legacy (version 0) config to a newer version. + * This involves restructuring the config format. + */ + MIGRATION("migrated"), + + /** + * Repair of a current-version config that has missing keys. + * The config format is correct but some keys need to be added. + */ + REPAIR("repaired"), + + /** + * Simple update with no migration or repair needed. + * Only deprecated keys were removed or minor changes made. + */ + UPDATE("updated"); + + private final String pastTenseVerb; + + ConfigUpdateType(String pastTenseVerb) { + this.pastTenseVerb = pastTenseVerb; + } + + /** + * Returns the past tense verb for this update type (e.g., "migrated", "repaired", "updated"). + */ + public String getPastTenseVerb() { + return pastTenseVerb; + } + + /** + * Determines the appropriate update type based on the original config version and diagnostics. + * + * @param originalVersion the version of the config before any updates + * @param latestVersion the latest config version + * @param hasMissingKeys whether the config has missing required or optional keys + * @return the appropriate ConfigUpdateType + */ + public static ConfigUpdateType determine(int originalVersion, int latestVersion, boolean hasMissingKeys) { + if (originalVersion < latestVersion) { + // Legacy config needs migration (version 0 -> 1+) + return MIGRATION; + } else if (hasMissingKeys) { + // Current version but missing keys - needs repair + return REPAIR; + } else { + // Only deprecated keys or other minor updates + return UPDATE; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/render/ConfigRenderer.java b/src/main/java/com/jagrosh/jmusicbot/config/render/ConfigRenderer.java new file mode 100644 index 0000000..c259cf8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/render/ConfigRenderer.java @@ -0,0 +1,266 @@ +/* + * 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.config.render; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.jagrosh.jmusicbot.config.diagnostics.ConfigDiagnostics; +import com.jagrosh.jmusicbot.config.io.ConfigIO; +import com.jagrosh.jmusicbot.config.model.ConfigOption; +import com.jagrosh.jmusicbot.config.model.ConfigUpdateType; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; +import com.typesafe.config.ConfigRenderOptions; +import com.typesafe.config.ConfigValue; +import com.typesafe.config.parser.ConfigDocument; + +/** + * Handles rendering of configuration objects to HOCON strings. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigRenderer { + private static final Logger LOGGER = LoggerFactory.getLogger(ConfigRenderer.class); + + /** Render options for inline HOCON values (no comments, no formatting). */ + private static final ConfigRenderOptions INLINE_VALUE_OPTIONS = ConfigRenderOptions.defaults() + .setOriginComments(false) + .setComments(false) + .setFormatted(false) + .setJson(false); + + /** + * Generates the HOCON content for the updated config file. + * Uses ConfigDocument to preserve the style, comments, and ordering from reference.conf. + * Uses the system clock for timestamp generation. + * + * @param migratedUserConfig the migrated user configuration (without defaults merged) + * @param diagnostics the diagnostic report + * @param updateType the type of update (migration, repair, or update) + * @return the HOCON content as a string + */ + public static String generateConfigContent(Config migratedUserConfig, ConfigDiagnostics.Report diagnostics, + ConfigUpdateType updateType) { + return generateConfigContent(migratedUserConfig, diagnostics, updateType, Clock.systemDefaultZone()); + } + + /** + * Generates the HOCON content for the updated config file. + * Uses ConfigDocument to preserve the style, comments, and ordering from reference.conf. + * + * @param migratedUserConfig the migrated user configuration (without defaults merged) + * @param diagnostics the diagnostic report + * @param updateType the type of update (migration, repair, or update) + * @param clock the clock to use for timestamp generation + * @return the HOCON content as a string + */ + public static String generateConfigContent(Config migratedUserConfig, ConfigDiagnostics.Report diagnostics, + ConfigUpdateType updateType, Clock clock) { + // Load reference.conf as ConfigDocument (template) + ConfigDocument templateDoc = ConfigIO.loadReferenceConfigAsDocument(); + + if (templateDoc == null) { + // Fallback to old behavior if template cannot be loaded + LOGGER.warn("Could not load reference.conf as ConfigDocument, falling back to rendered config"); + return generateConfigContentFallback(migratedUserConfig, diagnostics, updateType, clock); + } + + // Start with the template document + ConfigDocument outputDoc = templateDoc; + + // Load defaults to check if template has empty nested configs + Config defaults = ConfigIO.loadDefaults(); + + // For each ConfigOption, if migratedUserConfig has a value, update the document + for (ConfigOption option : ConfigOption.values()) { + String key = option.getKey(); + + // Check if user config has this key + if (migratedUserConfig.hasPath(key)) { + try { + // Handle nested configs (CONFIG type) specially + if (option.getType() == ConfigOption.ConfigType.CONFIG) { + Config nestedConfig = option.getConfig(migratedUserConfig); + + // Check if the template has an empty nested config for this key + // (e.g., transforms = {}). If so, user keys are dynamic and we need + // to replace the entire value rather than updating individual keys. + boolean templateIsEmpty = false; + if (defaults.hasPath(key)) { + try { + Config templateNested = defaults.getConfig(key); + templateIsEmpty = templateNested.isEmpty(); + } catch (ConfigException e) { + // Not a config object, treat as non-empty + } + } + + if (templateIsEmpty && !nestedConfig.isEmpty()) { + // Template is empty but user has values (like transforms.spotify) + // ConfigDocument.withValueText for nested paths doesn't properly + // add new keys to an empty object, so we replace the entire value. + // Build a HOCON object string with all user's nested values + StringBuilder sb = new StringBuilder("{ "); + boolean first = true; + for (String nestedKey : nestedConfig.root().keySet()) { + if (!first) { + sb.append(", "); + } + first = false; + ConfigValue nestedValue = nestedConfig.getValue(nestedKey); + String renderedValue = renderValue(nestedValue); + sb.append(nestedKey).append(" = ").append(renderedValue); + } + sb.append(" }"); + LOGGER.debug("Replacing empty config '{}' with: {}", key, sb.toString()); + outputDoc = outputDoc.withValueText(key, sb.toString()); + } else if (!nestedConfig.isEmpty()) { + // Template has keys, update individual keys to preserve template defaults + // This preserves keys from the template that aren't in the user config + for (String nestedKey : nestedConfig.root().keySet()) { + String fullPath = key + "." + nestedKey; + try { + ConfigValue nestedValue = nestedConfig.getValue(nestedKey); + String renderedValue = renderValue(nestedValue); + outputDoc = outputDoc.withValueText(fullPath, renderedValue); + } catch (ConfigException e) { + LOGGER.debug("Could not get nested value for key {}: {}", fullPath, e.getMessage()); + } + } + } + } else { + // For simple values, get the ConfigValue and render it + ConfigValue value = migratedUserConfig.getValue(key); + String renderedValue = renderValue(value); + // Update the document with the user's value + outputDoc = outputDoc.withValueText(key, renderedValue); + } + } catch (ConfigException e) { + // If we can't get the value, skip it (will use template default) + LOGGER.debug("Could not get value for key {}: {}", key, e.getMessage()); + } + } + // If user config doesn't have this key, the template default remains + } + + // Build the output with header comments + StringBuilder sb = buildHeaderComment(diagnostics, updateType, clock); + + // Render the document (preserves comments and formatting) + sb.append(outputDoc.render()); + + return sb.toString(); + } + + /** + * Fallback method that uses the old rendering approach if ConfigDocument cannot be loaded. + * Merges migratedUserConfig with defaults to match old behavior. + * Uses the system clock for timestamp generation. + * + * @param migratedUserConfig the migrated user configuration (without defaults merged) + * @param diagnostics the diagnostic report + * @param updateType the type of update (migration, repair, or update) + * @return the HOCON content as a string + */ + public static String generateConfigContentFallback(Config migratedUserConfig, ConfigDiagnostics.Report diagnostics, + ConfigUpdateType updateType) { + return generateConfigContentFallback(migratedUserConfig, diagnostics, updateType, Clock.systemDefaultZone()); + } + + /** + * Fallback method that uses the old rendering approach if ConfigDocument cannot be loaded. + * Merges migratedUserConfig with defaults to match old behavior. + * + * @param migratedUserConfig the migrated user configuration (without defaults merged) + * @param diagnostics the diagnostic report + * @param updateType the type of update (migration, repair, or update) + * @param clock the clock to use for timestamp generation + * @return the HOCON content as a string + */ + public static String generateConfigContentFallback(Config migratedUserConfig, ConfigDiagnostics.Report diagnostics, + ConfigUpdateType updateType, Clock clock) { + // Merge with defaults to match old behavior + Config defaults = ConfigIO.loadDefaults(); + Config config = migratedUserConfig.withFallback(defaults).resolve(); + + // Build the output with header comments + StringBuilder sb = buildHeaderComment(diagnostics, updateType, clock); + + // Render the config using old method + com.typesafe.config.ConfigRenderOptions options = com.typesafe.config.ConfigRenderOptions.defaults() + .setOriginComments(false) + .setComments(true) + .setFormatted(true) + .setJson(false); + + sb.append(config.root().render(options)); + + return sb.toString(); + } + + /** + * Builds the header comment section for the config file. + * + * @param diagnostics the diagnostic report + * @param updateType the type of update (migration, repair, or update) + * @param clock the clock to use for timestamp generation + * @return a StringBuilder containing the header comment + */ + private static StringBuilder buildHeaderComment(ConfigDiagnostics.Report diagnostics, ConfigUpdateType updateType, Clock clock) { + StringBuilder sb = new StringBuilder(); + + String timestamp = LocalDateTime.now(clock).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + sb.append("# This file was automatically ").append(updateType.getPastTenseVerb()).append(" on ").append(timestamp).append("\n"); + sb.append("# Your original config file has been backed up with a .bak extension (or .bak1, .bak2, etc. if previous backups exist).\n"); + sb.append("#\n"); + + if (diagnostics.hasIssues()) { + sb.append("# Changes detected:\n"); + if (!diagnostics.getMissingRequired().isEmpty()) { + sb.append("# - Missing required keys: ").append(diagnostics.getMissingRequired()).append("\n"); + } + if (!diagnostics.getMissingOptional().isEmpty()) { + sb.append("# - Missing optional keys (new options): ").append(diagnostics.getMissingOptional()).append("\n"); + } + if (!diagnostics.getDeprecated().isEmpty()) { + sb.append("# - Deprecated keys removed: ").append(diagnostics.getDeprecated()).append("\n"); + } + sb.append("#\n"); + } + + return sb; + } + + /** + * Renders a ConfigValue as a HOCON string suitable for inline use. + * This preserves HOCON syntax (not JSON) and is appropriate for setting values in a ConfigDocument. + * + * @param value the ConfigValue to render + * @return the HOCON string representation + */ + private static String renderValue(ConfigValue value) { + if (value == null) { + return "null"; + } + return value.render(INLINE_VALUE_OPTIONS); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/update/ConfigUpdater.java b/src/main/java/com/jagrosh/jmusicbot/config/update/ConfigUpdater.java new file mode 100644 index 0000000..29ab4d0 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/update/ConfigUpdater.java @@ -0,0 +1,134 @@ +/* + * 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.config.update; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.jagrosh.jmusicbot.config.diagnostics.ConfigDiagnostics; +import com.jagrosh.jmusicbot.config.io.ConfigIO; +import com.jagrosh.jmusicbot.config.model.ConfigUpdateType; +import com.jagrosh.jmusicbot.config.render.ConfigRenderer; +import com.typesafe.config.Config; + +/** + * Generates updated configuration files to guide users through config updates. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigUpdater { + private static final Logger LOGGER = LoggerFactory.getLogger(ConfigUpdater.class); + private static final String BACKUP_SUFFIX = ".bak"; + + /** + * Updates the config file in place by backing up the original and writing the migrated config. + * The original config file is backed up with a .bak extension. If a backup already exists, + * a numbered suffix is added (e.g., .bak1, .bak2) to avoid overwriting existing backups. + * + * @param userConfigPath the path to the user's config file + * @param migratedUserConfig the migrated user configuration (without defaults merged) + * @param diagnostics the diagnostic report + * @param updateType the type of update (migration, repair, or update) + * @return the path to the updated config file, or null if update failed + */ + public static Path generateUpdatedConfig(Path userConfigPath, Config migratedUserConfig, + ConfigDiagnostics.Report diagnostics, + ConfigUpdateType updateType) { + try { + // Normalize to absolute path + Path normalizedPath = userConfigPath.toAbsolutePath().normalize(); + + // Backup the original config file if it exists + if (normalizedPath.toFile().exists()) { + Path backupPath = findAvailableBackupPath(normalizedPath); + Files.copy(normalizedPath, backupPath); + LOGGER.info("Backed up original config to: {}", backupPath); + } + + // Generate HOCON content using ConfigDocument to preserve template style + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, updateType); + + // Write the migrated config to the original location + ConfigIO.writeConfigFile(normalizedPath, content); + + LOGGER.info("Updated config file: {}", normalizedPath); + return normalizedPath; + } catch (IOException e) { + LOGGER.error("Failed to update config file: {}", e.getMessage()); + return null; + } + } + + /** + * Finds an available backup path that doesn't overwrite any existing file. + * Starts with .bak, then tries .bak1, .bak2, etc. + * + * @param configPath the path to the config file being backed up + * @return a path that doesn't exist yet + */ + public static Path findAvailableBackupPath(Path configPath) { + String fileName = configPath.getFileName().toString(); + Path baseBackupPath = configPath.resolveSibling(fileName + BACKUP_SUFFIX); + + if (!Files.exists(baseBackupPath)) { + return baseBackupPath; + } + + // Find the next available numbered backup + int counter = 1; + Path numberedBackupPath; + do { + numberedBackupPath = configPath.resolveSibling(fileName + BACKUP_SUFFIX + counter); + counter++; + } while (Files.exists(numberedBackupPath)); + + return numberedBackupPath; + } + + /** + * Checks if any backup of the config file exists (including numbered backups like .bak1, .bak2, etc.). + * + * @param userConfigPath the path to the user's config file + * @return true if any backup file exists + */ + public static boolean backupExists(Path userConfigPath) { + Path normalizedPath = userConfigPath.toAbsolutePath().normalize(); + String fileName = normalizedPath.getFileName().toString(); + Path baseBackupPath = normalizedPath.resolveSibling(fileName + BACKUP_SUFFIX); + + // Check for base backup (.bak) + if (Files.exists(baseBackupPath)) { + return true; + } + + // Check for numbered backups (.bak.1, .bak.2, etc.) + Path parent = normalizedPath.getParent(); + if (parent != null) { + try { + return Files.list(parent) + .anyMatch(path -> path.getFileName().toString().startsWith(fileName + BACKUP_SUFFIX)); + } catch (IOException e) { + LOGGER.warn("Failed to check for numbered backups: {}", e.getMessage()); + } + } + + return false; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/config/validation/ConfigValidator.java b/src/main/java/com/jagrosh/jmusicbot/config/validation/ConfigValidator.java new file mode 100644 index 0000000..c4f452d --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/config/validation/ConfigValidator.java @@ -0,0 +1,132 @@ +/* + * 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.config.validation; + +import java.nio.file.Path; + +import com.jagrosh.jmusicbot.entities.UserInteraction; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; + +/** + * Handles validation of required configuration values. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigValidator { + private static final String CONTEXT = "Config"; + + /** + * Validates the bot token, prompting the user if missing or invalid. + * + * @return ValidationResult containing the token (or null if invalid) and whether a write is needed + */ + public static ValidationResult validateToken(String token, UserInteraction userInteraction, Path configPath) { + if (token == null || token.isEmpty() || token.equalsIgnoreCase("BOT_TOKEN_HERE")) { + String newToken = userInteraction.prompt("Please provide a bot token." + + "\nInstructions for obtaining a token can be found here:" + + "\nhttps://github.com/jagrosh/MusicBot/wiki/Getting-a-Bot-Token." + + "\nBot Token: "); + if (newToken == null) { + alertWithConfigLocation(userInteraction, Level.WARNING, + "No token provided! Exiting.", configPath); + return ValidationResult.invalid(); + } + return ValidationResult.validWithWrite(newToken); + } + return ValidationResult.valid(token); + } + + /** + * Validates the bot owner ID, prompting the user if missing or invalid. + * + * @return ValidationResult containing the owner ID (or null if invalid) and whether a write is needed + */ + public static ValidationResult validateOwner(Long owner, UserInteraction userInteraction, Path configPath) { + if (owner == null || owner <= 0) { + try { + String ownerInput = userInteraction.prompt("Owner ID was missing, or the provided owner ID is not valid." + + "\nPlease provide the User ID of the bot's owner." + + "\nInstructions for obtaining your User ID can be found here:" + + "\nhttps://github.com/jagrosh/MusicBot/wiki/Finding-Your-User-ID" + + "\nOwner User ID: "); + if (ownerInput != null) { + long newOwner = Long.parseLong(ownerInput); + if (newOwner > 0) { + return ValidationResult.validWithWrite(newOwner); + } + } + } catch (NumberFormatException | NullPointerException ex) { + // Fall through to error + } + alertWithConfigLocation(userInteraction, Level.ERROR, + "Invalid User ID! Exiting.", configPath); + return ValidationResult.invalid(); + } + return ValidationResult.valid(owner); + } + + /** + * Shows an alert with the config file location appended. + */ + private static void alertWithConfigLocation(UserInteraction userInteraction, Level level, + String message, Path configPath) { + userInteraction.alert(level, CONTEXT, + message + "\n\nConfig Location: " + configPath.toAbsolutePath().toString()); + } + + /** + * Result of a validation operation. + */ + public static class ValidationResult { + private final Object value; + private final boolean valid; + private final boolean needsWrite; + + public ValidationResult(Object value, boolean valid, boolean needsWrite) { + this.value = value; + this.valid = valid; + this.needsWrite = needsWrite; + } + + /** Creates a valid result with no write needed. */ + public static ValidationResult valid(Object value) { + return new ValidationResult(value, true, false); + } + + /** Creates a valid result that requires a config file write. */ + public static ValidationResult validWithWrite(Object value) { + return new ValidationResult(value, true, true); + } + + /** Creates an invalid result. */ + public static ValidationResult invalid() { + return new ValidationResult(null, false, false); + } + + @SuppressWarnings("unchecked") + public <T> T getValue() { + return (T) value; + } + + public boolean isValid() { + return valid; + } + + public boolean needsWrite() { + return needsWrite; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/entities/Pair.java b/src/main/java/com/jagrosh/jmusicbot/entities/Pair.java new file mode 100644 index 0000000..bbba786 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/entities/Pair.java @@ -0,0 +1,44 @@ +/* + * Copyright 2018 John Grosh <john.a.grosh@gmail.com>. + * + * 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.entities; + +/** + * + * @author John Grosh (john.a.grosh@gmail.com) + * @param <K> + * @param <V> + */ +public class Pair<K,V> +{ + private final K key; + private final V value; + + public Pair(K key, V value) + { + this.key = key; + this.value = value; + } + + public K getKey() + { + return key; + } + + public V getValue() + { + return value; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java b/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java new file mode 100644 index 0000000..af2eab5 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java @@ -0,0 +1,159 @@ +/* + * Copyright 2018 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.entities; + +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.util.Scanner; + +/** + * Implementation of UserInteraction that provides GUI dialogs or CLI interaction + * based on the runtime environment. + * + * @author John Grosh (john.a.grosh@gmail.com) + */ +public class Prompt implements UserInteraction +{ + private final String title; + private final String noguiMessage; + + private boolean nogui; + private boolean noprompt; + private Scanner scanner; + + public Prompt(String title) + { + this(title, null); + } + + public Prompt(String title, String noguiMessage) + { + this(title, noguiMessage, + isPropertyEnabled("nogui"), + isPropertyEnabled("noprompt")); + } + + private static boolean isPropertyEnabled(String propertyName) + { + String prop = System.getProperty(propertyName); + return prop != null && !"false".equalsIgnoreCase(prop); + } + + public Prompt(String title, String noguiMessage, boolean nogui, boolean noprompt) + { + this.title = title; + this.noguiMessage = noguiMessage == null ? "Switching to nogui mode. You can manually start in nogui mode by including the -Dnogui=true flag." : noguiMessage; + this.nogui = nogui; + this.noprompt = noprompt; + } + + @Override + public boolean isNoGUI() + { + return nogui; + } + + @Override + public String prompt(String content) { + if (noprompt) + return null; + + if (nogui) + return promptCli(content); + + try { + return JOptionPane.showInputDialog(null, content, title, JOptionPane.QUESTION_MESSAGE); + } catch (Exception e) { + alert(UserInteraction.Level.WARNING, title, noguiMessage); + return promptCli(content); // preserves your original “retry via CLI” behavior + } + } + + @Override + public void alert(UserInteraction.Level level, String context, String message) { + if (nogui) { + logAlert(level, context, message); + return; + } + + try { + JOptionPane.showMessageDialog( + null, + htmlMessage(message), + title, + optionFor(level) + ); + } catch (Exception e) { + nogui = true; + alert(UserInteraction.Level.WARNING, context, noguiMessage); + alert(level, context, message); + } + } + + private void logAlert(UserInteraction.Level level, String context, String message) { + var log = LoggerFactory.getLogger(context); + switch (level) { + case WARNING -> log.warn(message); + case ERROR -> log.error(message); + default -> log.info(message); + } + } + + private int optionFor(UserInteraction.Level level) { + return switch (level) { + case INFO -> JOptionPane.INFORMATION_MESSAGE; + case WARNING -> JOptionPane.WARNING_MESSAGE; + case ERROR -> JOptionPane.ERROR_MESSAGE; + default -> JOptionPane.PLAIN_MESSAGE; + }; + } + + private static String htmlMessage(String message) { + return "<html><body><p style='width:400px;'>" + message + "</p></body></html>"; + } + + + + private String promptCli(String content) { + if (scanner == null) + scanner = new Scanner(System.in); + + try { + System.out.println(content); + return scanner.hasNextLine() + ? scanner.nextLine() + : null; + } catch (Exception e) { + alert(UserInteraction.Level.ERROR, title, "Unable to read input from command line."); + e.printStackTrace(); + return null; + } + } + + /** + * @deprecated Use {@link UserInteraction.Level} instead. + * This alias is kept for backward compatibility. + */ + @Deprecated(since = "0.5.0", forRemoval = true) + public static class Level { + public static final UserInteraction.Level INFO = UserInteraction.Level.INFO; + public static final UserInteraction.Level WARNING = UserInteraction.Level.WARNING; + public static final UserInteraction.Level ERROR = UserInteraction.Level.ERROR; + + private Level() {} // Prevent instantiation + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/entities/UserInteraction.java b/src/main/java/com/jagrosh/jmusicbot/entities/UserInteraction.java new file mode 100644 index 0000000..9dd7829 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/entities/UserInteraction.java @@ -0,0 +1,57 @@ +/* + * 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.entities; + +/** + * Interface for user interaction capabilities. + * Provides abstraction for prompting users and showing alerts, + * allowing different implementations (GUI, CLI, headless, etc.). + * + * @author Arif Banai (arif-banai) + */ +public interface UserInteraction { + + /** + * Severity levels for alert messages. + */ + enum Level { + INFO, WARNING, ERROR + } + + /** + * Prompts the user for input. + * + * @param message The message to display to the user + * @return The user's input, or null if cancelled/no input + */ + String prompt(String message); + + /** + * Shows an alert message to the user. + * + * @param level The severity level of the alert + * @param context The context/category of the alert + * @param message The message to display + */ + void alert(Level level, String context, String message); + + /** + * Checks if running in no-GUI mode. + * + * @return true if running without GUI, false otherwise + */ + boolean isNoGUI(); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/ConsolePanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/ConsolePanel.java new file mode 100644 index 0000000..f3666dc --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/ConsolePanel.java @@ -0,0 +1,299 @@ +/* + * Copyright 2017 John Grosh <john.a.grosh@gmail.com>. + * 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.gui; + +import com.jagrosh.jmusicbot.utils.ConsoleUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultHighlighter; +import javax.swing.text.Highlighter; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; + +/** + * Enhanced console panel with monospace font, search, filtering, and controls. + * + * @author John Grosh (john.a.grosh@gmail.com) + * @author Arif Banai (arif-banai) + */ +public class ConsolePanel extends JPanel { + + private static final Logger LOG = LoggerFactory.getLogger(ConsolePanel.class); + + private final JTextArea textArea; + private final JScrollPane scrollPane; + private final JTextField searchField; + private final JCheckBox autoScrollCheckbox; + private final JLabel matchCountLabel; + + private static final Font CONSOLE_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12); + private static final Color HIGHLIGHT_COLOR = new Color(255, 255, 0, 100); + + public ConsolePanel() { + super(new BorderLayout(0, 4)); + setBorder(new EmptyBorder(8, 8, 8, 8)); + + // Initialize text area with redirected streams + textArea = ConsoleUtil.redirectSystemStreams(); + configureTextArea(); + + // Create scroll pane + scrollPane = new JScrollPane(textArea); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); + + // Create toolbar + JPanel toolbar = createToolbar(); + + // Create search panel + JPanel searchPanel = createSearchPanel(); + searchField = (JTextField) ((JPanel) searchPanel.getComponent(0)).getComponent(1); + matchCountLabel = (JLabel) searchPanel.getComponent(1); + + // Top panel with toolbar and search + JPanel topPanel = new JPanel(new BorderLayout(8, 0)); + topPanel.add(toolbar, BorderLayout.WEST); + topPanel.add(searchPanel, BorderLayout.CENTER); + + // Auto-scroll checkbox + autoScrollCheckbox = new JCheckBox("Auto-scroll", true); + autoScrollCheckbox.setFont(autoScrollCheckbox.getFont().deriveFont(11f)); + topPanel.add(autoScrollCheckbox, BorderLayout.EAST); + + add(topPanel, BorderLayout.NORTH); + add(scrollPane, BorderLayout.CENTER); + + installEdtUncaughtExceptionHandler(); + } + + /** + * Installs an uncaught exception handler for the AWT Event Dispatch Thread so that + * EDT exceptions are logged with full stack trace instead of printing + * "Exception in thread \"AWT-EventQueue-0\"" to stderr (and thus into the console). + */ + private void installEdtUncaughtExceptionHandler() { + EventQueue.invokeLater(() -> { + Thread edt = Thread.currentThread(); + edt.setUncaughtExceptionHandler((t, e) -> { + LOG.error("Uncaught exception on AWT Event Dispatch Thread", e); + // Do not delegate to default handler; avoids duplicate "Exception in thread..." in console + }); + }); + } + + /** + * Configures the text area with appropriate settings. + */ + private void configureTextArea() { + textArea.setFont(CONSOLE_FONT); + textArea.setEditable(false); + textArea.setLineWrap(true); + textArea.setWrapStyleWord(true); + textArea.setMargin(new Insets(4, 4, 4, 4)); + + // Add caret listener for auto-scroll behavior (guarded so it never throws on EDT). + // When the user starts selecting text, disable auto-scroll so it doesn't clear the selection. + // Otherwise only auto-scroll when there is no selection. + textArea.addCaretListener(e -> { + try { + if (autoScrollCheckbox == null) return; + if (textArea.getSelectionStart() != textArea.getSelectionEnd()) { + autoScrollCheckbox.setSelected(false); + } else if (autoScrollCheckbox.isSelected()) { + textArea.setCaretPosition(textArea.getDocument().getLength()); + } + } catch (Throwable ignored) { + // Catch Throwable so Errors (e.g. AssertionError) do not propagate on EDT + } + }); + } + + /** + * Creates the toolbar with action buttons. + */ + private JPanel createToolbar() { + JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + + // Clear button + JButton clearButton = new JButton("Clear"); + clearButton.setToolTipText("Clear console output"); + clearButton.addActionListener(e -> clearConsole()); + toolbar.add(clearButton); + + // Copy button + JButton copyButton = new JButton("Copy"); + copyButton.setToolTipText("Copy all console output to clipboard"); + copyButton.addActionListener(e -> copyToClipboard()); + toolbar.add(copyButton); + + // Copy selection button + JButton copySelectionButton = new JButton("Copy Selection"); + copySelectionButton.setToolTipText("Copy selected text to clipboard"); + copySelectionButton.addActionListener(e -> copySelectionToClipboard()); + toolbar.add(copySelectionButton); + + return toolbar; + } + + /** + * Creates the search panel with text field and match count. + */ + private JPanel createSearchPanel() { + JPanel panel = new JPanel(new BorderLayout(4, 0)); + + JPanel searchInputPanel = new JPanel(new BorderLayout(4, 0)); + JLabel searchLabel = new JLabel("Search:"); + JTextField searchInput = new JTextField(); + searchInput.setToolTipText("Type to search (Enter to find next, Esc to clear)"); + + searchInput.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + performSearch(searchInput.getText()); + } else if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { + searchInput.setText(""); + clearHighlights(); + } + } + + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() != KeyEvent.VK_ENTER && e.getKeyCode() != KeyEvent.VK_ESCAPE) { + performSearch(searchInput.getText()); + } + } + }); + + searchInputPanel.add(searchLabel, BorderLayout.WEST); + searchInputPanel.add(searchInput, BorderLayout.CENTER); + + JLabel matchCount = new JLabel("0 matches"); + matchCount.setFont(matchCount.getFont().deriveFont(11f)); + matchCount.setForeground(Color.GRAY); + + panel.add(searchInputPanel, BorderLayout.CENTER); + panel.add(matchCount, BorderLayout.EAST); + + return panel; + } + + /** + * Clears all console output. + */ + private void clearConsole() { + SwingUtilities.invokeLater(() -> textArea.setText("")); + } + + /** + * Copies all console text to the clipboard. + */ + private void copyToClipboard() { + String text = textArea.getText(); + if (!text.isEmpty()) { + StringSelection selection = new StringSelection(text); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, null); + } + } + + /** + * Copies selected text to the clipboard. + */ + private void copySelectionToClipboard() { + String selectedText = textArea.getSelectedText(); + if (selectedText != null && !selectedText.isEmpty()) { + StringSelection selection = new StringSelection(selectedText); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, null); + } + } + + /** + * Performs a search and highlights matching text. + */ + private void performSearch(String searchText) { + clearHighlights(); + + if (searchText == null || searchText.isEmpty()) { + matchCountLabel.setText("0 matches"); + return; + } + + String content = textArea.getText().toLowerCase(); + String search = searchText.toLowerCase(); + + Highlighter highlighter = textArea.getHighlighter(); + Highlighter.HighlightPainter painter = new DefaultHighlighter.DefaultHighlightPainter(HIGHLIGHT_COLOR); + + int count = 0; + int index = 0; + + while ((index = content.indexOf(search, index)) >= 0) { + try { + highlighter.addHighlight(index, index + search.length(), painter); + count++; + index += search.length(); + } catch (BadLocationException e) { + break; + } + } + + matchCountLabel.setText(count + " match" + (count != 1 ? "es" : "")); + + // Scroll to first match + if (count > 0) { + int firstMatch = content.indexOf(search); + if (firstMatch >= 0) { + try { + Rectangle rect = textArea.modelToView2D(firstMatch).getBounds(); + textArea.scrollRectToVisible(rect); + } catch (BadLocationException e) { + // Ignore + } + } + } + } + + /** + * Clears all search highlights. + */ + private void clearHighlights() { + textArea.getHighlighter().removeAllHighlights(); + } + + /** + * Gets the underlying text area. + */ + public JTextArea getTextArea() { + return textArea; + } + + /** + * Scrolls to the bottom of the console. + */ + public void scrollToBottom() { + SwingUtilities.invokeLater(() -> { + textArea.setCaretPosition(textArea.getDocument().getLength()); + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum()); + }); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/MainFrame.java b/src/main/java/com/jagrosh/jmusicbot/gui/MainFrame.java new file mode 100644 index 0000000..779e7c9 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/MainFrame.java @@ -0,0 +1,340 @@ +/* + * 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.gui; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.GCMonitor; +import com.jagrosh.jmusicbot.audio.SystemHealthMonitor; +import com.jagrosh.jmusicbot.gui.components.StatusBar; +import com.jagrosh.jmusicbot.gui.model.BotStatusData; +import com.jagrosh.jmusicbot.gui.panels.ConfigPanel; +import com.jagrosh.jmusicbot.gui.panels.PerformancePanel; +import com.jagrosh.jmusicbot.gui.panels.SettingsPanel; +import com.jagrosh.jmusicbot.gui.panels.SourceHealthPanel; +import com.jagrosh.jmusicbot.gui.panels.StatusPanel; +import com.jagrosh.jmusicbot.gui.panels.SystemHealthPanel; +import com.jagrosh.jmusicbot.gui.theme.ThemeManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.time.Instant; + +/** + * Main application frame for JMusicBot with modern FlatLaf styling. + * Provides a tabbed interface with console, status, and settings panels. + * + * @author Arif Banai (arif-banai) + */ +public class MainFrame extends JFrame { + + private static final Logger LOG = LoggerFactory.getLogger(MainFrame.class); + private static final String TITLE = "JMusicBot"; + private static final int DEFAULT_WIDTH = 800; + private static final int DEFAULT_HEIGHT = 600; + + private final Bot bot; + private final JTabbedPane tabbedPane; + private final ConsolePanel consolePanel; + private final StatusPanel statusPanel; + private final PerformancePanel performancePanel; + private final SystemHealthPanel systemHealthPanel; + private final SourceHealthPanel sourceHealthPanel; + private final SettingsPanel settingsPanel; + private final ConfigPanel configPanel; + private final StatusBar statusBar; + private final Instant startTime; + private Timer statusUpdateTimer; + + /** + * Creates the main application frame. + * + * @param bot the bot instance + */ + public MainFrame(Bot bot) { + super(TITLE); + this.bot = bot; + this.startTime = Instant.now(); + + // Initialize panels + this.consolePanel = new ConsolePanel(); + this.statusPanel = new StatusPanel(bot); + this.performancePanel = new PerformancePanel(bot); + this.systemHealthPanel = new SystemHealthPanel(); + this.sourceHealthPanel = new SourceHealthPanel(bot.getTrackLoadingMonitor()); + this.settingsPanel = new SettingsPanel(); + this.configPanel = new ConfigPanel(bot); + this.statusBar = new StatusBar(); + this.tabbedPane = new JTabbedPane(); + + initializeFrame(); + } + + /** + * Initializes the frame layout and components. + */ + private void initializeFrame() { + setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + setMinimumSize(new Dimension(600, 400)); + setPreferredSize(new Dimension(DEFAULT_WIDTH, DEFAULT_HEIGHT)); + + // Create menu bar + setJMenuBar(createMenuBar()); + + // Setup tabbed pane with icons + setupTabbedPane(); + + // Main layout + JPanel mainPanel = new JPanel(new BorderLayout()); + mainPanel.add(tabbedPane, BorderLayout.CENTER); + mainPanel.add(statusBar, BorderLayout.SOUTH); + + setContentPane(mainPanel); + + // Window close handler + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + handleWindowClosing(); + } + }); + } + + /** + * Initializes and shows the frame. + */ + public void init() { + pack(); + setLocationRelativeTo(null); + setVisible(true); + + // Start status bar update timer + startStatusBarUpdates(); + + // Start GC monitoring for performance visualization + GCMonitor.getInstance().start(); + + // Start system health monitoring (CPU, memory, threads) + SystemHealthMonitor.getInstance().start(); + + LOG.info("JMusicBot GUI initialized"); + } + + /** + * Starts a timer to periodically update status from bot's JDA. + * Fetches data once and distributes to both StatusBar and StatusPanel. + */ + private void startStatusBarUpdates() { + statusUpdateTimer = new Timer(2000, e -> updateAllStatusDisplays()); + statusUpdateTimer.setInitialDelay(500); + statusUpdateTimer.start(); + } + + /** + * Fetches status data once and updates both StatusBar and StatusPanel. + * Single source of truth for status information. + */ + private void updateAllStatusDisplays() { + // Fetch data once (includes uptime calculation) + BotStatusData statusData = BotStatusData.fromBot(bot, startTime); + + // Distribute to components + statusBar.updateStatus(statusData, statusData.uptimeString()); + statusPanel.updateStatus(statusData); + + // Update performance metrics if performance/system/sources tab is visible + int selectedTab = tabbedPane.getSelectedIndex(); + if (selectedTab == 2) { + performancePanel.updateMetrics(); + } else if (selectedTab == 3) { + systemHealthPanel.updateMetrics(); + } else if (selectedTab == 4) { + sourceHealthPanel.refreshMetrics(); + } + } + + /** + * Creates the menu bar with File, View, and Help menus. + */ + private JMenuBar createMenuBar() { + JMenuBar menuBar = new JMenuBar(); + + // File menu + JMenu fileMenu = new JMenu("File"); + fileMenu.setMnemonic('F'); + + JMenuItem exitItem = new JMenuItem("Exit"); + exitItem.setMnemonic('x'); + exitItem.addActionListener(e -> handleWindowClosing()); + fileMenu.add(exitItem); + + // View menu + JMenu viewMenu = new JMenu("View"); + viewMenu.setMnemonic('V'); + + // Theme submenu + JMenu themeMenu = new JMenu("Theme"); + ButtonGroup themeGroup = new ButtonGroup(); + + for (ThemeManager.Theme theme : ThemeManager.getAvailableThemes()) { + JRadioButtonMenuItem themeItem = new JRadioButtonMenuItem(theme.getDisplayName()); + themeItem.setSelected(theme == ThemeManager.getCurrentTheme()); + themeItem.addActionListener(e -> ThemeManager.setTheme(theme)); + themeGroup.add(themeItem); + themeMenu.add(themeItem); + } + viewMenu.add(themeMenu); + + viewMenu.addSeparator(); + + // Tab navigation + JMenuItem consoleTab = new JMenuItem("Console"); + consoleTab.addActionListener(e -> tabbedPane.setSelectedIndex(0)); + viewMenu.add(consoleTab); + + JMenuItem statusTab = new JMenuItem("Status"); + statusTab.addActionListener(e -> tabbedPane.setSelectedIndex(1)); + viewMenu.add(statusTab); + + JMenuItem performanceTab = new JMenuItem("Performance"); + performanceTab.addActionListener(e -> tabbedPane.setSelectedIndex(2)); + viewMenu.add(performanceTab); + + JMenuItem systemTab = new JMenuItem("System Health"); + systemTab.addActionListener(e -> tabbedPane.setSelectedIndex(3)); + viewMenu.add(systemTab); + + JMenuItem sourcesTab = new JMenuItem("Sources"); + sourcesTab.addActionListener(e -> tabbedPane.setSelectedIndex(4)); + viewMenu.add(sourcesTab); + + JMenuItem settingsTab = new JMenuItem("Settings"); + settingsTab.addActionListener(e -> tabbedPane.setSelectedIndex(5)); + viewMenu.add(settingsTab); + + JMenuItem configTab = new JMenuItem("Config"); + configTab.addActionListener(e -> tabbedPane.setSelectedIndex(6)); + viewMenu.add(configTab); + + // Help menu + JMenu helpMenu = new JMenu("Help"); + helpMenu.setMnemonic('H'); + + JMenuItem aboutItem = new JMenuItem("About"); + aboutItem.addActionListener(e -> showAboutDialog()); + helpMenu.add(aboutItem); + + menuBar.add(fileMenu); + menuBar.add(viewMenu); + menuBar.add(helpMenu); + + return menuBar; + } + + /** + * Sets up the tabbed pane with all panels. + */ + private void setupTabbedPane() { + tabbedPane.setTabPlacement(JTabbedPane.TOP); + + // Add tabs with tooltips + tabbedPane.addTab("Console", null, consolePanel, "View application logs and output"); + tabbedPane.addTab("Status", null, statusPanel, "View bot status and connected guilds"); + tabbedPane.addTab("Performance", null, performancePanel, "Monitor audio processing performance"); + tabbedPane.addTab("System", null, systemHealthPanel, "Monitor system health (CPU, memory, GC)"); + tabbedPane.addTab("Sources", null, sourceHealthPanel, "Monitor track loading health by source"); + tabbedPane.addTab("Settings", null, settingsPanel, "Configure GUI settings"); + tabbedPane.addTab("Config", null, configPanel, "Edit bot configuration"); + + // Refresh panels when selected + tabbedPane.addChangeListener(e -> { + int selectedTab = tabbedPane.getSelectedIndex(); + if (selectedTab == 2) { + performancePanel.refreshGuildList(); + } else if (selectedTab == 3) { + systemHealthPanel.refreshMetrics(); + } else if (selectedTab == 4) { + sourceHealthPanel.refreshMetrics(); + } + }); + } + + /** + * Handles window closing - shuts down the bot gracefully. + */ + private void handleWindowClosing() { + int result = JOptionPane.showConfirmDialog( + this, + "Are you sure you want to exit? This will shut down the bot.", + "Confirm Exit", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE + ); + + if (result == JOptionPane.YES_OPTION) { + LOG.info("User requested shutdown"); + try { + bot.shutdown(); + } catch (Exception ex) { + LOG.error("Error during shutdown", ex); + } + dispose(); + System.exit(0); + } + } + + /** + * Shows the About dialog. + */ + private void showAboutDialog() { + String message = """ + JMusicBot + + A cross-platform Discord music bot with a clean interface. + + Theme: %s + Java: %s + """.formatted( + ThemeManager.getCurrentTheme().getDisplayName(), + System.getProperty("java.version") + ); + + JOptionPane.showMessageDialog( + this, + message, + "About JMusicBot", + JOptionPane.INFORMATION_MESSAGE + ); + } + + + /** + * Gets the status panel for external updates. + */ + public StatusPanel getStatusPanel() { + return statusPanel; + } + + /** + * Gets the console panel. + */ + public ConsolePanel getConsolePanel() { + return consolePanel; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/TextAreaOutputStream.java b/src/main/java/com/jagrosh/jmusicbot/gui/TextAreaOutputStream.java new file mode 100644 index 0000000..dd38973 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/TextAreaOutputStream.java @@ -0,0 +1,171 @@ +/* + * Copyright 2017 John Grosh <john.a.grosh@gmail.com>. + * + * 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.gui; + +import javax.swing.*; +import java.awt.*; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +/** + * + * @author Lawrence Dol + */ +public class TextAreaOutputStream extends OutputStream { + +// ************************************************************************************************* +// INSTANCE MEMBERS +// ************************************************************************************************* + +private byte[] oneByte; // array for write(int val); +private Appender appender; // most recent action + +public TextAreaOutputStream(JTextArea txtara) { + this(txtara,1000); + } + +public TextAreaOutputStream(JTextArea txtara, int maxlin) { + if(maxlin<1) { throw new IllegalArgumentException("TextAreaOutputStream maximum lines must be positive (value="+maxlin+")"); } + oneByte=new byte[1]; + appender=new Appender(txtara,maxlin); + } + +/** Clear the current console text area. */ +public synchronized void clear() { + if(appender!=null) { appender.clear(); } + } + +@Override +public synchronized void close() { + appender=null; + } + +@Override +public synchronized void flush() { + /* empty */ + } + +@Override +public synchronized void write(int val) { + oneByte[0]=(byte)val; + write(oneByte,0,1); + } + +@Override +public synchronized void write(byte[] ba) { + write(ba,0,ba.length); + } + +@Override +public synchronized void write(byte[] ba,int str,int len) { + if(appender!=null) { appender.append(bytesToString(ba,str,len)); } + } + +//@edu.umd.cs.findbugs.annotations.SuppressWarnings("DM_DEFAULT_ENCODING") +static private String bytesToString(byte[] ba, int str, int len) { + try { + return new String(ba,str,len,"UTF-8"); + } catch(UnsupportedEncodingException thr) { + return new String(ba,str,len); + } // all JVMs are required to support UTF-8 + } + +// ************************************************************************************************* +// STATIC MEMBERS +// ************************************************************************************************* + + static class Appender + implements Runnable + { + static private final String EOL1="\n"; + static private final String EOL2=System.getProperty("line.separator",EOL1); + + private final JTextArea textArea; + private final int maxLines; // maximum lines allowed in text area + private final LinkedList<Integer> lengths; // length of lines within text area + private final List<String> values; // values waiting to be appended + + private int curLength; // length of current line + private boolean clear; + private boolean queue; + + Appender(JTextArea txtara, int maxlin) { + textArea =txtara; + maxLines =maxlin; + lengths =new LinkedList<>(); + values =new ArrayList<>(); + + curLength=0; + clear =false; + queue =true; + } + + private synchronized void append(String val) { + values.add(val); + if(queue) { + queue=false; + EventQueue.invokeLater(this); + } + } + + private synchronized void clear() { + clear=true; + curLength=0; + lengths.clear(); + values.clear(); + if(queue) { + queue=false; + EventQueue.invokeLater(this); + } + } + + // MUST BE THE ONLY METHOD THAT TOUCHES textArea! + @Override + public synchronized void run() { + try { + if (clear) { + textArea.setText(""); + } + values.stream().map((val) -> { + curLength += val.length(); + return val; + }).map((val) -> { + if (val.endsWith(EOL1) || val.endsWith(EOL2)) { + if (lengths.size() >= maxLines) { + textArea.replaceRange("", 0, lengths.removeFirst()); + } + lengths.addLast(curLength); + curLength = 0; + } + return val; + }).forEach((val) -> { + textArea.append(val); + }); + values.clear(); + clear = false; + } catch (Throwable t) { + values.clear(); + clear = false; + // Do not rethrow: an uncaught exception on the EDT would stop future log updates + // because queue would never be set back to true in finally. + } finally { + queue = true; + } + } + } +} /* END PUBLIC CLASS */ \ No newline at end of file diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/components/IconFactory.java b/src/main/java/com/jagrosh/jmusicbot/gui/components/IconFactory.java new file mode 100644 index 0000000..e0effb2 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/components/IconFactory.java @@ -0,0 +1,221 @@ +/* + * 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.gui.components; + +import com.formdev.flatlaf.extras.FlatSVGIcon; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.awt.*; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Factory for loading and caching icons, with support for FlatLaf SVG icons. + * Provides commonly used icons for the application. + * + * @author Arif Banai (arif-banai) + */ +public final class IconFactory { + + private static final Logger LOG = LoggerFactory.getLogger(IconFactory.class); + + // Icon cache for reuse + private static final Map<String, Icon> iconCache = new ConcurrentHashMap<>(); + + // Default icon size + private static final int DEFAULT_SIZE = 16; + + private IconFactory() { + // Utility class - prevent instantiation + } + + /** + * Available icon types for the application. + */ + public enum IconType { + // Status icons + CONNECTED("connected", "icons/connected.svg"), + DISCONNECTED("disconnected", "icons/disconnected.svg"), + + // Tab icons + CONSOLE("console", "icons/console.svg"), + STATUS("status", "icons/status.svg"), + SETTINGS("settings", "icons/settings.svg"), + + // Action icons + PLAY("play", "icons/play.svg"), + PAUSE("pause", "icons/pause.svg"), + STOP("stop", "icons/stop.svg"), + SKIP("skip", "icons/skip.svg"), + VOLUME("volume", "icons/volume.svg"), + + // Misc icons + SEARCH("search", "icons/search.svg"), + CLEAR("clear", "icons/clear.svg"), + COPY("copy", "icons/copy.svg"), + REFRESH("refresh", "icons/refresh.svg"); + + private final String name; + private final String path; + + IconType(String name, String path) { + this.name = name; + this.path = path; + } + + public String getName() { + return name; + } + + public String getPath() { + return path; + } + } + + /** + * Gets an icon by type with default size. + * + * @param type the icon type + * @return the icon, or a placeholder if not found + */ + public static Icon getIcon(IconType type) { + return getIcon(type, DEFAULT_SIZE); + } + + /** + * Gets an icon by type with specified size. + * + * @param type the icon type + * @param size the icon size + * @return the icon, or a placeholder if not found + */ + public static Icon getIcon(IconType type, int size) { + String cacheKey = type.getName() + "_" + size; + + return iconCache.computeIfAbsent(cacheKey, k -> { + try { + // Try to load as SVG first + FlatSVGIcon icon = new FlatSVGIcon(type.getPath(), size, size); + if (icon.hasFound()) { + return icon; + } + } catch (Exception e) { + LOG.debug("Could not load SVG icon: {}", type.getPath()); + } + + // Return a simple colored placeholder icon + return createPlaceholderIcon(type, size); + }); + } + + /** + * Gets a FlatLaf built-in icon by name. + * These icons are included with FlatLaf and adapt to the current theme. + * + * @param name the icon name (e.g., "actions/search.svg") + * @return the icon, or null if not found + */ + public static Icon getFlatLafIcon(String name) { + return getFlatLafIcon(name, DEFAULT_SIZE); + } + + /** + * Gets a FlatLaf built-in icon by name with specified size. + * + * @param name the icon name + * @param size the icon size + * @return the icon, or null if not found + */ + public static Icon getFlatLafIcon(String name, int size) { + String cacheKey = "flatlaf_" + name + "_" + size; + + return iconCache.computeIfAbsent(cacheKey, k -> { + try { + FlatSVGIcon icon = new FlatSVGIcon("com/formdev/flatlaf/icons/" + name, size, size); + if (icon.hasFound()) { + return icon; + } + } catch (Exception e) { + LOG.debug("Could not load FlatLaf icon: {}", name); + } + return null; + }); + } + + /** + * Creates a simple placeholder icon when the real icon is not available. + */ + private static Icon createPlaceholderIcon(IconType type, int size) { + return new Icon() { + @Override + public void paintIcon(Component c, Graphics g, int x, int y) { + Graphics2D g2d = (Graphics2D) g.create(); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Draw a colored circle as placeholder + Color color = getPlaceholderColor(type); + g2d.setColor(color); + g2d.fillOval(x + 2, y + 2, size - 4, size - 4); + + // Draw first letter of icon name + g2d.setColor(Color.WHITE); + g2d.setFont(g2d.getFont().deriveFont(Font.BOLD, size * 0.5f)); + String letter = type.getName().substring(0, 1).toUpperCase(); + FontMetrics fm = g2d.getFontMetrics(); + int textX = x + (size - fm.stringWidth(letter)) / 2; + int textY = y + (size + fm.getAscent() - fm.getDescent()) / 2; + g2d.drawString(letter, textX, textY); + + g2d.dispose(); + } + + @Override + public int getIconWidth() { + return size; + } + + @Override + public int getIconHeight() { + return size; + } + }; + } + + /** + * Gets a color for the placeholder icon based on icon type. + */ + private static Color getPlaceholderColor(IconType type) { + return switch (type) { + case CONNECTED -> new Color(46, 204, 113); // Green + case DISCONNECTED, STOP, CLEAR -> new Color(231, 76, 60); // Red + case PLAY, SKIP -> new Color(52, 152, 219); // Blue + case PAUSE -> new Color(241, 196, 15); // Yellow + case SETTINGS -> new Color(155, 89, 182); // Purple + case CONSOLE -> new Color(52, 73, 94); // Dark blue-gray + case STATUS -> new Color(26, 188, 156); // Teal + default -> new Color(149, 165, 166); // Gray + }; + } + + /** + * Clears the icon cache. + */ + public static void clearCache() { + iconCache.clear(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/components/StatusBar.java b/src/main/java/com/jagrosh/jmusicbot/gui/components/StatusBar.java new file mode 100644 index 0000000..476dc8f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/components/StatusBar.java @@ -0,0 +1,122 @@ +/* + * 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.gui.components; + +import com.jagrosh.jmusicbot.gui.model.BotStatusData; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; + +/** + * Status bar component that displays connection status and bot statistics. + * Shows at the bottom of the main frame. + * + * @author Arif Banai (arif-banai) + */ +public class StatusBar extends JPanel { + + private final JLabel connectionLabel; + private final JLabel guildsLabel; + private final JLabel voiceLabel; + private final JLabel uptimeLabel; + private final JLabel memoryLabel; + + private static final Color CONNECTED_COLOR = new Color(46, 204, 113); // Green + private static final Color DISCONNECTED_COLOR = new Color(231, 76, 60); // Red + + public StatusBar() { + setLayout(new BorderLayout()); + setBorder(new EmptyBorder(4, 8, 4, 8)); + + // Left section - connection status + JPanel leftPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0)); + connectionLabel = createStatusLabel("Disconnected"); + guildsLabel = createStatusLabel("Guilds: 0"); + voiceLabel = createStatusLabel("Voice: 0"); + uptimeLabel = createStatusLabel("Uptime: --"); + + leftPanel.add(connectionLabel); + leftPanel.add(createSeparator()); + leftPanel.add(guildsLabel); + leftPanel.add(createSeparator()); + leftPanel.add(voiceLabel); + leftPanel.add(createSeparator()); + leftPanel.add(uptimeLabel); + + // Right section - memory usage + JPanel rightPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0)); + memoryLabel = createStatusLabel("Memory: --"); + rightPanel.add(memoryLabel); + + add(leftPanel, BorderLayout.WEST); + add(rightPanel, BorderLayout.EAST); + + // Start memory monitoring + startMemoryMonitor(); + } + + private JLabel createStatusLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(Font.PLAIN, 11f)); + return label; + } + + private JSeparator createSeparator() { + JSeparator separator = new JSeparator(SwingConstants.VERTICAL); + separator.setPreferredSize(new Dimension(1, 16)); + return separator; + } + + /** + * Updates the status bar with current bot status data. + * + * @param statusData the shared status data + * @param uptimeString formatted uptime string + */ + public void updateStatus(BotStatusData statusData, String uptimeString) { + if (statusData.connected()) { + connectionLabel.setText("Connected"); + connectionLabel.setForeground(CONNECTED_COLOR); + } else { + connectionLabel.setText("Disconnected"); + connectionLabel.setForeground(DISCONNECTED_COLOR); + } + + guildsLabel.setText("Guilds: " + statusData.guildCount()); + voiceLabel.setText("Voice: " + statusData.voiceConnections()); + uptimeLabel.setText("Uptime: " + uptimeString); + } + + /** + * Starts a timer to periodically update memory usage. + */ + private void startMemoryMonitor() { + Timer memoryTimer = new Timer(5000, e -> updateMemoryUsage()); + memoryTimer.setInitialDelay(0); + memoryTimer.start(); + } + + /** + * Updates the memory usage display. + */ + private void updateMemoryUsage() { + Runtime runtime = Runtime.getRuntime(); + long usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024); + long maxMemory = runtime.maxMemory() / (1024 * 1024); + memoryLabel.setText(String.format("Memory: %d/%d MB", usedMemory, maxMemory)); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/components/StyledComponents.java b/src/main/java/com/jagrosh/jmusicbot/gui/components/StyledComponents.java new file mode 100644 index 0000000..a7be91e --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/components/StyledComponents.java @@ -0,0 +1,323 @@ +/* + * 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.gui.components; + +import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import java.awt.*; + +/** + * Factory methods for creating consistently styled Swing components. + * Ensures uniform appearance across the application. + * + * @author Arif Banai (arif-banai) + */ +public final class StyledComponents { + + // Standard spacing values + public static final int PADDING_SMALL = 4; + public static final int PADDING_MEDIUM = 8; + public static final int PADDING_LARGE = 16; + + // Standard font sizes + public static final float FONT_SMALL = 10f; + public static final float FONT_NORMAL = 12f; + public static final float FONT_LARGE = 14f; + public static final float FONT_HEADING = 16f; + + private StyledComponents() { + // Utility class - prevent instantiation + } + + // ============ Buttons ============ + + /** + * Creates a standard button with text. + */ + public static JButton createButton(String text) { + return new JButton(text); + } + + /** + * Creates a button with text and icon. + */ + public static JButton createButton(String text, Icon icon) { + JButton button = new JButton(text, icon); + button.setIconTextGap(PADDING_SMALL); + return button; + } + + /** + * Creates an icon-only button with tooltip. + */ + public static JButton createIconButton(Icon icon, String tooltip) { + JButton button = new JButton(icon); + button.setToolTipText(tooltip); + button.setFocusPainted(false); + return button; + } + + /** + * Creates a primary action button (styled for emphasis). + */ + public static JButton createPrimaryButton(String text) { + JButton button = new JButton(text); + button.putClientProperty("JButton.buttonType", "default"); + return button; + } + + // ============ Labels ============ + + /** + * Creates a standard label. + */ + public static JLabel createLabel(String text) { + return new JLabel(text); + } + + /** + * Creates a label with icon. + */ + public static JLabel createLabel(String text, Icon icon) { + JLabel label = new JLabel(text, icon, SwingConstants.LEADING); + label.setIconTextGap(PADDING_SMALL); + return label; + } + + /** + * Creates a heading label (larger, bold text). + */ + public static JLabel createHeadingLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(Font.BOLD, FONT_HEADING)); + return label; + } + + /** + * Creates a secondary/muted label (smaller, gray text). + */ + public static JLabel createSecondaryLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(FONT_SMALL)); + label.setForeground(Color.GRAY); + return label; + } + + /** + * Creates an error label (red text). + */ + public static JLabel createErrorLabel(String text) { + JLabel label = new JLabel(text); + label.setForeground(new Color(231, 76, 60)); + return label; + } + + /** + * Creates a success label (green text). + */ + public static JLabel createSuccessLabel(String text) { + JLabel label = new JLabel(text); + label.setForeground(new Color(46, 204, 113)); + return label; + } + + // ============ Panels ============ + + /** + * Creates a panel with standard padding. + */ + public static JPanel createPaddedPanel() { + JPanel panel = new JPanel(); + panel.setBorder(createPaddingBorder()); + return panel; + } + + /** + * Creates a panel with specified layout and standard padding. + */ + public static JPanel createPaddedPanel(LayoutManager layout) { + JPanel panel = new JPanel(layout); + panel.setBorder(createPaddingBorder()); + return panel; + } + + /** + * Creates a titled panel with border. + */ + public static JPanel createTitledPanel(String title) { + JPanel panel = new JPanel(); + panel.setBorder(createTitledBorder(title)); + return panel; + } + + /** + * Creates a titled panel with specified layout. + */ + public static JPanel createTitledPanel(String title, LayoutManager layout) { + JPanel panel = new JPanel(layout); + panel.setBorder(createTitledBorder(title)); + return panel; + } + + /** + * Creates a horizontal flow panel (left-aligned). + */ + public static JPanel createHorizontalPanel() { + return new JPanel(new FlowLayout(FlowLayout.LEFT, PADDING_SMALL, 0)); + } + + /** + * Creates a vertical box panel. + */ + public static JPanel createVerticalPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + return panel; + } + + // ============ Scroll Panes ============ + + /** + * Creates a scroll pane with standard settings. + */ + public static JScrollPane createScrollPane(Component view) { + JScrollPane scrollPane = new JScrollPane(view); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); + return scrollPane; + } + + /** + * Creates a scroll pane with titled border. + */ + public static JScrollPane createTitledScrollPane(Component view, String title) { + JScrollPane scrollPane = createScrollPane(view); + scrollPane.setBorder(createTitledBorder(title)); + return scrollPane; + } + + // ============ Text Components ============ + + /** + * Creates a standard text field. + */ + public static JTextField createTextField() { + return new JTextField(); + } + + /** + * Creates a text field with specified columns. + */ + public static JTextField createTextField(int columns) { + return new JTextField(columns); + } + + /** + * Creates a read-only text area with monospace font. + */ + public static JTextArea createConsoleTextArea() { + JTextArea textArea = new JTextArea(); + textArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + textArea.setEditable(false); + textArea.setLineWrap(true); + textArea.setWrapStyleWord(true); + return textArea; + } + + // ============ Borders ============ + + /** + * Creates a standard padding border. + */ + public static Border createPaddingBorder() { + return new EmptyBorder(PADDING_MEDIUM, PADDING_MEDIUM, PADDING_MEDIUM, PADDING_MEDIUM); + } + + /** + * Creates a padding border with custom size. + */ + public static Border createPaddingBorder(int padding) { + return new EmptyBorder(padding, padding, padding, padding); + } + + /** + * Creates a titled border. + */ + public static Border createTitledBorder(String title) { + return new TitledBorder(title); + } + + /** + * Creates a titled border with inner padding. + */ + public static Border createTitledBorderWithPadding(String title) { + return new CompoundBorder( + new TitledBorder(title), + new EmptyBorder(PADDING_SMALL, PADDING_SMALL, PADDING_SMALL, PADDING_SMALL) + ); + } + + // ============ Separators ============ + + /** + * Creates a horizontal separator. + */ + public static JSeparator createHorizontalSeparator() { + return new JSeparator(SwingConstants.HORIZONTAL); + } + + /** + * Creates a vertical separator with specified height. + */ + public static JSeparator createVerticalSeparator(int height) { + JSeparator separator = new JSeparator(SwingConstants.VERTICAL); + separator.setPreferredSize(new Dimension(1, height)); + return separator; + } + + // ============ Spacing ============ + + /** + * Creates a horizontal spacer. + */ + public static Component createHorizontalStrut(int width) { + return Box.createHorizontalStrut(width); + } + + /** + * Creates a vertical spacer. + */ + public static Component createVerticalStrut(int height) { + return Box.createVerticalStrut(height); + } + + /** + * Creates flexible horizontal glue. + */ + public static Component createHorizontalGlue() { + return Box.createHorizontalGlue(); + } + + /** + * Creates flexible vertical glue. + */ + public static Component createVerticalGlue() { + return Box.createVerticalGlue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/model/BotStatusData.java b/src/main/java/com/jagrosh/jmusicbot/gui/model/BotStatusData.java new file mode 100644 index 0000000..07ea35a --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/model/BotStatusData.java @@ -0,0 +1,114 @@ +/* + * 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.gui.model; + +import com.jagrosh.jmusicbot.Bot; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +/** + * Immutable record holding bot status data for GUI display. + * Single source of truth for status information used by StatusBar and StatusPanel. + * + * @param connected whether the bot is connected to Discord + * @param jdaStatus the JDA connection status + * @param guildCount number of guilds the bot is in + * @param voiceConnections number of active voice connections + * @param guilds list of connected guilds (may be empty if disconnected) + * @param uptimeString formatted uptime string + * + * @author Arif Banai (arif-banai) + */ +public record BotStatusData( + boolean connected, + JDA.Status jdaStatus, + int guildCount, + int voiceConnections, + List<Guild> guilds, + String uptimeString +) { + + /** + * Creates a disconnected status with no data. + */ + public static BotStatusData disconnected() { + return new BotStatusData(false, null, 0, 0, List.of(), "--"); + } + + /** + * Fetches current status data from the bot. + * + * @param bot the bot instance + * @param startTime the application start time for uptime calculation + * @return current status data + */ + public static BotStatusData fromBot(Bot bot, Instant startTime) { + String uptimeStr = formatUptime(startTime); + + if (bot == null) { + return new BotStatusData(false, null, 0, 0, List.of(), uptimeStr); + } + + JDA jda = bot.getJDA(); + if (jda == null) { + return new BotStatusData(false, null, 0, 0, List.of(), uptimeStr); + } + + JDA.Status status = jda.getStatus(); + boolean connected = status == JDA.Status.CONNECTED; + + if (!connected) { + return new BotStatusData(false, status, 0, 0, List.of(), uptimeStr); + } + + List<Guild> guilds = jda.getGuilds(); + int guildCount = guilds.size(); + + // Count active voice connections + int voiceConnections = (int) guilds.stream() + .filter(g -> g.getAudioManager().isConnected()) + .count(); + + return new BotStatusData(true, status, guildCount, voiceConnections, guilds, uptimeStr); + } + + /** + * Formats uptime duration as a human-readable string. + */ + private static String formatUptime(Instant startTime) { + if (startTime == null) { + return "--"; + } + + Duration uptime = Duration.between(startTime, Instant.now()); + long days = uptime.toDays(); + long hours = uptime.toHoursPart(); + long minutes = uptime.toMinutesPart(); + long seconds = uptime.toSecondsPart(); + + if (days > 0) { + return String.format("%dd %dh %dm", days, hours, minutes); + } else if (hours > 0) { + return String.format("%dh %dm %ds", hours, minutes, seconds); + } else { + return String.format("%dm %ds", minutes, seconds); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/panels/ConfigPanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/panels/ConfigPanel.java new file mode 100644 index 0000000..d312e85 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/panels/ConfigPanel.java @@ -0,0 +1,606 @@ +/* + * 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.gui.panels; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.config.io.ConfigIO; +import com.jagrosh.jmusicbot.config.update.ConfigUpdater; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import java.awt.*; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration panel for viewing and editing bot configuration. + * Allows editing of safe configuration options and saving to config.txt. + * Sensitive options (token, owner) and dangerous options (eval) are excluded. + * + * @author Arif Banai (arif-banai) + */ +public class ConfigPanel extends JPanel { + + private static final Logger LOG = LoggerFactory.getLogger(ConfigPanel.class); + + private final BotConfig config; + + // Commands section + private final JTextField prefixField; + private final JTextField altPrefixField; + private final JTextField helpWordField; + + // Presence section + private final JTextField gameField; + private final JComboBox<String> statusComboBox; + private final JCheckBox songInStatusCheckBox; + + // Voice section + private final JCheckBox stayInChannelCheckBox; + private final JSpinner aloneTimeSpinner; + + // Playback section + private final JSpinner maxSecondsSpinner; + private final JSpinner maxYTPlaylistPagesSpinner; + private final JSpinner skipRatioSpinner; + private final JCheckBox useYouTubeOAuthCheckBox; + + // UI/Emojis section + private final JTextField successEmojiField; + private final JTextField warningEmojiField; + private final JTextField errorEmojiField; + private final JTextField loadingEmojiField; + private final JTextField searchingEmojiField; + + // Other section + private final JCheckBox npImagesCheckBox; + private final JCheckBox updateAlertsCheckBox; + private final JComboBox<String> logLevelComboBox; + private final JTextField playlistsFolderField; + + /** + * Creates the configuration panel. + * + * @param bot the bot instance + */ + public ConfigPanel(Bot bot) { + super(new BorderLayout(8, 8)); + setBorder(new EmptyBorder(8, 8, 8, 8)); + + this.config = bot.getConfig(); + + // Initialize all input components + // Commands + prefixField = new JTextField(15); + altPrefixField = new JTextField(15); + helpWordField = new JTextField(15); + + // Presence + gameField = new JTextField(20); + statusComboBox = new JComboBox<>(new String[]{"ONLINE", "IDLE", "DND", "INVISIBLE"}); + songInStatusCheckBox = new JCheckBox("Show current song in status"); + + // Voice + stayInChannelCheckBox = new JCheckBox("Stay in voice channel after queue ends"); + aloneTimeSpinner = new JSpinner(new SpinnerNumberModel(0, 0, 3600, 1)); + + // Playback + maxSecondsSpinner = new JSpinner(new SpinnerNumberModel(0, 0, 86400, 60)); + maxYTPlaylistPagesSpinner = new JSpinner(new SpinnerNumberModel(10, 1, 100, 1)); + skipRatioSpinner = new JSpinner(new SpinnerNumberModel(0.55, 0.0, 1.0, 0.05)); + useYouTubeOAuthCheckBox = new JCheckBox("Use YouTube OAuth for playback"); + + // UI/Emojis + successEmojiField = new JTextField(5); + warningEmojiField = new JTextField(5); + errorEmojiField = new JTextField(5); + loadingEmojiField = new JTextField(5); + searchingEmojiField = new JTextField(5); + + // Other + npImagesCheckBox = new JCheckBox("Show YouTube thumbnails in now playing"); + updateAlertsCheckBox = new JCheckBox("Alert owner about updates"); + logLevelComboBox = new JComboBox<>(new String[]{"off", "error", "warn", "info", "debug", "trace", "all"}); + playlistsFolderField = new JTextField(20); + + // Build UI + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + + contentPanel.add(createCommandsSection()); + contentPanel.add(Box.createVerticalStrut(8)); + contentPanel.add(createPresenceSection()); + contentPanel.add(Box.createVerticalStrut(8)); + contentPanel.add(createVoiceSection()); + contentPanel.add(Box.createVerticalStrut(8)); + contentPanel.add(createPlaybackSection()); + contentPanel.add(Box.createVerticalStrut(8)); + contentPanel.add(createEmojisSection()); + contentPanel.add(Box.createVerticalStrut(8)); + contentPanel.add(createOtherSection()); + contentPanel.add(Box.createVerticalGlue()); + + // Scroll pane for content + JScrollPane scrollPane = new JScrollPane(contentPanel); + scrollPane.setBorder(null); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + add(scrollPane, BorderLayout.CENTER); + + // Bottom panel with buttons and warning + add(createBottomPanel(), BorderLayout.SOUTH); + + // Load current values + loadCurrentValues(); + } + + /** + * Creates the Commands configuration section. + */ + private JPanel createCommandsSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("Commands")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + // Row 0: Prefix and Alt Prefix + gbc.gridx = 0; gbc.gridy = 0; + panel.add(new JLabel("Prefix:"), gbc); + gbc.gridx = 1; + panel.add(prefixField, gbc); + + gbc.gridx = 2; + panel.add(new JLabel("Alt Prefix:"), gbc); + gbc.gridx = 3; + gbc.weightx = 1.0; + panel.add(altPrefixField, gbc); + + // Row 1: Help Word + gbc.gridx = 0; gbc.gridy = 1; + gbc.weightx = 0; + panel.add(new JLabel("Help Word:"), gbc); + gbc.gridx = 1; + panel.add(helpWordField, gbc); + + return panel; + } + + /** + * Creates the Presence configuration section. + */ + private JPanel createPresenceSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("Presence")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + // Row 0: Game Status + gbc.gridx = 0; gbc.gridy = 0; + panel.add(new JLabel("Game Status:"), gbc); + gbc.gridx = 1; + gbc.gridwidth = 3; + gbc.fill = GridBagConstraints.HORIZONTAL; + panel.add(gameField, gbc); + + // Row 1: Online Status and Song in Status + gbc.gridx = 0; gbc.gridy = 1; + gbc.gridwidth = 1; + gbc.fill = GridBagConstraints.NONE; + panel.add(new JLabel("Online Status:"), gbc); + gbc.gridx = 1; + panel.add(statusComboBox, gbc); + + gbc.gridx = 2; + gbc.gridwidth = 2; + gbc.weightx = 1.0; + panel.add(songInStatusCheckBox, gbc); + + return panel; + } + + /** + * Creates the Voice configuration section. + */ + private JPanel createVoiceSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("Voice")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 80)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + // Row 0: Stay in Channel + gbc.gridx = 0; gbc.gridy = 0; + gbc.gridwidth = 2; + panel.add(stayInChannelCheckBox, gbc); + + // Row 1: Alone Time + gbc.gridx = 0; gbc.gridy = 1; + gbc.gridwidth = 1; + panel.add(new JLabel("Alone Time Until Stop (seconds):"), gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(aloneTimeSpinner, gbc); + + return panel; + } + + /** + * Creates the Playback configuration section. + */ + private JPanel createPlaybackSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("Playback")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 120)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + // Row 0: Max Seconds and Skip Ratio + gbc.gridx = 0; gbc.gridy = 0; + panel.add(new JLabel("Max Track Seconds (0=unlimited):"), gbc); + gbc.gridx = 1; + panel.add(maxSecondsSpinner, gbc); + + gbc.gridx = 2; + panel.add(new JLabel("Skip Ratio:"), gbc); + gbc.gridx = 3; + gbc.weightx = 1.0; + panel.add(skipRatioSpinner, gbc); + + // Row 1: Max YT Playlist Pages + gbc.gridx = 0; gbc.gridy = 1; + gbc.weightx = 0; + panel.add(new JLabel("Max YouTube Playlist Pages:"), gbc); + gbc.gridx = 1; + panel.add(maxYTPlaylistPagesSpinner, gbc); + + // Row 2: YouTube OAuth + gbc.gridx = 0; gbc.gridy = 2; + gbc.gridwidth = 4; + panel.add(useYouTubeOAuthCheckBox, gbc); + + return panel; + } + + /** + * Creates the UI/Emojis configuration section. + */ + private JPanel createEmojisSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("UI / Emojis")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + // Row 0: Success, Warning, Error + gbc.gridx = 0; gbc.gridy = 0; + panel.add(new JLabel("Success:"), gbc); + gbc.gridx = 1; + panel.add(successEmojiField, gbc); + + gbc.gridx = 2; + panel.add(new JLabel("Warning:"), gbc); + gbc.gridx = 3; + panel.add(warningEmojiField, gbc); + + gbc.gridx = 4; + panel.add(new JLabel("Error:"), gbc); + gbc.gridx = 5; + gbc.weightx = 1.0; + panel.add(errorEmojiField, gbc); + + // Row 1: Loading, Searching + gbc.gridx = 0; gbc.gridy = 1; + gbc.weightx = 0; + panel.add(new JLabel("Loading:"), gbc); + gbc.gridx = 1; + panel.add(loadingEmojiField, gbc); + + gbc.gridx = 2; + panel.add(new JLabel("Searching:"), gbc); + gbc.gridx = 3; + panel.add(searchingEmojiField, gbc); + + return panel; + } + + /** + * Creates the Other configuration section. + */ + private JPanel createOtherSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("Other")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 120)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + // Row 0: NP Images, Update Alerts + gbc.gridx = 0; gbc.gridy = 0; + panel.add(npImagesCheckBox, gbc); + + gbc.gridx = 1; + gbc.weightx = 1.0; + panel.add(updateAlertsCheckBox, gbc); + + // Row 1: Log Level + gbc.gridx = 0; gbc.gridy = 1; + gbc.weightx = 0; + panel.add(new JLabel("Log Level:"), gbc); + gbc.gridx = 1; + panel.add(logLevelComboBox, gbc); + + // Row 2: Playlists Folder + gbc.gridx = 0; gbc.gridy = 2; + panel.add(new JLabel("Playlists Folder:"), gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + panel.add(playlistsFolderField, gbc); + + return panel; + } + + /** + * Creates the bottom panel with save/reset buttons and warning. + */ + private JPanel createBottomPanel() { + JPanel panel = new JPanel(new BorderLayout(8, 8)); + panel.setBorder(new EmptyBorder(8, 0, 0, 0)); + + // Buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); + + JButton saveButton = new JButton("Save Changes"); + saveButton.addActionListener(e -> saveConfiguration()); + buttonPanel.add(saveButton); + + JButton resetButton = new JButton("Reset to Current"); + resetButton.addActionListener(e -> loadCurrentValues()); + buttonPanel.add(resetButton); + + panel.add(buttonPanel, BorderLayout.CENTER); + + // Warning label + JLabel warningLabel = new JLabel("<html><i>Note: Changes require bot restart to take effect.</i></html>"); + warningLabel.setFont(warningLabel.getFont().deriveFont(10f)); + warningLabel.setForeground(Color.GRAY); + warningLabel.setBorder(new EmptyBorder(4, 8, 0, 8)); + panel.add(warningLabel, BorderLayout.SOUTH); + + return panel; + } + + /** + * Loads current configuration values into the input fields. + */ + private void loadCurrentValues() { + // Commands + prefixField.setText(config.getPrefix()); + String altPrefix = config.getAltPrefix(); + altPrefixField.setText(altPrefix != null ? altPrefix : "NONE"); + helpWordField.setText(config.getHelp()); + + // Presence + String gameStatus = config.isGameNone() ? "NONE" : + (config.getGame() != null ? config.getGame().getName() : "DEFAULT"); + gameField.setText(gameStatus); + statusComboBox.setSelectedItem(config.getStatus().name()); + songInStatusCheckBox.setSelected(config.getSongInStatus()); + + // Voice + stayInChannelCheckBox.setSelected(config.getStay()); + aloneTimeSpinner.setValue((int) config.getAloneTimeUntilStop()); + + // Playback + maxSecondsSpinner.setValue((int) config.getMaxSeconds()); + maxYTPlaylistPagesSpinner.setValue(config.getMaxYTPlaylistPages()); + skipRatioSpinner.setValue(config.getSkipRatio()); + useYouTubeOAuthCheckBox.setSelected(config.useYouTubeOauth()); + + // UI/Emojis + successEmojiField.setText(config.getSuccess()); + warningEmojiField.setText(config.getWarning()); + errorEmojiField.setText(config.getError()); + loadingEmojiField.setText(config.getLoading()); + searchingEmojiField.setText(config.getSearching()); + + // Other + npImagesCheckBox.setSelected(config.useNPImages()); + updateAlertsCheckBox.setSelected(config.useUpdateAlerts()); + logLevelComboBox.setSelectedItem(config.getLogLevel()); + playlistsFolderField.setText(config.getPlaylistsFolder()); + } + + /** + * Saves the configuration to the config file. + */ + private void saveConfiguration() { + try { + Path configPath = ConfigIO.getConfigPath(); + + // Read current config file + String content = Files.readString(configPath, StandardCharsets.UTF_8); + + // Build updates map + Map<String, String> updates = buildUpdatesMap(); + + // Apply updates to config content + String updatedContent = applyConfigUpdates(content, updates); + + // Create backup using the same resolution logic as config migration + Path backupPath = ConfigUpdater.findAvailableBackupPath(configPath); + Files.copy(configPath, backupPath); + + // Write updated config + ConfigIO.writeConfigFile(configPath, updatedContent); + + LOG.info("Configuration saved successfully to {}", configPath); + + JOptionPane.showMessageDialog( + this, + "Configuration saved successfully!\n\nPlease restart the bot for changes to take effect.\n\nBackup created: " + backupPath.getFileName(), + "Configuration Saved", + JOptionPane.INFORMATION_MESSAGE + ); + + } catch (IOException ex) { + LOG.error("Failed to save configuration", ex); + JOptionPane.showMessageDialog( + this, + "Failed to save configuration: " + ex.getMessage(), + "Save Error", + JOptionPane.ERROR_MESSAGE + ); + } + } + + /** + * Builds a map of config keys to their new values. + */ + private Map<String, String> buildUpdatesMap() { + Map<String, String> updates = new HashMap<>(); + + // Commands + updates.put("commands.prefix", quoteString(prefixField.getText())); + updates.put("commands.altPrefix", quoteString(altPrefixField.getText())); + updates.put("commands.help", quoteString(helpWordField.getText())); + + // Presence + updates.put("presence.game", quoteString(gameField.getText())); + updates.put("presence.status", quoteString((String) statusComboBox.getSelectedItem())); + updates.put("presence.songInStatus", String.valueOf(songInStatusCheckBox.isSelected())); + + // Voice + updates.put("voice.stayInChannel", String.valueOf(stayInChannelCheckBox.isSelected())); + updates.put("voice.aloneTimeUntilStopSeconds", String.valueOf(aloneTimeSpinner.getValue())); + + // Playback + updates.put("playback.maxTrackSeconds", String.valueOf(maxSecondsSpinner.getValue())); + updates.put("playback.maxYouTubePlaylistPages", String.valueOf(maxYTPlaylistPagesSpinner.getValue())); + updates.put("playback.skipRatio", String.valueOf(skipRatioSpinner.getValue())); + updates.put("playback.youtube.useOAuth", String.valueOf(useYouTubeOAuthCheckBox.isSelected())); + + // UI/Emojis + updates.put("ui.emojis.success", quoteString(successEmojiField.getText())); + updates.put("ui.emojis.warning", quoteString(warningEmojiField.getText())); + updates.put("ui.emojis.error", quoteString(errorEmojiField.getText())); + updates.put("ui.emojis.loading", quoteString(loadingEmojiField.getText())); + updates.put("ui.emojis.searching", quoteString(searchingEmojiField.getText())); + + // Other + updates.put("nowPlaying.images", String.valueOf(npImagesCheckBox.isSelected())); + updates.put("updates.alerts", String.valueOf(updateAlertsCheckBox.isSelected())); + updates.put("logging.level", quoteString((String) logLevelComboBox.getSelectedItem())); + updates.put("paths.playlistsFolder", quoteString(playlistsFolderField.getText())); + + return updates; + } + + /** + * Quotes a string value for HOCON format. + */ + private String quoteString(String value) { + if (value == null) { + return "\"\""; + } + // Escape backslashes and quotes + String escaped = value.replace("\\", "\\\\").replace("\"", "\\\""); + return "\"" + escaped + "\""; + } + + /** + * Applies config updates to the content using regex pattern matching. + * Handles both nested (e.g., presence.status) and flat key formats. + */ + private String applyConfigUpdates(String content, Map<String, String> updates) { + String result = content; + + for (Map.Entry<String, String> entry : updates.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + // Extract the leaf key (e.g., "status" from "presence.status") + String leafKey = key.contains(".") ? key.substring(key.lastIndexOf('.') + 1) : key; + + // Try to find and replace the value in the config + // Match pattern: leafKey = value (with optional quotes and whitespace) + String pattern = "(?m)^(\\s*" + java.util.regex.Pattern.quote(leafKey) + "\\s*=\\s*)([^\r\n]+)"; + java.util.regex.Pattern regex = java.util.regex.Pattern.compile(pattern); + java.util.regex.Matcher matcher = regex.matcher(result); + + // Find the right occurrence based on context (section) + String section = key.contains(".") ? key.substring(0, key.indexOf('.')) : null; + + if (section != null) { + // Find section and update within it + result = updateKeyInSection(result, section, leafKey, value); + } else { + // Simple replacement for top-level keys + result = matcher.replaceFirst("$1" + java.util.regex.Matcher.quoteReplacement(value)); + } + } + + return result; + } + + /** + * Updates a key within a specific section of the config. + */ + private String updateKeyInSection(String content, String section, String leafKey, String value) { + // Handle nested sections like "playback.youtube.useOAuth" + String[] sectionParts = section.split("\\."); + + // Find the section start + StringBuilder sectionPattern = new StringBuilder(); + for (String part : sectionParts) { + sectionPattern.append("(?s).*?").append(java.util.regex.Pattern.quote(part)).append("\\s*\\{"); + } + + java.util.regex.Pattern sectionRegex = java.util.regex.Pattern.compile( + "(?s)(" + java.util.regex.Pattern.quote(sectionParts[sectionParts.length - 1]) + "\\s*\\{[^}]*?" + + java.util.regex.Pattern.quote(leafKey) + "\\s*=\\s*)([^\r\n]+)" + ); + + java.util.regex.Matcher matcher = sectionRegex.matcher(content); + + if (matcher.find()) { + return matcher.replaceFirst("$1" + java.util.regex.Matcher.quoteReplacement(value)); + } + + // Fallback: simple pattern match for the key + String pattern = "(?m)^(\\s*" + java.util.regex.Pattern.quote(leafKey) + "\\s*=\\s*)([^\r\n]+)"; + return content.replaceFirst(pattern, "$1" + java.util.regex.Matcher.quoteReplacement(value)); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/panels/EnhancedTimelinePanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/panels/EnhancedTimelinePanel.java new file mode 100644 index 0000000..88a47d4 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/panels/EnhancedTimelinePanel.java @@ -0,0 +1,526 @@ +/* + * 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.gui.panels; + +import com.jagrosh.jmusicbot.audio.GCMonitor; +import com.jagrosh.jmusicbot.audio.PerformanceMetrics.*; +import com.jagrosh.jmusicbot.audio.VoiceConnectionMonitor; +import com.jagrosh.jmusicbot.audio.VoiceConnectionMonitor.VoiceEvent; + +import javax.swing.*; +import javax.swing.border.TitledBorder; +import java.awt.*; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Enhanced timeline panel with multi-layer correlation view. + * Displays track events, frame status, stutters, voice events, and system events + * on synchronized time axes for correlation analysis. + * + * <p>Data Layers (top to bottom): + * <ol> + * <li>Track Layer - Track start/end markers</li> + * <li>Frame Layer - Binary band showing provided (green) vs missed (red) frames</li> + * <li>Stutter Layer - Stutter circles + stuck event markers</li> + * <li>Voice Layer - Connect/disconnect/reconnect markers</li> + * <li>System Layer - GC markers</li> + * </ol> + * + * @author Arif Banai (arif-banai) + */ +public class EnhancedTimelinePanel extends JPanel { + + private static final Color GRAPH_BG = new Color(25, 25, 30); + private static final Color GRID_COLOR = new Color(50, 50, 55); + private static final Color GOOD_COLOR = new Color(46, 204, 113); + private static final Color WARNING_COLOR = new Color(241, 196, 15); + private static final Color CRITICAL_COLOR = new Color(231, 76, 60); + private static final Color VOICE_COLOR = new Color(100, 149, 237); // Cornflower blue + private static final Color GC_COLOR = new Color(155, 89, 182); // Purple + private static final Color TRACK_START_COLOR = new Color(46, 204, 113); // Green + private static final Color TRACK_END_COLOR = new Color(231, 76, 60); // Red + private static final Color TRACK_EXCEPTION_COLOR = new Color(241, 196, 15); // Yellow + + private static final int MIN_LAYER_HEIGHT = 26; + private static final int MARGIN_LEFT = 70; // Increased for better label visibility + private static final int MARGIN_RIGHT = 10; + private static final int MARGIN_TOP = 20; // Account for TitledBorder + private static final int MARGIN_BOTTOM = 22; + + private static final String[] LAYER_NAMES = {"Track", "Frames", "Events", "Voice", "System"}; + + private MetricsSnapshot snapshot; + private int windowSeconds = 60; + private long guildId; + + // Tooltip tracking + private String tooltipText = null; + + public EnhancedTimelinePanel() { + // Set minimum size but allow growth to fill available space + setPreferredSize(new Dimension(600, MIN_LAYER_HEIGHT * 5 + MARGIN_TOP + MARGIN_BOTTOM)); + setMinimumSize(new Dimension(300, MIN_LAYER_HEIGHT * 5 + MARGIN_TOP + MARGIN_BOTTOM)); + setBorder(new TitledBorder("Enhanced Timeline")); + + // Add mouse motion listener for tooltips + addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + updateTooltip(e.getX(), e.getY()); + } + }); + + setToolTipText(""); // Enable tooltips + } + + @Override + public String getToolTipText(MouseEvent event) { + if (tooltipText != null && !tooltipText.isEmpty()) { + return tooltipText; + } + return null; + } + + /** + * Updates the panel with new snapshot data. + */ + public void updateSnapshot(MetricsSnapshot snapshot, long guildId, int windowSeconds) { + this.snapshot = snapshot; + this.guildId = guildId; + this.windowSeconds = windowSeconds; + repaint(); + } + + private void updateTooltip(int mouseX, int mouseY) { + if (snapshot == null) { + tooltipText = null; + return; + } + + int graphW = getWidth() - MARGIN_LEFT - MARGIN_RIGHT; + if (mouseX < MARGIN_LEFT || mouseX > getWidth() - MARGIN_RIGHT) { + tooltipText = null; + return; + } + + // Calculate dynamic layer height (same as paintComponent) + int availableH = getHeight() - MARGIN_TOP - MARGIN_BOTTOM; + int layerH = Math.max(MIN_LAYER_HEIGHT, availableH / 5); + + long windowMs = windowSeconds * 1000L; + long now = System.currentTimeMillis(); + long startTime = now - windowMs; + + // Calculate time at mouse position + double relX = (mouseX - MARGIN_LEFT) / (double) graphW; + long timeAtMouse = startTime + (long) (relX * windowMs); + + // Determine which layer using dynamic height + int layerIndex = (mouseY - MARGIN_TOP) / layerH; + if (layerIndex < 0 || layerIndex >= 5) { + tooltipText = null; + return; + } + + SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); + StringBuilder sb = new StringBuilder(); + sb.append("<html>"); + sb.append("<b>Time:</b> ").append(sdf.format(new Date(timeAtMouse))).append("<br>"); + + // Find nearby events based on layer + switch (layerIndex) { + case 0 -> findNearbyTrackEvent(timeAtMouse, sb, sdf); + case 1 -> findNearbyFrameInfo(timeAtMouse, sb); + case 2 -> findNearbyStutterEvent(timeAtMouse, sb, sdf); + case 3 -> findNearbyVoiceEvent(timeAtMouse, sb, sdf); + case 4 -> findNearbyGCEvent(timeAtMouse, sb, sdf); + } + + sb.append("</html>"); + tooltipText = sb.length() > 20 ? sb.toString() : null; + } + + private void findNearbyTrackEvent(long time, StringBuilder sb, SimpleDateFormat sdf) { + if (snapshot.trackEvents() == null) return; + + for (TrackEvent e : snapshot.trackEvents()) { + if (Math.abs(e.timestamp() - time) < 2000) { + sb.append("<b>Track:</b> ").append(e.type().name()).append("<br>"); + if (e.trackTitle() != null) { + sb.append(truncate(e.trackTitle(), 30)).append("<br>"); + } + break; + } + } + } + + private void findNearbyFrameInfo(long time, StringBuilder sb) { + // Estimate frame index based on time + FrameMetric[] frames = snapshot.frameHistory(); + if (frames == null || frames.length == 0) return; + + // Find frames near this time + int providedCount = 0; + int missedCount = 0; + for (FrameMetric f : frames) { + if (Math.abs(f.timestamp() - time) < 500) { + if (f.frameProvided()) providedCount++; + else missedCount++; + } + } + + if (providedCount + missedCount > 0) { + sb.append("<b>Frames (±0.5s):</b><br>"); + sb.append("Provided: ").append(providedCount).append("<br>"); + sb.append("Missed: ").append(missedCount).append("<br>"); + } + } + + private void findNearbyStutterEvent(long time, StringBuilder sb, SimpleDateFormat sdf) { + for (StutterEvent e : snapshot.stutterEvents()) { + if (Math.abs(e.timestamp() - time) < 2000) { + sb.append("<b>Stutter:</b><br>"); + sb.append("Missed: ").append(e.missedFrames()).append(" frames<br>"); + sb.append("Duration: ").append(e.durationMs()).append("ms<br>"); + sb.append("Severity: ").append(e.severity().name()).append("<br>"); + break; + } + } + + for (StuckEvent e : snapshot.stuckEvents()) { + if (Math.abs(e.timestamp() - time) < 2000) { + sb.append("<b>Stuck Event:</b><br>"); + sb.append("Threshold: ").append(e.thresholdMs()).append("ms<br>"); + if (e.trackTitle() != null) { + sb.append("Track: ").append(truncate(e.trackTitle(), 25)).append("<br>"); + } + break; + } + } + } + + private void findNearbyVoiceEvent(long time, StringBuilder sb, SimpleDateFormat sdf) { + VoiceEvent[] events = VoiceConnectionMonitor.getInstance().getRecentEvents(guildId, windowSeconds); + for (VoiceEvent e : events) { + if (Math.abs(e.timestamp() - time) < 2000) { + sb.append("<b>Voice:</b> ").append(e.type().name()).append("<br>"); + if (e.fromChannel() != null) { + sb.append("From: ").append(e.fromChannel()).append("<br>"); + } + if (e.toChannel() != null) { + sb.append("To: ").append(e.toChannel()).append("<br>"); + } + break; + } + } + } + + private void findNearbyGCEvent(long time, StringBuilder sb, SimpleDateFormat sdf) { + for (GCMonitor.GCEvent e : snapshot.gcEvents()) { + if (Math.abs(e.timestamp() - time) < 2000) { + sb.append("<b>GC Event:</b><br>"); + sb.append("Collector: ").append(e.collectorName()).append("<br>"); + sb.append("Duration: ").append(e.durationMs()).append("ms<br>"); + sb.append("Severity: ").append(e.severity().name()).append("<br>"); + break; + } + } + } + + private String truncate(String s, int maxLen) { + if (s == null) return ""; + return s.length() > maxLen ? s.substring(0, maxLen - 2) + ".." : s; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(); + int h = getHeight(); + int graphW = w - MARGIN_LEFT - MARGIN_RIGHT; + + // Dynamically calculate layer height to fill available space + int availableH = h - MARGIN_TOP - MARGIN_BOTTOM; + int layerH = Math.max(MIN_LAYER_HEIGHT, availableH / 5); + int graphH = layerH * 5; + + if (graphW < 50 || graphH < 50) { + g2.dispose(); + return; + } + + // Background + g2.setColor(GRAPH_BG); + g2.fillRect(MARGIN_LEFT, MARGIN_TOP, graphW, graphH); + + // Draw layer labels with larger, bolder font - scale font with layer height + float fontSize = Math.min(14f, Math.max(11f, layerH / 2.5f)); + g2.setFont(g2.getFont().deriveFont(Font.BOLD, fontSize)); + g2.setColor(Color.LIGHT_GRAY); + for (int i = 0; i < LAYER_NAMES.length; i++) { + int y = MARGIN_TOP + i * layerH + layerH / 2 + (int)(fontSize / 3); + g2.drawString(LAYER_NAMES[i], 5, y); + } + + // Draw layer separators + g2.setColor(GRID_COLOR); + for (int i = 0; i <= 5; i++) { + int y = MARGIN_TOP + i * layerH; + g2.drawLine(MARGIN_LEFT, y, MARGIN_LEFT + graphW, y); + } + + // Time grid with labels + int gridInterval = windowSeconds <= 30 ? 10 : windowSeconds <= 60 ? 15 : 30; + g2.setFont(g2.getFont().deriveFont(Font.PLAIN, 9f)); + for (int sec = 0; sec <= windowSeconds; sec += gridInterval) { + int x = MARGIN_LEFT + (int) ((double) sec / windowSeconds * graphW); + g2.setColor(GRID_COLOR); + g2.drawLine(x, MARGIN_TOP, x, MARGIN_TOP + graphH); + g2.setColor(Color.GRAY); + String label = "-" + (windowSeconds - sec) + "s"; + int labelWidth = g2.getFontMetrics().stringWidth(label); + g2.drawString(label, x - labelWidth / 2, MARGIN_TOP + graphH + 14); + } + + // Draw "now" marker with emphasis + g2.setColor(new Color(120, 120, 120)); + g2.setStroke(new BasicStroke(1.5f)); + g2.drawLine(MARGIN_LEFT + graphW, MARGIN_TOP, MARGIN_LEFT + graphW, MARGIN_TOP + graphH); + g2.setStroke(new BasicStroke(1)); + g2.setFont(g2.getFont().deriveFont(Font.BOLD, 9f)); + g2.drawString("now", MARGIN_LEFT + graphW - 14, MARGIN_TOP + graphH + 14); + + if (snapshot == null) { + g2.setColor(Color.GRAY); + g2.drawString("No data", w / 2 - 20, MARGIN_TOP + graphH / 2); + g2.dispose(); + return; + } + + long windowMs = windowSeconds * 1000L; + long now = System.currentTimeMillis(); + long startTime = now - windowMs; + + // Layer 0: Track events + drawTrackLayer(g2, MARGIN_LEFT, MARGIN_TOP, graphW, layerH, startTime, windowMs); + + // Layer 1: Frame binary band + drawFrameLayer(g2, MARGIN_LEFT, MARGIN_TOP + layerH, graphW, layerH, startTime, windowMs); + + // Layer 2: Stutter/Stuck events + drawEventLayer(g2, MARGIN_LEFT, MARGIN_TOP + 2 * layerH, graphW, layerH, startTime, windowMs); + + // Layer 3: Voice events + drawVoiceLayer(g2, MARGIN_LEFT, MARGIN_TOP + 3 * layerH, graphW, layerH, startTime, windowMs); + + // Layer 4: System (GC) events + drawSystemLayer(g2, MARGIN_LEFT, MARGIN_TOP + 4 * layerH, graphW, layerH, startTime, windowMs); + + // Border + g2.setColor(Color.GRAY); + g2.drawRect(MARGIN_LEFT, MARGIN_TOP, graphW, graphH); + + g2.dispose(); + } + + private void drawTrackLayer(Graphics2D g2, int x, int y, int w, int h, long startTime, long windowMs) { + if (snapshot.trackEvents() == null) return; + + int midY = y + h / 2; + + for (TrackEvent e : snapshot.trackEvents()) { + if (e.timestamp() < startTime) continue; + + int ex = x + (int) ((e.timestamp() - startTime) / (double) windowMs * w); + + switch (e.type()) { + case STARTED -> { + // Green diamond for start + g2.setColor(TRACK_START_COLOR); + int[] xp = {ex, ex + 5, ex, ex - 5}; + int[] yp = {midY - 5, midY, midY + 5, midY}; + g2.fillPolygon(xp, yp, 4); + } + case ENDED -> { + // Red square for end + g2.setColor(TRACK_END_COLOR); + g2.fillRect(ex - 4, midY - 4, 8, 8); + } + case EXCEPTION -> { + // Yellow warning triangle + g2.setColor(TRACK_EXCEPTION_COLOR); + int[] xp = {ex, ex + 5, ex - 5}; + int[] yp = {midY - 5, midY + 5, midY + 5}; + g2.fillPolygon(xp, yp, 3); + } + case STUCK -> { + // X marker + g2.setColor(CRITICAL_COLOR); + g2.setStroke(new BasicStroke(2)); + g2.drawLine(ex - 4, midY - 4, ex + 4, midY + 4); + g2.drawLine(ex - 4, midY + 4, ex + 4, midY - 4); + g2.setStroke(new BasicStroke(1)); + } + } + } + } + + private void drawFrameLayer(Graphics2D g2, int x, int y, int w, int h, long startTime, long windowMs) { + FrameMetric[] frames = snapshot.frameHistory(); + if (frames == null || frames.length == 0) return; + + int bandY = y + h / 4; + int bandH = h / 2; + + // Aggregate frames into buckets for visualization + int bucketCount = Math.min(w, frames.length); + int framesPerBucket = Math.max(1, frames.length / bucketCount); + + for (int i = 0; i < bucketCount; i++) { + int startIdx = i * framesPerBucket; + int endIdx = Math.min(startIdx + framesPerBucket, frames.length); + + int provided = 0; + int missed = 0; + for (int j = startIdx; j < endIdx; j++) { + if (frames[j].frameProvided()) provided++; + else missed++; + } + + int total = provided + missed; + if (total == 0) continue; + + double provideRatio = (double) provided / total; + int bx = x + (int) ((double) i / bucketCount * w); + int bw = Math.max(1, w / bucketCount); + + // Color based on ratio - more lenient thresholds for realistic display + if (provideRatio >= 0.995) { // 99.5%+ = green (excellent) + g2.setColor(GOOD_COLOR); + } else if (provideRatio >= 0.95) { // 95%+ = yellow (minor issues) + g2.setColor(WARNING_COLOR); + } else { // <95% = red (significant issues) + g2.setColor(CRITICAL_COLOR); + } + + g2.fillRect(bx, bandY, bw, bandH); + } + + // Draw border around band + g2.setColor(Color.DARK_GRAY); + g2.drawRect(x, bandY, w, bandH); + } + + private void drawEventLayer(Graphics2D g2, int x, int y, int w, int h, long startTime, long windowMs) { + int midY = y + h / 2; + + // Draw stutter events as circles + for (StutterEvent e : snapshot.stutterEvents()) { + if (e.timestamp() < startTime) continue; + + int ex = x + (int) ((e.timestamp() - startTime) / (double) windowMs * w); + int size = switch (e.severity()) { + case SEVERE -> 10; + case MODERATE -> 7; + case MINOR -> 5; + default -> 3; + }; + + g2.setColor(switch (e.severity()) { + case SEVERE -> CRITICAL_COLOR; + case MODERATE -> WARNING_COLOR; + case MINOR -> new Color(255, 200, 100); + default -> Color.GRAY; + }); + + g2.fillOval(ex - size / 2, midY - size / 2, size, size); + } + + // Draw stuck events as X markers + for (StuckEvent e : snapshot.stuckEvents()) { + if (e.timestamp() < startTime) continue; + + int ex = x + (int) ((e.timestamp() - startTime) / (double) windowMs * w); + + g2.setColor(CRITICAL_COLOR); + g2.setStroke(new BasicStroke(2)); + g2.drawLine(ex - 5, midY - 5, ex + 5, midY + 5); + g2.drawLine(ex - 5, midY + 5, ex + 5, midY - 5); + g2.setStroke(new BasicStroke(1)); + } + } + + private void drawVoiceLayer(Graphics2D g2, int x, int y, int w, int h, long startTime, long windowMs) { + VoiceEvent[] events = VoiceConnectionMonitor.getInstance().getRecentEvents(guildId, windowSeconds); + int midY = y + h / 2; + + g2.setFont(g2.getFont().deriveFont(Font.BOLD, 11f)); + + for (VoiceEvent e : events) { + if (e.timestamp() < startTime) continue; + + int ex = x + (int) ((e.timestamp() - startTime) / (double) windowMs * w); + + switch (e.type()) { + case CONNECTED -> { + g2.setColor(VOICE_COLOR); + g2.drawString("↑", ex - 4, midY + 4); + } + case DISCONNECTED -> { + g2.setColor(VOICE_COLOR.darker()); + g2.drawString("↓", ex - 4, midY + 4); + } + case CHANNEL_MOVE, RECONNECT -> { + g2.setColor(WARNING_COLOR); + g2.drawString("↻", ex - 5, midY + 4); + } + } + } + } + + private void drawSystemLayer(Graphics2D g2, int x, int y, int w, int h, long startTime, long windowMs) { + int midY = y + h / 2; + + // Draw GC events as triangles + for (GCMonitor.GCEvent e : snapshot.gcEvents()) { + if (e.timestamp() < startTime) continue; + + int ex = x + (int) ((e.timestamp() - startTime) / (double) windowMs * w); + + g2.setColor(switch (e.severity()) { + case SEVERE -> CRITICAL_COLOR; + case MODERATE -> WARNING_COLOR; + case MINOR, MINIMAL -> GC_COLOR; + }); + + int size = switch (e.severity()) { + case SEVERE -> 10; + case MODERATE -> 7; + default -> 5; + }; + + int[] xp = {ex, ex + size / 2, ex - size / 2}; + int[] yp = {midY - size / 2, midY + size / 2, midY + size / 2}; + g2.fillPolygon(xp, yp, 3); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/panels/PerformancePanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/panels/PerformancePanel.java new file mode 100644 index 0000000..d9b93ba --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/panels/PerformancePanel.java @@ -0,0 +1,887 @@ +/* + * 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.gui.panels; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.audio.GCMonitor; +import com.jagrosh.jmusicbot.audio.PerformanceMetrics; +import com.jagrosh.jmusicbot.audio.PerformanceMetrics.*; +import com.jagrosh.jmusicbot.audio.SystemHealthMonitor; +import com.jagrosh.jmusicbot.audio.SystemHealthMonitor.HealthSample; +import com.jagrosh.jmusicbot.audio.SystemHealthMonitor.HealthSnapshot; +import net.dv8tion.jda.api.entities.Guild; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import javax.swing.filechooser.FileNameExtensionFilter; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.List; + +/** + * Panel for visualizing audio processing performance. + * Uses JSplitPane for resizable graphs vs stats/events. + * + * @author Arif Banai (arif-banai) + */ +public class PerformancePanel extends JPanel { + + private static final Color GOOD_COLOR = new Color(46, 204, 113); + private static final Color WARNING_COLOR = new Color(241, 196, 15); + private static final Color CRITICAL_COLOR = new Color(231, 76, 60); + private static final Color GRAPH_BG = new Color(25, 25, 30); + private static final Color DRIFT_COLOR = new Color(241, 196, 15); // Yellow/amber for drift + + private final Bot bot; + private final JComboBox<GuildItem> guildSelector; + private final JComboBox<WindowOption> windowSelector; + + // Visualization panels + private final EnhancedTimelinePanel enhancedTimeline; + private final LatencyGraphPanel latencyGraph; + private final SchedulerDriftPanel driftChart; + + // Guild overview table + private final JTable guildOverviewTable; + private final DefaultTableModel guildOverviewModel; + + // Stats labels + private final JLabel healthLabel; + private final JLabel qualityScoreLabel; + private final JLabel durationLabel; + private final JLabel framesLabel; + private final JLabel missedLabel; + private final JLabel missRateLabel; + private final JLabel latencyLabel; + private final JLabel fpsLabel; + private final JLabel stuttersLabel; + private final JLabel stuckLabel; + private final JLabel gcCountLabel; + private final JLabel ttffLabel; + private final JLabel missRateWindowsLabel; + + // Event table + private final JTable eventTable; + private final DefaultTableModel eventTableModel; + + private MetricsSnapshot currentSnapshot; + private HealthSnapshot healthSnapshot; + private int selectedWindowSeconds = 30; + + private enum WindowOption { + SECONDS_10(10, "10 seconds"), + SECONDS_30(30, "30 seconds"), + MINUTE_1(60, "1 minute"), + MINUTE_2(120, "2 minutes"); + + private final int seconds; + private final String display; + + WindowOption(int seconds, String display) { + this.seconds = seconds; + this.display = display; + } + + public int getSeconds() { return seconds; } + + @Override + public String toString() { return display; } + } + + public PerformancePanel(Bot bot) { + super(new BorderLayout(4, 4)); + setBorder(new EmptyBorder(4, 4, 4, 4)); + this.bot = bot; + + // === TOP: Controls === + JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2)); + + topPanel.add(new JLabel("Guild:")); + guildSelector = new JComboBox<>(); + guildSelector.setPreferredSize(new Dimension(130, 22)); + guildSelector.addActionListener(e -> refreshMetrics()); + topPanel.add(guildSelector); + + topPanel.add(new JLabel("Window:")); + windowSelector = new JComboBox<>(WindowOption.values()); + windowSelector.setSelectedItem(WindowOption.SECONDS_30); + windowSelector.addActionListener(e -> { + WindowOption sel = (WindowOption) windowSelector.getSelectedItem(); + if (sel != null) { + selectedWindowSeconds = sel.getSeconds(); + refreshMetrics(); + } + }); + topPanel.add(windowSelector); + + JButton refreshBtn = new JButton("Refresh"); + refreshBtn.addActionListener(e -> refreshGuildList()); + topPanel.add(refreshBtn); + + topPanel.add(Box.createHorizontalStrut(8)); + healthLabel = new JLabel("●"); + healthLabel.setFont(healthLabel.getFont().deriveFont(Font.BOLD, 16f)); + topPanel.add(healthLabel); + + qualityScoreLabel = new JLabel("Quality: --%"); + qualityScoreLabel.setFont(qualityScoreLabel.getFont().deriveFont(Font.BOLD, 12f)); + topPanel.add(qualityScoreLabel); + + topPanel.add(Box.createHorizontalStrut(12)); + + JButton exportJsonBtn = new JButton("Export JSON"); + exportJsonBtn.setToolTipText("Export diagnostics to JSON file"); + exportJsonBtn.addActionListener(e -> exportToJson()); + topPanel.add(exportJsonBtn); + + JButton copySummaryBtn = new JButton("Copy Summary"); + copySummaryBtn.setToolTipText("Copy summary to clipboard for Discord"); + copySummaryBtn.addActionListener(e -> copyToClipboard()); + topPanel.add(copySummaryBtn); + + add(topPanel, BorderLayout.NORTH); + + // === GUILD OVERVIEW TABLE (Collapsible) === + String[] overviewCols = {"Guild", "Status", "Quality", "Miss Rate", "Stutters", "Stuck", "TTFF"}; + guildOverviewModel = new DefaultTableModel(overviewCols, 0) { + @Override + public boolean isCellEditable(int r, int c) { return false; } + }; + guildOverviewTable = new JTable(guildOverviewModel); + guildOverviewTable.setRowHeight(18); + guildOverviewTable.setFillsViewportHeight(true); + guildOverviewTable.getColumnModel().getColumn(0).setPreferredWidth(100); + guildOverviewTable.getColumnModel().getColumn(1).setPreferredWidth(60); + guildOverviewTable.getColumnModel().getColumn(2).setPreferredWidth(55); + guildOverviewTable.getColumnModel().getColumn(3).setPreferredWidth(65); + guildOverviewTable.getColumnModel().getColumn(4).setPreferredWidth(55); + guildOverviewTable.getColumnModel().getColumn(5).setPreferredWidth(45); + guildOverviewTable.getColumnModel().getColumn(6).setPreferredWidth(50); + + // Select guild when row is clicked + guildOverviewTable.getSelectionModel().addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + int row = guildOverviewTable.getSelectedRow(); + if (row >= 0 && row < guildSelector.getItemCount()) { + guildSelector.setSelectedIndex(row); + } + } + }); + + JScrollPane overviewScroll = new JScrollPane(guildOverviewTable); + overviewScroll.setBorder(new TitledBorder("Guild Overview (click to select)")); + overviewScroll.setPreferredSize(new Dimension(600, 55)); // More compact + overviewScroll.setMinimumSize(new Dimension(300, 45)); + overviewScroll.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + // === GRAPHS PANEL (GridBagLayout for weighted rows) === + JPanel graphsContent = new JPanel(new GridBagLayout()); + graphsContent.setMinimumSize(new Dimension(500, 200)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.BOTH; + gbc.insets = new Insets(2, 0, 2, 0); + + // Row 0: Enhanced Timeline (weight 2.0 - primary visualization) + enhancedTimeline = new EnhancedTimelinePanel(); + gbc.gridx = 0; gbc.gridy = 0; + gbc.weightx = 1.0; gbc.weighty = 2.0; + graphsContent.add(enhancedTimeline, gbc); + + // Row 1: Latency Graph (full width, weight 1.0) + latencyGraph = new LatencyGraphPanel(); + JPanel latencyContainer = createGraphContainer("Latency", latencyGraph); + gbc.gridy = 1; + gbc.weighty = 1.0; + graphsContent.add(latencyContainer, gbc); + + // Row 2: Scheduler Drift (full width, weight 1.0) + driftChart = new SchedulerDriftPanel(); + JPanel driftContainer = createGraphContainer("Scheduler Drift", driftChart); + gbc.gridy = 2; + gbc.weighty = 1.0; + graphsContent.add(driftContainer, gbc); + + JScrollPane graphScroll = new JScrollPane(graphsContent); + graphScroll.setBorder(null); + graphScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); + graphScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + graphScroll.getHorizontalScrollBar().setUnitIncrement(16); + graphScroll.getVerticalScrollBar().setUnitIncrement(16); + graphScroll.setMinimumSize(new Dimension(200, 150)); + + // === BOTTOM: Stats + Events with resizable split === + + // Stats - 6 rows x 4 columns inside single border + JPanel statsInner = new JPanel(new GridLayout(6, 4, 8, 2)); + + durationLabel = createStatLabel("--"); + framesLabel = createStatLabel("--"); + missedLabel = createStatLabel("--"); + missRateLabel = createStatLabel("--%"); + latencyLabel = createStatLabel("-- ms"); + fpsLabel = createStatLabel("-- fps"); + stuttersLabel = createStatLabel("--"); + stuckLabel = createStatLabel("--"); + gcCountLabel = createStatLabel("--"); + ttffLabel = createStatLabel("--"); + missRateWindowsLabel = createStatLabel("--"); + + // Row 1 + statsInner.add(createFixedLabel("Duration:")); + statsInner.add(durationLabel); + statsInner.add(createFixedLabel("Latency:")); + statsInner.add(latencyLabel); + // Row 2 + statsInner.add(createFixedLabel("Frames:")); + statsInner.add(framesLabel); + statsInner.add(createFixedLabel("Miss Rate:")); + statsInner.add(missRateLabel); + // Row 3 + statsInner.add(createFixedLabel("Missed:")); + statsInner.add(missedLabel); + statsInner.add(createFixedLabel("Stutters:")); + statsInner.add(stuttersLabel); + // Row 4 + statsInner.add(createFixedLabel("Rate:")); + statsInner.add(fpsLabel); + statsInner.add(createFixedLabel("Stuck:")); + statsInner.add(stuckLabel); + // Row 5 + statsInner.add(createFixedLabel("GC Events:")); + statsInner.add(gcCountLabel); + statsInner.add(createFixedLabel("TTFF:")); + statsInner.add(ttffLabel); + // Row 6 + statsInner.add(createFixedLabel("Miss 10s/60s:")); + statsInner.add(missRateWindowsLabel); + statsInner.add(new JLabel()); // Empty cell + statsInner.add(new JLabel()); // Empty cell + + JPanel statsPanel = new JPanel(new BorderLayout()); + statsPanel.setBorder(new TitledBorder("Statistics")); + statsPanel.add(statsInner, BorderLayout.CENTER); + statsPanel.setMinimumSize(new Dimension(200, 80)); + statsPanel.setPreferredSize(new Dimension(280, 100)); + + // Events table + String[] cols = {"Time", "Type", "Details", "Sev"}; + eventTableModel = new DefaultTableModel(cols, 0) { + @Override + public boolean isCellEditable(int r, int c) { return false; } + }; + eventTable = new JTable(eventTableModel); + eventTable.setFillsViewportHeight(true); + eventTable.setRowHeight(16); + eventTable.getColumnModel().getColumn(0).setPreferredWidth(55); + eventTable.getColumnModel().getColumn(1).setPreferredWidth(35); + eventTable.getColumnModel().getColumn(2).setPreferredWidth(120); + eventTable.getColumnModel().getColumn(3).setPreferredWidth(35); + + JScrollPane tableScroll = new JScrollPane(eventTable); + tableScroll.setBorder(new TitledBorder("Events")); + tableScroll.setMinimumSize(new Dimension(150, 80)); + tableScroll.setPreferredSize(new Dimension(300, 100)); + + // Horizontal split between Stats and Events + JSplitPane bottomSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, statsPanel, tableScroll); + bottomSplit.setResizeWeight(0.4); // Stats gets 40%, Events gets 60% + bottomSplit.setDividerSize(5); + bottomSplit.setContinuousLayout(true); + bottomSplit.setBorder(null); + bottomSplit.setMinimumSize(new Dimension(200, 80)); + bottomSplit.setPreferredSize(new Dimension(600, 100)); + + // === SPLIT PANE: Graphs (middle, more space) vs Stats/Events (bottom, less space) === + JSplitPane graphsAndStatsSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, graphScroll, bottomSplit); + graphsAndStatsSplit.setResizeWeight(0.75); // Graphs get 75% of space + graphsAndStatsSplit.setDividerSize(5); + graphsAndStatsSplit.setContinuousLayout(true); + graphsAndStatsSplit.setBorder(null); + + // === MAIN SPLIT: Guild Overview (top, small) vs Graphs+Stats (bottom, large) === + JSplitPane mainSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, overviewScroll, graphsAndStatsSplit); + mainSplit.setResizeWeight(0.1); // Guild overview gets 10% of space + mainSplit.setDividerSize(5); + mainSplit.setContinuousLayout(true); + mainSplit.setBorder(null); + + add(mainSplit, BorderLayout.CENTER); + } + + private JPanel createGraphContainer(String title, JPanel graph) { + JPanel container = new JPanel(new BorderLayout()); + container.setBorder(new TitledBorder(title)); + container.add(graph, BorderLayout.CENTER); + return container; + } + + private JLabel createStatLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(10f)); + return label; + } + + private JLabel createFixedLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(Font.BOLD, 10f)); + label.setForeground(Color.GRAY); + return label; + } + + public void refreshGuildList() { + guildSelector.removeAllItems(); + guildOverviewModel.setRowCount(0); + + if (bot == null || bot.getJDA() == null) return; + + List<Guild> guilds = bot.getJDA().getGuilds(); + for (Guild g : guilds) { + if (g.getAudioManager().isConnected()) { + guildSelector.addItem(new GuildItem(g)); + + // Add to overview table + var handler = g.getAudioManager().getSendingHandler(); + if (handler instanceof AudioHandler ah) { + MetricsSnapshot snap = ah.getPerformanceMetrics().getSnapshot(selectedWindowSeconds); + String status = ah.getPlayer().getPlayingTrack() != null + ? (ah.getPlayer().isPaused() ? "Paused" : "Playing") + : "Idle"; + + guildOverviewModel.addRow(new Object[]{ + g.getName(), + status, + snap.qualityScore() + "%", + String.format("%.2f%%", snap.missRatePercent()), + snap.stutterCount(), + snap.stuckCount(), + snap.timeToFirstFrameMs() > 0 ? snap.timeToFirstFrameMs() + "ms" : "-" + }); + } + } + } + if (guildSelector.getItemCount() == 0) { + guildSelector.addItem(new GuildItem(null)); + } + } + + private void refreshMetrics() { + // Always get health snapshot (system-wide, not guild-specific) + healthSnapshot = SystemHealthMonitor.getInstance().getSnapshot(selectedWindowSeconds); + + GuildItem sel = (GuildItem) guildSelector.getSelectedItem(); + if (sel == null || sel.guild() == null) { + currentSnapshot = null; + enhancedTimeline.updateSnapshot(null, 0, selectedWindowSeconds); + updateDisplay(); + return; + } + + var handler = sel.guild().getAudioManager().getSendingHandler(); + if (!(handler instanceof AudioHandler ah)) { + currentSnapshot = null; + enhancedTimeline.updateSnapshot(null, 0, selectedWindowSeconds); + updateDisplay(); + return; + } + + currentSnapshot = ah.getPerformanceMetrics().getSnapshot(selectedWindowSeconds); + enhancedTimeline.updateSnapshot(currentSnapshot, sel.guild().getIdLong(), selectedWindowSeconds); + updateDisplay(); + } + + private void updateDisplay() { + if (currentSnapshot == null) { + healthLabel.setForeground(Color.GRAY); + qualityScoreLabel.setText("Quality: --%"); + durationLabel.setText("--"); + framesLabel.setText("--"); + missedLabel.setText("--"); + missRateLabel.setText("--%"); + latencyLabel.setText("-- ms"); + fpsLabel.setText("-- fps"); + stuttersLabel.setText("--"); + stuckLabel.setText("--"); + gcCountLabel.setText("--"); + ttffLabel.setText("--"); + missRateWindowsLabel.setText("--"); + eventTableModel.setRowCount(0); + repaintGraphs(); + return; + } + + HealthStatus health = currentSnapshot.healthStatus(); + healthLabel.setForeground(switch (health) { + case GOOD -> GOOD_COLOR; + case WARNING -> WARNING_COLOR; + case CRITICAL -> CRITICAL_COLOR; + }); + + int score = currentSnapshot.qualityScore(); + qualityScoreLabel.setText("Quality: " + score + "%"); + qualityScoreLabel.setForeground(score >= 90 ? GOOD_COLOR : score >= 70 ? WARNING_COLOR : CRITICAL_COLOR); + + durationLabel.setText(currentSnapshot.formattedDuration()); + framesLabel.setText(formatNum(currentSnapshot.totalFramesProvided())); + missedLabel.setText(String.valueOf(currentSnapshot.totalFramesMissed())); + missedLabel.setForeground(currentSnapshot.totalFramesMissed() > 0 ? CRITICAL_COLOR : null); + missRateLabel.setText(String.format("%.2f%%", currentSnapshot.missRatePercent())); + // Show avg latency with p95 - use microseconds for sub-millisecond values + double avgLat = currentSnapshot.avgLatencyMs(); + double p95Lat = currentSnapshot.p95LatencyMs(); + boolean useMicroseconds = avgLat < 0.5 && p95Lat < 0.5; + + if (useMicroseconds) { + // Convert to microseconds for better readability + double avgUs = avgLat * 1000; + double p95Us = p95Lat * 1000; + if (p95Lat > 0) { + latencyLabel.setText(String.format("%.0f (p95: %.0f) μs", avgUs, p95Us)); + } else { + latencyLabel.setText(String.format("%.0f μs", avgUs)); + } + } else { + if (p95Lat > 0) { + latencyLabel.setText(String.format("%.2f (p95: %.2f) ms", avgLat, p95Lat)); + } else { + latencyLabel.setText(String.format("%.2f ms", avgLat)); + } + } + fpsLabel.setText(String.format("%.1f fps", currentSnapshot.framesPerSecond())); + stuttersLabel.setText(String.valueOf(currentSnapshot.stutterCount())); + stuttersLabel.setForeground(currentSnapshot.stutterCount() > 0 ? WARNING_COLOR : null); + stuckLabel.setText(String.valueOf(currentSnapshot.stuckCount())); + stuckLabel.setForeground(currentSnapshot.stuckCount() > 0 ? CRITICAL_COLOR : null); + gcCountLabel.setText(String.valueOf(currentSnapshot.gcEvents().length)); + + // TTFF (Time to First Frame) + long ttff = currentSnapshot.timeToFirstFrameMs(); + if (ttff > 0) { + ttffLabel.setText(ttff + "ms"); + ttffLabel.setForeground(ttff > 500 ? WARNING_COLOR : ttff > 1000 ? CRITICAL_COLOR : null); + } else { + ttffLabel.setText("--"); + ttffLabel.setForeground(null); + } + + // Miss rate windows (10s / 60s) + missRateWindowsLabel.setText(String.format("%.2f%% / %.2f%%", + currentSnapshot.missRate10s(), currentSnapshot.missRate60s())); + + updateEventTable(); + repaintGraphs(); + } + + private void updateEventTable() { + eventTableModel.setRowCount(0); + if (currentSnapshot == null) return; + + SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); + + for (StutterEvent e : currentSnapshot.stutterEvents()) { + eventTableModel.addRow(new Object[]{ + sdf.format(new Date(e.timestamp())), + "Stut", + e.missedFrames() + "f (" + e.durationMs() + "ms)", + e.severity().name().substring(0, 3) + }); + } + + for (PerformanceMetrics.StuckEvent e : currentSnapshot.stuckEvents()) { + String details = e.trackTitle() != null + ? truncate(e.trackTitle(), 20) + " (" + e.thresholdMs() + "ms)" + : "Track stuck (" + e.thresholdMs() + "ms)"; + eventTableModel.addRow(new Object[]{ + sdf.format(new Date(e.timestamp())), + "Stck", + details, + e.severity().name().substring(0, 3) + }); + } + + for (GCMonitor.GCEvent e : currentSnapshot.gcEvents()) { + eventTableModel.addRow(new Object[]{ + sdf.format(new Date(e.timestamp())), + "GC", + e.collectorName().replace(" Generation", "").replace("Copy", "YG") + " (" + e.durationMs() + "ms)", + e.severity().name().substring(0, 3) + }); + } + } + + private String truncate(String s, int maxLen) { + if (s == null) return ""; + return s.length() > maxLen ? s.substring(0, maxLen - 2) + ".." : s; + } + + private void repaintGraphs() { + enhancedTimeline.repaint(); + latencyGraph.repaint(); + driftChart.repaint(); + } + + private String formatNum(long n) { + if (n >= 1_000_000) return String.format("%.1fM", n / 1_000_000.0); + if (n >= 1_000) return String.format("%.1fK", n / 1_000.0); + return String.valueOf(n); + } + + public void updateMetrics() { + refreshMetrics(); + } + + /** + * Exports current metrics to a JSON file. + */ + private void exportToJson() { + if (currentSnapshot == null) { + JOptionPane.showMessageDialog(this, + "No metrics data available. Select a guild with active voice first.", + "Export Error", JOptionPane.WARNING_MESSAGE); + return; + } + + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Export Diagnostics"); + chooser.setFileFilter(new FileNameExtensionFilter("JSON Files", "json")); + + // Generate default filename with timestamp + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + chooser.setSelectedFile(new File("jmusicbot_diagnostics_" + timestamp + ".json")); + + if (chooser.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) { + File file = chooser.getSelectedFile(); + if (!file.getName().endsWith(".json")) { + file = new File(file.getAbsolutePath() + ".json"); + } + + try { + String json = currentSnapshot.toJson(); + Files.writeString(file.toPath(), json); + JOptionPane.showMessageDialog(this, + "Diagnostics exported to:\n" + file.getAbsolutePath(), + "Export Successful", JOptionPane.INFORMATION_MESSAGE); + } catch (IOException ex) { + JOptionPane.showMessageDialog(this, + "Failed to export: " + ex.getMessage(), + "Export Error", JOptionPane.ERROR_MESSAGE); + } + } + } + + /** + * Copies a summary to the clipboard for sharing on Discord. + */ + private void copyToClipboard() { + if (currentSnapshot == null) { + JOptionPane.showMessageDialog(this, + "No metrics data available. Select a guild with active voice first.", + "Copy Error", JOptionPane.WARNING_MESSAGE); + return; + } + + String summary = currentSnapshot.toDiscordSummary(); + StringSelection selection = new StringSelection(summary); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, null); + + JOptionPane.showMessageDialog(this, + "Summary copied to clipboard!", + "Copied", JOptionPane.INFORMATION_MESSAGE); + } + + // ===== Graph Panels ===== + + private class LatencyGraphPanel extends JPanel { + private static final Color GRID_COLOR = new Color(50, 50, 55); + private static final Color P95_COLOR = new Color(255, 140, 0); // Orange + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(), h = getHeight(); + if (w < 40 || h < 20) { g2.dispose(); return; } + + int margin = 35; // Increased for microsecond labels + int graphW = w - margin - 15; + int graphH = h - 20; // More bottom margin for time labels + int graphTop = 5; + + g2.setColor(GRAPH_BG); + g2.fillRect(margin, graphTop, graphW, graphH); + + if (currentSnapshot == null || currentSnapshot.latencyBuckets().length == 0) { + drawCentered(g2, "No data", w, h); + g2.dispose(); + return; + } + + LatencyBucket[] buckets = currentSnapshot.latencyBuckets(); + double p95Latency = currentSnapshot.p95LatencyMs(); + + // Find max data latency + double maxDataLat = 0; + for (LatencyBucket b : buckets) { + maxDataLat = Math.max(maxDataLat, b.max()); + } + + // Determine if we should use microsecond scale + // Use microseconds when max latency is below 0.5ms (500μs) + boolean useMicroseconds = maxDataLat < 0.5 && p95Latency < 0.5; + + // Scale factor: 1.0 for ms, 1000.0 for μs + double scaleFactor = useMicroseconds ? 1000.0 : 1.0; + String unit = useMicroseconds ? "μs" : "ms"; + + // Convert to display units + double maxDataDisplay = maxDataLat * scaleFactor; + double p95Display = p95Latency * scaleFactor; + + // Determine scale max in display units + double scaleMax; + if (useMicroseconds) { + // Microsecond scales: 10μs, 25μs, 50μs, 100μs, 250μs, 500μs + if (maxDataDisplay <= 10) scaleMax = 10; + else if (maxDataDisplay <= 25) scaleMax = 25; + else if (maxDataDisplay <= 50) scaleMax = 50; + else if (maxDataDisplay <= 100) scaleMax = 100; + else if (maxDataDisplay <= 250) scaleMax = 250; + else scaleMax = 500; + } else { + // Millisecond scales: 1ms, 2ms, 5ms, 10ms, 20ms, etc. + if (maxDataDisplay <= 1) scaleMax = 1; + else if (maxDataDisplay <= 2) scaleMax = 2; + else if (maxDataDisplay <= 5) scaleMax = 5; + else if (maxDataDisplay <= 10) scaleMax = 10; + else if (maxDataDisplay <= 20) scaleMax = 20; + else scaleMax = Math.ceil(maxDataDisplay * 1.2); + } + + g2.setFont(g2.getFont().deriveFont(9f)); + + // Draw horizontal grid lines at appropriate intervals + double gridStep; + if (useMicroseconds) { + // Microsecond grid steps + gridStep = scaleMax <= 25 ? 5 : scaleMax <= 50 ? 10 : scaleMax <= 100 ? 25 : scaleMax <= 250 ? 50 : 100; + } else { + // Millisecond grid steps + gridStep = scaleMax <= 2 ? 0.5 : scaleMax <= 5 ? 1.0 : scaleMax <= 10 ? 2.0 : 5.0; + } + + for (double val = gridStep; val < scaleMax; val += gridStep) { + int y = graphTop + graphH - (int) (val / scaleMax * graphH); + g2.setColor(GRID_COLOR); + g2.drawLine(margin, y, margin + graphW, y); + g2.setColor(Color.GRAY); + String label = val == (int) val ? String.format("%.0f", val) : String.format("%.1f", val); + g2.drawString(label, 2, y + 4); + } + + // Y-axis labels with unit + g2.setColor(Color.GRAY); + g2.drawString(String.format("%.0f%s", scaleMax, unit), 2, graphTop + 10); + g2.drawString("0", 2, graphTop + graphH - 2); + + // Draw min-max range as shaded area (convert ms to display units) + g2.setColor(new Color(70, 130, 180, 40)); + for (int i = 0; i < buckets.length - 1; i++) { + int x1 = margin + i * graphW / buckets.length; + int x2 = margin + (i + 1) * graphW / buckets.length; + double min1 = buckets[i].min() * scaleFactor; + double max1 = buckets[i].max() * scaleFactor; + double min2 = buckets[i + 1].min() * scaleFactor; + double max2 = buckets[i + 1].max() * scaleFactor; + int y1min = graphTop + graphH - (int) (Math.min(min1, scaleMax) / scaleMax * graphH); + int y1max = graphTop + graphH - (int) (Math.min(max1, scaleMax) / scaleMax * graphH); + int y2min = graphTop + graphH - (int) (Math.min(min2, scaleMax) / scaleMax * graphH); + int y2max = graphTop + graphH - (int) (Math.min(max2, scaleMax) / scaleMax * graphH); + g2.fillPolygon(new int[]{x1, x2, x2, x1}, new int[]{y1max, y2max, y2min, y1min}, 4); + } + + // Draw p95 horizontal reference line (dashed) + if (p95Display > 0 && p95Display <= scaleMax) { + int p95Y = graphTop + graphH - (int) (p95Display / scaleMax * graphH); + g2.setColor(P95_COLOR); + g2.setStroke(new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, + 10, new float[]{4, 4}, 0)); + g2.drawLine(margin, p95Y, margin + graphW, p95Y); + g2.setStroke(new BasicStroke(1)); + g2.drawString("p95", margin + graphW + 2, p95Y + 4); + } + + // Draw average latency line (solid blue) + g2.setColor(new Color(70, 130, 180)); + g2.setStroke(new BasicStroke(2f)); + for (int i = 0; i < buckets.length - 1; i++) { + int x1 = margin + i * graphW / buckets.length; + int x2 = margin + (i + 1) * graphW / buckets.length; + double avg1 = buckets[i].avg() * scaleFactor; + double avg2 = buckets[i + 1].avg() * scaleFactor; + int y1 = graphTop + graphH - (int) (Math.min(avg1, scaleMax) / scaleMax * graphH); + int y2 = graphTop + graphH - (int) (Math.min(avg2, scaleMax) / scaleMax * graphH); + g2.drawLine(x1, y1, x2, y2); + } + + // Draw time axis labels + g2.setStroke(new BasicStroke(1)); + g2.setFont(g2.getFont().deriveFont(8f)); + g2.setColor(Color.GRAY); + int timeInterval = selectedWindowSeconds <= 30 ? 10 : selectedWindowSeconds <= 60 ? 15 : 30; + for (int sec = 0; sec <= selectedWindowSeconds; sec += timeInterval) { + int x = margin + (int) ((double) sec / selectedWindowSeconds * graphW); + g2.setColor(GRID_COLOR); + g2.drawLine(x, graphTop, x, graphTop + graphH); + g2.setColor(Color.GRAY); + g2.drawString("-" + (selectedWindowSeconds - sec) + "s", x - 8, h - 3); + } + + // Border + g2.setColor(Color.GRAY); + g2.drawRect(margin, graphTop, graphW, graphH); + g2.dispose(); + } + } + + private class SchedulerDriftPanel extends JPanel { + private static final Color GRID_COLOR = new Color(50, 50, 55); + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(), h = getHeight(); + if (w < 40 || h < 20) { g2.dispose(); return; } + + int margin = 35; + int graphW = w - margin - 10; + int graphH = h - 20; + int graphTop = 5; + + g2.setColor(GRAPH_BG); + g2.fillRect(margin, graphTop, graphW, graphH); + + if (healthSnapshot == null || healthSnapshot.isEmpty()) { + drawCentered(g2, "No data", w, h); + g2.dispose(); + return; + } + + HealthSample[] samples = healthSnapshot.samples(); + if (samples.length < 2) { + drawCentered(g2, "Collecting...", w, h); + g2.dispose(); + return; + } + + // Find max drift for scaling - use adaptive scale + long maxDrift = 10; + for (HealthSample s : samples) { + maxDrift = Math.max(maxDrift, s.schedulerDriftMs()); + } + + // Adaptive scaling: 25ms, 50ms, 100ms, 200ms, 500ms + long scaleMax; + if (maxDrift <= 25) scaleMax = 25; + else if (maxDrift <= 50) scaleMax = 50; + else if (maxDrift <= 100) scaleMax = 100; + else if (maxDrift <= 200) scaleMax = 200; + else if (maxDrift <= 500) scaleMax = 500; + else scaleMax = ((maxDrift / 100) + 1) * 100; + + g2.setFont(g2.getFont().deriveFont(9f)); + + // Draw horizontal grid lines + long gridStep = scaleMax <= 50 ? 10 : scaleMax <= 100 ? 25 : scaleMax <= 200 ? 50 : 100; + for (long ms = gridStep; ms < scaleMax; ms += gridStep) { + int y = graphTop + graphH - (int) ((double) ms / scaleMax * graphH); + g2.setColor(GRID_COLOR); + g2.drawLine(margin, y, margin + graphW, y); + g2.setColor(Color.GRAY); + g2.drawString(ms + "", 2, y + 4); + } + + // Y-axis labels + g2.setColor(Color.GRAY); + g2.drawString(scaleMax + "ms", 2, graphTop + 10); + g2.drawString("0", 2, graphTop + graphH - 2); + + // Draw drift bars + int barWidth = Math.max(2, graphW / samples.length - 1); + for (int i = 0; i < samples.length; i++) { + long drift = samples[i].schedulerDriftMs(); + if (drift <= 0) continue; + + int x = margin + (i * graphW / samples.length); + int barHeight = (int) ((double) drift / scaleMax * graphH); + barHeight = Math.min(barHeight, graphH); // Clamp to graph height + + // Color based on severity + if (drift > 100) { + g2.setColor(CRITICAL_COLOR); + } else if (drift > 50) { + g2.setColor(WARNING_COLOR); + } else { + g2.setColor(DRIFT_COLOR); + } + + g2.fillRect(x, graphTop + graphH - barHeight, barWidth, barHeight); + } + + // Draw time axis labels + g2.setFont(g2.getFont().deriveFont(8f)); + int timeInterval = selectedWindowSeconds <= 30 ? 10 : selectedWindowSeconds <= 60 ? 15 : 30; + for (int sec = 0; sec <= selectedWindowSeconds; sec += timeInterval) { + int x = margin + (int) ((double) sec / selectedWindowSeconds * graphW); + g2.setColor(GRID_COLOR); + g2.drawLine(x, graphTop, x, graphTop + graphH); + g2.setColor(Color.GRAY); + g2.drawString("-" + (selectedWindowSeconds - sec) + "s", x - 8, h - 3); + } + + // Border + g2.setColor(Color.GRAY); + g2.drawRect(margin, graphTop, graphW, graphH); + g2.dispose(); + } + } + + private void drawCentered(Graphics2D g2, String text, int w, int h) { + g2.setColor(Color.GRAY); + int tw = g2.getFontMetrics().stringWidth(text); + g2.drawString(text, w / 2 - tw / 2, h / 2 + 4); + } + + private record GuildItem(Guild guild) { + @Override + public String toString() { + return guild != null ? guild.getName() : "(No active voice)"; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/panels/SettingsPanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/panels/SettingsPanel.java new file mode 100644 index 0000000..cd83f8d --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/panels/SettingsPanel.java @@ -0,0 +1,289 @@ +/* + * 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.gui.panels; + +import com.jagrosh.jmusicbot.gui.theme.ThemeManager; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import java.awt.*; +import java.io.File; +import java.io.IOException; + +/** + * Settings panel for configuring GUI appearance and viewing configuration. + * + * @author Arif Banai (arif-banai) + */ +public class SettingsPanel extends JPanel { + + private final JComboBox<ThemeManager.Theme> themeComboBox; + private final JSpinner fontSizeSpinner; + + public SettingsPanel() { + super(new BorderLayout(8, 8)); + setBorder(new EmptyBorder(8, 8, 8, 8)); + + // Create main content panel with vertical layout + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + + // Appearance section + JPanel appearancePanel = createAppearanceSection(); + contentPanel.add(appearancePanel); + contentPanel.add(Box.createVerticalStrut(16)); + + // Configuration section + JPanel configPanel = createConfigSection(); + contentPanel.add(configPanel); + contentPanel.add(Box.createVerticalStrut(16)); + + // Info section + JPanel infoPanel = createInfoSection(); + contentPanel.add(infoPanel); + + // Add filler to push content to top + contentPanel.add(Box.createVerticalGlue()); + + // Initialize combo box reference + themeComboBox = (JComboBox<ThemeManager.Theme>) findComponentByName(appearancePanel, "themeComboBox"); + fontSizeSpinner = (JSpinner) findComponentByName(appearancePanel, "fontSizeSpinner"); + + add(new JScrollPane(contentPanel), BorderLayout.CENTER); + } + + /** + * Creates the appearance settings section. + */ + private JPanel createAppearanceSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("Appearance")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 120)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 8, 4, 8); + gbc.anchor = GridBagConstraints.WEST; + + // Theme selector + gbc.gridx = 0; + gbc.gridy = 0; + panel.add(new JLabel("Theme:"), gbc); + + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + JComboBox<ThemeManager.Theme> themeBox = new JComboBox<>(ThemeManager.Theme.values()); + themeBox.setName("themeComboBox"); + themeBox.setSelectedItem(ThemeManager.getCurrentTheme()); + themeBox.addActionListener(e -> { + ThemeManager.Theme selected = (ThemeManager.Theme) themeBox.getSelectedItem(); + if (selected != null) { + ThemeManager.setTheme(selected); + } + }); + panel.add(themeBox, gbc); + + // Font size spinner + gbc.gridx = 0; + gbc.gridy = 1; + gbc.fill = GridBagConstraints.NONE; + gbc.weightx = 0; + panel.add(new JLabel("Font Size:"), gbc); + + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + SpinnerNumberModel fontModel = new SpinnerNumberModel( + ThemeManager.getBaseFontSize(), 8, 24, 1 + ); + JSpinner fontSpinner = new JSpinner(fontModel); + fontSpinner.setName("fontSizeSpinner"); + fontSpinner.addChangeListener(e -> { + int size = (Integer) fontSpinner.getValue(); + ThemeManager.setBaseFontSize(size); + }); + panel.add(fontSpinner, gbc); + + // Note about persistence + gbc.gridx = 0; + gbc.gridy = 2; + gbc.gridwidth = 2; + JLabel noteLabel = new JLabel("<html><i>Note: Theme and font size changes are applied immediately but not saved to config.</i></html>"); + noteLabel.setFont(noteLabel.getFont().deriveFont(10f)); + noteLabel.setForeground(Color.GRAY); + panel.add(noteLabel, gbc); + + return panel; + } + + /** + * Creates the configuration info section. + */ + private JPanel createConfigSection() { + JPanel panel = new JPanel(new BorderLayout(8, 8)); + panel.setBorder(new TitledBorder("Configuration")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 4)); + + // Open config folder button + JButton openFolderButton = new JButton("Open Config Folder"); + openFolderButton.addActionListener(e -> openConfigFolder()); + buttonPanel.add(openFolderButton); + + // Open config file button + JButton openFileButton = new JButton("Open config.txt"); + openFileButton.addActionListener(e -> openConfigFile()); + buttonPanel.add(openFileButton); + + panel.add(buttonPanel, BorderLayout.CENTER); + + JLabel pathLabel = new JLabel("<html><i>Config location: " + getConfigPath() + "</i></html>"); + pathLabel.setFont(pathLabel.getFont().deriveFont(10f)); + pathLabel.setForeground(Color.GRAY); + pathLabel.setBorder(new EmptyBorder(0, 8, 4, 8)); + panel.add(pathLabel, BorderLayout.SOUTH); + + return panel; + } + + /** + * Creates the system info section. + */ + private JPanel createInfoSection() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new TitledBorder("System Information")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 150)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(2, 8, 2, 8); + gbc.anchor = GridBagConstraints.WEST; + gbc.gridx = 0; + + // Java version + gbc.gridy = 0; + panel.add(createInfoRow("Java Version:", System.getProperty("java.version")), gbc); + + // Java vendor + gbc.gridy = 1; + panel.add(createInfoRow("Java Vendor:", System.getProperty("java.vendor")), gbc); + + // OS + gbc.gridy = 2; + panel.add(createInfoRow("Operating System:", + System.getProperty("os.name") + " " + System.getProperty("os.version")), gbc); + + // Current theme + gbc.gridy = 3; + panel.add(createInfoRow("Current Theme:", ThemeManager.getCurrentTheme().getDisplayName()), gbc); + + // FlatLaf version + gbc.gridy = 4; + panel.add(createInfoRow("FlatLaf:", "3.7"), gbc); + + return panel; + } + + /** + * Creates an info row with label and value. + */ + private JPanel createInfoRow(String label, String value) { + JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + JLabel labelComponent = new JLabel(label); + labelComponent.setFont(labelComponent.getFont().deriveFont(Font.BOLD)); + row.add(labelComponent); + row.add(new JLabel(value)); + return row; + } + + /** + * Gets the config file path. + */ + private String getConfigPath() { + File configFile = new File("config.txt"); + try { + return configFile.getCanonicalPath(); + } catch (IOException e) { + return configFile.getAbsolutePath(); + } + } + + /** + * Opens the config folder in the system file manager. + */ + private void openConfigFolder() { + try { + File configFile = new File("config.txt"); + File folder = configFile.getParentFile(); + if (folder == null) { + folder = new File("."); + } + Desktop.getDesktop().open(folder.getCanonicalFile()); + } catch (IOException e) { + JOptionPane.showMessageDialog( + this, + "Could not open config folder: " + e.getMessage(), + "Error", + JOptionPane.ERROR_MESSAGE + ); + } + } + + /** + * Opens the config file in the default text editor. + */ + private void openConfigFile() { + try { + File configFile = new File("config.txt"); + if (configFile.exists()) { + Desktop.getDesktop().edit(configFile); + } else { + JOptionPane.showMessageDialog( + this, + "Config file not found: " + configFile.getAbsolutePath(), + "File Not Found", + JOptionPane.WARNING_MESSAGE + ); + } + } catch (IOException e) { + JOptionPane.showMessageDialog( + this, + "Could not open config file: " + e.getMessage(), + "Error", + JOptionPane.ERROR_MESSAGE + ); + } + } + + /** + * Finds a component by name within a container. + */ + private Component findComponentByName(Container container, String name) { + for (Component comp : container.getComponents()) { + if (name.equals(comp.getName())) { + return comp; + } + if (comp instanceof Container) { + Component found = findComponentByName((Container) comp, name); + if (found != null) { + return found; + } + } + } + return null; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/panels/SourceHealthPanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/panels/SourceHealthPanel.java new file mode 100644 index 0000000..97d9fc0 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/panels/SourceHealthPanel.java @@ -0,0 +1,505 @@ +/* + * 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.gui.panels; + +import com.jagrosh.jmusicbot.audio.TrackLoadingMonitor; +import com.jagrosh.jmusicbot.audio.TrackLoadingMonitor.*; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Panel for visualizing track loading health and performance by source. + * Shows load times, success rates, and recent load history. + * + * @author Arif Banai (arif-banai) + */ +public class SourceHealthPanel extends JPanel { + + private static final Color GOOD_COLOR = new Color(46, 204, 113); + private static final Color WARNING_COLOR = new Color(241, 196, 15); + private static final Color CRITICAL_COLOR = new Color(231, 76, 60); + private static final Color GRAPH_BG = new Color(25, 25, 30); + + private final JComboBox<WindowOption> windowSelector; + + // Stats labels + private final JLabel totalLoadsLabel; + private final JLabel successRateLabel; + private final JLabel avgLoadTimeLabel; + private final JLabel p95LoadTimeLabel; + private final JLabel failedLoadsLabel; + + // Source table + private final JTable sourceTable; + private final DefaultTableModel sourceTableModel; + + // Recent loads table + private final JTable recentLoadsTable; + private final DefaultTableModel recentLoadsModel; + + // Visualization + private final LoadTimeChartPanel loadTimeChart; + private final SuccessPiePanel successPie; + + private final TrackLoadingMonitor monitor; + private LoadingSnapshot currentSnapshot; + private int selectedWindowSeconds = 60; + + private enum WindowOption { + SECONDS_30(30, "30 seconds"), + MINUTE_1(60, "1 minute"), + MINUTE_5(300, "5 minutes"), + MINUTE_10(600, "10 minutes"); + + private final int seconds; + private final String display; + + WindowOption(int seconds, String display) { + this.seconds = seconds; + this.display = display; + } + + public int getSeconds() { return seconds; } + + @Override + public String toString() { return display; } + } + + /** + * Creates a new SourceHealthPanel. + * + * @param monitor the TrackLoadingMonitor instance to display data from + */ + public SourceHealthPanel(TrackLoadingMonitor monitor) { + super(new BorderLayout(4, 4)); + this.monitor = monitor; + setBorder(new EmptyBorder(4, 4, 4, 4)); + + // === TOP: Controls and Summary Stats === + JPanel topPanel = new JPanel(new BorderLayout(8, 4)); + + // Controls row + JPanel controlsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2)); + controlsRow.add(new JLabel("Window:")); + windowSelector = new JComboBox<>(WindowOption.values()); + windowSelector.setSelectedItem(WindowOption.MINUTE_1); + windowSelector.addActionListener(e -> { + WindowOption sel = (WindowOption) windowSelector.getSelectedItem(); + if (sel != null) { + selectedWindowSeconds = sel.getSeconds(); + refreshMetrics(); + } + }); + controlsRow.add(windowSelector); + + JButton refreshBtn = new JButton("Refresh"); + refreshBtn.addActionListener(e -> refreshMetrics()); + controlsRow.add(refreshBtn); + + topPanel.add(controlsRow, BorderLayout.NORTH); + + // Summary stats row + JPanel statsRow = new JPanel(new GridLayout(1, 5, 8, 0)); + statsRow.setBorder(new TitledBorder("Summary")); + + totalLoadsLabel = createStatLabel("Total: --"); + successRateLabel = createStatLabel("Success: --%"); + avgLoadTimeLabel = createStatLabel("Avg: --ms"); + p95LoadTimeLabel = createStatLabel("p95: --ms"); + failedLoadsLabel = createStatLabel("Failed: --"); + + statsRow.add(totalLoadsLabel); + statsRow.add(successRateLabel); + statsRow.add(avgLoadTimeLabel); + statsRow.add(p95LoadTimeLabel); + statsRow.add(failedLoadsLabel); + + topPanel.add(statsRow, BorderLayout.CENTER); + add(topPanel, BorderLayout.NORTH); + + // === CENTER: Charts and Tables === + JPanel centerPanel = new JPanel(new GridLayout(2, 1, 4, 4)); + + // Top row: Charts + JPanel chartsPanel = new JPanel(new GridLayout(1, 2, 4, 0)); + + loadTimeChart = new LoadTimeChartPanel(); + JPanel chartContainer = new JPanel(new BorderLayout()); + chartContainer.setBorder(new TitledBorder("Load Time by Source")); + chartContainer.add(loadTimeChart, BorderLayout.CENTER); + chartsPanel.add(chartContainer); + + successPie = new SuccessPiePanel(); + JPanel pieContainer = new JPanel(new BorderLayout()); + pieContainer.setBorder(new TitledBorder("Success Rate by Source")); + pieContainer.add(successPie, BorderLayout.CENTER); + chartsPanel.add(pieContainer); + + centerPanel.add(chartsPanel); + + // Bottom row: Tables + JPanel tablesPanel = new JPanel(new GridLayout(1, 2, 4, 0)); + + // Source stats table + String[] sourceCols = {"Source", "Loads", "Success%", "Avg (ms)", "p95 (ms)"}; + sourceTableModel = new DefaultTableModel(sourceCols, 0) { + @Override + public boolean isCellEditable(int r, int c) { return false; } + }; + sourceTable = new JTable(sourceTableModel); + sourceTable.setRowHeight(18); + sourceTable.getColumnModel().getColumn(0).setPreferredWidth(80); + sourceTable.getColumnModel().getColumn(1).setPreferredWidth(50); + sourceTable.getColumnModel().getColumn(2).setPreferredWidth(60); + sourceTable.getColumnModel().getColumn(3).setPreferredWidth(60); + sourceTable.getColumnModel().getColumn(4).setPreferredWidth(60); + + // Color-code success rate column + sourceTable.getColumnModel().getColumn(2).setCellRenderer(new DefaultTableCellRenderer() { + @Override + public Component getTableCellRendererComponent(JTable table, Object value, + boolean isSelected, boolean hasFocus, int row, int column) { + Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + if (value instanceof String s) { + try { + double rate = Double.parseDouble(s.replace("%", "")); + if (rate >= 95) c.setForeground(GOOD_COLOR); + else if (rate >= 80) c.setForeground(WARNING_COLOR); + else c.setForeground(CRITICAL_COLOR); + } catch (NumberFormatException ignored) {} + } + return c; + } + }); + + JScrollPane sourceScroll = new JScrollPane(sourceTable); + sourceScroll.setBorder(new TitledBorder("Sources")); + tablesPanel.add(sourceScroll); + + // Recent loads table + String[] recentCols = {"Time", "Source", "Result", "Duration", "Track/Error"}; + recentLoadsModel = new DefaultTableModel(recentCols, 0) { + @Override + public boolean isCellEditable(int r, int c) { return false; } + }; + recentLoadsTable = new JTable(recentLoadsModel); + recentLoadsTable.setRowHeight(18); + recentLoadsTable.getColumnModel().getColumn(0).setPreferredWidth(60); + recentLoadsTable.getColumnModel().getColumn(1).setPreferredWidth(70); + recentLoadsTable.getColumnModel().getColumn(2).setPreferredWidth(70); + recentLoadsTable.getColumnModel().getColumn(3).setPreferredWidth(55); + recentLoadsTable.getColumnModel().getColumn(4).setPreferredWidth(150); + + // Color-code result column + recentLoadsTable.getColumnModel().getColumn(2).setCellRenderer(new DefaultTableCellRenderer() { + @Override + public Component getTableCellRendererComponent(JTable table, Object value, + boolean isSelected, boolean hasFocus, int row, int column) { + Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + if (value instanceof String s) { + if (s.contains("LOADED")) c.setForeground(GOOD_COLOR); + else if (s.equals("NO_MATCHES")) c.setForeground(WARNING_COLOR); + else if (s.equals("LOAD_FAILED")) c.setForeground(CRITICAL_COLOR); + } + return c; + } + }); + + JScrollPane recentScroll = new JScrollPane(recentLoadsTable); + recentScroll.setBorder(new TitledBorder("Recent Loads")); + tablesPanel.add(recentScroll); + + centerPanel.add(tablesPanel); + + add(centerPanel, BorderLayout.CENTER); + } + + private JLabel createStatLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(Font.BOLD, 11f)); + label.setHorizontalAlignment(SwingConstants.CENTER); + return label; + } + + /** + * Refreshes metrics from TrackLoadingMonitor. + */ + public void refreshMetrics() { + if (monitor == null) { + currentSnapshot = null; + } else { + currentSnapshot = monitor.getSnapshot(selectedWindowSeconds); + } + updateDisplay(); + } + + private void updateDisplay() { + if (currentSnapshot == null) { + totalLoadsLabel.setText("Total: --"); + successRateLabel.setText("Success: --%"); + avgLoadTimeLabel.setText("Avg: --ms"); + p95LoadTimeLabel.setText("p95: --ms"); + failedLoadsLabel.setText("Failed: --"); + sourceTableModel.setRowCount(0); + recentLoadsModel.setRowCount(0); + repaintCharts(); + return; + } + + // Update summary stats + totalLoadsLabel.setText("Total: " + currentSnapshot.loadsInWindow()); + + double successRate = currentSnapshot.successRatePercent(); + successRateLabel.setText(String.format("Success: %.1f%%", successRate)); + successRateLabel.setForeground(successRate >= 95 ? GOOD_COLOR : + successRate >= 80 ? WARNING_COLOR : CRITICAL_COLOR); + + avgLoadTimeLabel.setText(String.format("Avg: %.0fms", currentSnapshot.avgLoadDurationMs())); + p95LoadTimeLabel.setText(String.format("p95: %.0fms", currentSnapshot.p95LoadDurationMs())); + + failedLoadsLabel.setText("Failed: " + currentSnapshot.failedInWindow()); + failedLoadsLabel.setForeground(currentSnapshot.failedInWindow() > 0 ? CRITICAL_COLOR : null); + + // Update source table + updateSourceTable(); + + // Update recent loads table + updateRecentLoadsTable(); + + repaintCharts(); + } + + private void updateSourceTable() { + sourceTableModel.setRowCount(0); + + for (String source : currentSnapshot.trackedSources()) { + SourceStats stats = monitor.getSourceStats(source); + if (stats == null) continue; + + sourceTableModel.addRow(new Object[]{ + source, + stats.getTotalLoads(), + String.format("%.1f%%", stats.getSuccessRate()), + String.format("%.0f", stats.getAverageDurationMs()), + String.format("%.0f", stats.getP95DurationMs()) + }); + } + } + + private void updateRecentLoadsTable() { + recentLoadsModel.setRowCount(0); + + SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); + LoadEvent[] events = currentSnapshot.recentEvents(); + + // Show most recent 20 events, reversed so newest is at top + int start = Math.max(0, events.length - 20); + for (int i = events.length - 1; i >= start; i--) { + LoadEvent e = events[i]; + + String details = e.trackOrPlaylistName() != null + ? truncate(e.trackOrPlaylistName(), 40) + : e.errorMessage() != null ? truncate(e.errorMessage(), 40) : "-"; + + recentLoadsModel.addRow(new Object[]{ + sdf.format(new Date(e.timestamp())), + e.source(), + e.result().name(), + e.loadDurationMs() + "ms", + details + }); + } + } + + private String truncate(String s, int maxLen) { + if (s == null) return ""; + return s.length() > maxLen ? s.substring(0, maxLen - 2) + ".." : s; + } + + private void repaintCharts() { + loadTimeChart.repaint(); + successPie.repaint(); + } + + // ===== Chart Panels ===== + + private class LoadTimeChartPanel extends JPanel { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(), h = getHeight(); + int margin = 40; + int graphW = w - margin - 10; + int graphH = h - 30; + + g2.setColor(GRAPH_BG); + g2.fillRect(margin, 10, graphW, graphH); + + if (currentSnapshot == null || currentSnapshot.trackedSources().length == 0) { + g2.setColor(Color.GRAY); + g2.drawString("No data", w / 2 - 20, h / 2); + g2.dispose(); + return; + } + + String[] sources = currentSnapshot.trackedSources(); + int barCount = sources.length; + int barWidth = Math.max(20, (graphW - 10) / barCount - 10); + + // Find max duration for scaling + double maxDuration = 100; + for (String source : sources) { + SourceStats stats = monitor.getSourceStats(source); + if (stats != null) { + maxDuration = Math.max(maxDuration, stats.getP95DurationMs()); + } + } + maxDuration = Math.ceil(maxDuration / 100) * 100; // Round up to nearest 100 + + // Draw Y axis labels + g2.setFont(g2.getFont().deriveFont(8f)); + g2.setColor(Color.GRAY); + g2.drawString(String.format("%.0f", maxDuration), 5, 15); + g2.drawString("0", 5, 10 + graphH); + + // Draw bars + for (int i = 0; i < sources.length; i++) { + String source = sources[i]; + SourceStats stats = monitor.getSourceStats(source); + if (stats == null) continue; + + int x = margin + 5 + i * (barWidth + 10); + + // Avg bar + double avgHeight = (stats.getAverageDurationMs() / maxDuration) * graphH; + g2.setColor(new Color(70, 130, 180)); + g2.fillRect(x, 10 + graphH - (int) avgHeight, barWidth / 2 - 2, (int) avgHeight); + + // P95 bar (outline) + double p95Height = (stats.getP95DurationMs() / maxDuration) * graphH; + g2.setColor(new Color(255, 140, 0)); + g2.fillRect(x + barWidth / 2, 10 + graphH - (int) p95Height, barWidth / 2 - 2, (int) p95Height); + + // Source label + g2.setColor(Color.GRAY); + String label = truncate(source, 8); + g2.drawString(label, x, h - 5); + } + + // Legend + g2.setFont(g2.getFont().deriveFont(9f)); + g2.setColor(new Color(70, 130, 180)); + g2.fillRect(w - 70, 15, 10, 10); + g2.setColor(Color.GRAY); + g2.drawString("Avg", w - 55, 24); + + g2.setColor(new Color(255, 140, 0)); + g2.fillRect(w - 70, 30, 10, 10); + g2.setColor(Color.GRAY); + g2.drawString("p95", w - 55, 39); + + g2.setColor(Color.GRAY); + g2.drawRect(margin, 10, graphW, graphH); + g2.dispose(); + } + } + + private class SuccessPiePanel extends JPanel { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(), h = getHeight(); + + g2.setColor(GRAPH_BG); + g2.fillRect(10, 10, w - 20, h - 20); + + if (currentSnapshot == null || currentSnapshot.loadsInWindow() == 0) { + g2.setColor(Color.GRAY); + g2.drawString("No data", w / 2 - 20, h / 2); + g2.dispose(); + return; + } + + int success = currentSnapshot.successInWindow(); + int failed = currentSnapshot.failedInWindow(); + int noMatch = currentSnapshot.noMatchInWindow(); + int total = success + failed + noMatch; + + int diameter = Math.min(w, h) - 60; + int x = (w - diameter) / 2; + int y = 20; + + // Draw pie slices + int startAngle = 0; + + if (success > 0) { + int arc = (int) ((success * 360.0) / total); + g2.setColor(GOOD_COLOR); + g2.fillArc(x, y, diameter, diameter, startAngle, arc); + startAngle += arc; + } + + if (noMatch > 0) { + int arc = (int) ((noMatch * 360.0) / total); + g2.setColor(WARNING_COLOR); + g2.fillArc(x, y, diameter, diameter, startAngle, arc); + startAngle += arc; + } + + if (failed > 0) { + int arc = 360 - startAngle; // Remainder + g2.setColor(CRITICAL_COLOR); + g2.fillArc(x, y, diameter, diameter, startAngle, arc); + } + + // Legend + int legendY = y + diameter + 15; + g2.setFont(g2.getFont().deriveFont(9f)); + + g2.setColor(GOOD_COLOR); + g2.fillRect(10, legendY, 10, 10); + g2.setColor(Color.GRAY); + g2.drawString("Success: " + success, 25, legendY + 9); + + g2.setColor(WARNING_COLOR); + g2.fillRect(w / 3, legendY, 10, 10); + g2.setColor(Color.GRAY); + g2.drawString("No Match: " + noMatch, w / 3 + 15, legendY + 9); + + g2.setColor(CRITICAL_COLOR); + g2.fillRect(2 * w / 3, legendY, 10, 10); + g2.setColor(Color.GRAY); + g2.drawString("Failed: " + failed, 2 * w / 3 + 15, legendY + 9); + + g2.setColor(Color.GRAY); + g2.drawRect(10, 10, w - 20, h - 20); + g2.dispose(); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/panels/StatusPanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/panels/StatusPanel.java new file mode 100644 index 0000000..8be5c55 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/panels/StatusPanel.java @@ -0,0 +1,369 @@ +/* + * 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.gui.panels; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.gui.model.BotStatusData; +import com.jagrosh.jmusicbot.utils.FormatUtil; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import javax.swing.event.ListSelectionEvent; +import java.awt.*; +import java.util.List; + +/** + * Panel displaying connected guilds with detailed view on selection. + * Shows guild list on left, details on right when a guild is selected. + * + * @author Arif Banai (arif-banai) + */ +public class StatusPanel extends JPanel { + + private final Bot bot; + private final DefaultListModel<GuildListItem> guildListModel; + private final JList<GuildListItem> guildList; + private final JPanel detailsPanel; + private final JLabel noSelectionLabel; + + // Detail labels + private final JLabel guildNameLabel; + private final JLabel guildIdLabel; + private final JLabel memberCountLabel; + private final JLabel voiceStatusLabel; + private final JLabel voiceChannelLabel; + private final JLabel nowPlayingLabel; + private final JLabel queueSizeLabel; + private final JLabel volumeLabel; + private final JLabel repeatModeLabel; + + private List<Guild> currentGuilds = List.of(); + + public StatusPanel(Bot bot) { + super(new BorderLayout(8, 8)); + setBorder(new EmptyBorder(8, 8, 8, 8)); + + this.bot = bot; + + // Initialize guild list + guildListModel = new DefaultListModel<>(); + guildList = new JList<>(guildListModel); + guildList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + guildList.setCellRenderer(new GuildListCellRenderer()); + guildList.addListSelectionListener(this::onGuildSelected); + + JScrollPane listScrollPane = new JScrollPane(guildList); + listScrollPane.setBorder(new TitledBorder("Connected Guilds")); + listScrollPane.setPreferredSize(new Dimension(250, 0)); + + // Initialize detail labels + guildNameLabel = new JLabel("-"); + guildIdLabel = new JLabel("-"); + memberCountLabel = new JLabel("-"); + voiceStatusLabel = new JLabel("-"); + voiceChannelLabel = new JLabel("-"); + nowPlayingLabel = new JLabel("-"); + queueSizeLabel = new JLabel("-"); + volumeLabel = new JLabel("-"); + repeatModeLabel = new JLabel("-"); + + // Create details panel + detailsPanel = createDetailsPanel(); + detailsPanel.setVisible(false); + + // No selection label + noSelectionLabel = new JLabel("Select a guild to view details", SwingConstants.CENTER); + noSelectionLabel.setForeground(Color.GRAY); + noSelectionLabel.setFont(noSelectionLabel.getFont().deriveFont(Font.ITALIC, 14f)); + + // Right panel with card layout for switching between no-selection and details + JPanel rightPanel = new JPanel(new BorderLayout()); + rightPanel.setBorder(new TitledBorder("Guild Details")); + rightPanel.add(noSelectionLabel, BorderLayout.CENTER); + + // Create split pane + JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, listScrollPane, rightPanel); + splitPane.setDividerLocation(250); + splitPane.setResizeWeight(0.3); + + add(splitPane, BorderLayout.CENTER); + } + + /** + * Creates the details panel with all guild information fields. + */ + private JPanel createDetailsPanel() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new EmptyBorder(8, 8, 8, 8)); + + GridBagConstraints labelGbc = new GridBagConstraints(); + labelGbc.anchor = GridBagConstraints.WEST; + labelGbc.insets = new Insets(4, 4, 4, 8); + labelGbc.gridx = 0; + + GridBagConstraints valueGbc = new GridBagConstraints(); + valueGbc.anchor = GridBagConstraints.WEST; + valueGbc.insets = new Insets(4, 0, 4, 4); + valueGbc.gridx = 1; + valueGbc.weightx = 1.0; + valueGbc.fill = GridBagConstraints.HORIZONTAL; + + int row = 0; + + // Guild Info Section + addSectionHeader(panel, "Guild Information", row++, labelGbc); + addDetailRow(panel, "Name:", guildNameLabel, row++, labelGbc, valueGbc); + addDetailRow(panel, "ID:", guildIdLabel, row++, labelGbc, valueGbc); + addDetailRow(panel, "Members:", memberCountLabel, row++, labelGbc, valueGbc); + + // Voice Section + row++; // spacer + addSectionHeader(panel, "Voice Status", row++, labelGbc); + addDetailRow(panel, "Status:", voiceStatusLabel, row++, labelGbc, valueGbc); + addDetailRow(panel, "Channel:", voiceChannelLabel, row++, labelGbc, valueGbc); + + // Playback Section + row++; // spacer + addSectionHeader(panel, "Playback", row++, labelGbc); + addDetailRow(panel, "Now Playing:", nowPlayingLabel, row++, labelGbc, valueGbc); + addDetailRow(panel, "Queue Size:", queueSizeLabel, row++, labelGbc, valueGbc); + addDetailRow(panel, "Volume:", volumeLabel, row++, labelGbc, valueGbc); + addDetailRow(panel, "Repeat:", repeatModeLabel, row++, labelGbc, valueGbc); + + // Filler to push content to top + GridBagConstraints fillerGbc = new GridBagConstraints(); + fillerGbc.gridx = 0; + fillerGbc.gridy = row; + fillerGbc.weighty = 1.0; + fillerGbc.fill = GridBagConstraints.VERTICAL; + panel.add(Box.createVerticalGlue(), fillerGbc); + + return panel; + } + + private void addSectionHeader(JPanel panel, String text, int row, GridBagConstraints gbc) { + JLabel header = new JLabel(text); + header.setFont(header.getFont().deriveFont(Font.BOLD, 13f)); + gbc.gridy = row; + gbc.gridwidth = 2; + panel.add(header, gbc); + gbc.gridwidth = 1; + } + + private void addDetailRow(JPanel panel, String labelText, JLabel valueLabel, int row, + GridBagConstraints labelGbc, GridBagConstraints valueGbc) { + JLabel label = new JLabel(labelText); + label.setForeground(Color.GRAY); + + labelGbc.gridy = row; + valueGbc.gridy = row; + + panel.add(label, labelGbc); + panel.add(valueLabel, valueGbc); + } + + /** + * Handles guild selection from the list. + */ + private void onGuildSelected(ListSelectionEvent e) { + if (e.getValueIsAdjusting()) { + return; + } + + GuildListItem selected = guildList.getSelectedValue(); + if (selected == null) { + showNoSelection(); + return; + } + + updateDetailsForGuild(selected.guild()); + } + + /** + * Shows the "no selection" state. + */ + private void showNoSelection() { + Container parent = detailsPanel.getParent(); + if (parent != null) { + parent.remove(detailsPanel); + parent.add(noSelectionLabel, BorderLayout.CENTER); + parent.revalidate(); + parent.repaint(); + } + } + + /** + * Shows detailed information for the selected guild. + */ + private void updateDetailsForGuild(Guild guild) { + // Update labels + guildNameLabel.setText(guild.getName()); + guildIdLabel.setText(guild.getId()); + memberCountLabel.setText(String.valueOf(guild.getMemberCount())); + + // Voice status + boolean inVoice = guild.getAudioManager().isConnected(); + if (inVoice) { + voiceStatusLabel.setText("Connected"); + voiceStatusLabel.setForeground(new Color(46, 204, 113)); + AudioChannel channel = guild.getAudioManager().getConnectedChannel(); + voiceChannelLabel.setText(channel != null ? channel.getName() : "Unknown"); + } else { + voiceStatusLabel.setText("Not Connected"); + voiceStatusLabel.setForeground(Color.GRAY); + voiceChannelLabel.setText("-"); + } + + // Playback info + updatePlaybackInfo(guild); + + // Show details panel + Container parent = noSelectionLabel.getParent(); + if (parent != null) { + parent.remove(noSelectionLabel); + parent.add(detailsPanel, BorderLayout.CENTER); + detailsPanel.setVisible(true); + parent.revalidate(); + parent.repaint(); + } + } + + /** + * Updates playback information for a guild. + */ + private void updatePlaybackInfo(Guild guild) { + try { + var sendingHandler = guild.getAudioManager().getSendingHandler(); + if (!(sendingHandler instanceof AudioHandler handler)) { + setNoPlaybackInfo(); + return; + } + + var player = handler.getPlayer(); + var track = player != null ? player.getPlayingTrack() : null; + + if (track == null) { + nowPlayingLabel.setText("Nothing playing"); + nowPlayingLabel.setForeground(Color.GRAY); + } else { + String title = FormatUtil.getTrackTitle(track); + if (title == null) { + title = ""; + } + if (title.length() > 40) { + title = title.substring(0, 37) + "..."; + } + nowPlayingLabel.setText(title); + nowPlayingLabel.setForeground(null); // default color + } + + // Queue size + int queueSize = handler.getQueue().size(); + queueSizeLabel.setText(queueSize + " track" + (queueSize != 1 ? "s" : "")); + + // Volume + int volume = player != null ? player.getVolume() : 100; + volumeLabel.setText(volume + "%"); + + // Repeat mode - fetched from settings manager + var repeatMode = bot.getSettingsManager().getSettings(guild).getRepeatMode(); + repeatModeLabel.setText(repeatMode != null ? repeatMode.getUserFriendlyName() : "Off"); + + } catch (Exception e) { + setNoPlaybackInfo(); + } + } + + private void setNoPlaybackInfo() { + nowPlayingLabel.setText("-"); + nowPlayingLabel.setForeground(Color.GRAY); + queueSizeLabel.setText("-"); + volumeLabel.setText("-"); + repeatModeLabel.setText("-"); + } + + /** + * Updates the panel with shared status data from MainFrame. + * + * @param statusData the shared status data + */ + public void updateStatus(BotStatusData statusData) { + this.currentGuilds = statusData.guilds(); + + // Remember selected guild + GuildListItem selected = guildList.getSelectedValue(); + String selectedId = selected != null ? selected.guild().getId() : null; + + // Update guild list + guildListModel.clear(); + for (Guild guild : currentGuilds) { + boolean inVoice = guild.getAudioManager().isConnected(); + guildListModel.addElement(new GuildListItem(guild, inVoice)); + } + + // Restore selection if still exists + if (selectedId != null) { + for (int i = 0; i < guildListModel.size(); i++) { + if (guildListModel.get(i).guild().getId().equals(selectedId)) { + guildList.setSelectedIndex(i); + // Update details for selected guild + updateDetailsForGuild(guildListModel.get(i).guild()); + break; + } + } + } + } + + /** + * Record to hold guild info for the list. + */ + private record GuildListItem(Guild guild, boolean inVoice) { + @Override + public String toString() { + return guild.getName(); + } + } + + /** + * Custom cell renderer for guild list items. + */ + private static class GuildListCellRenderer extends DefaultListCellRenderer { + private static final Color VOICE_ACTIVE_COLOR = new Color(46, 204, 113); + + @Override + public Component getListCellRendererComponent(JList<?> list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + + if (value instanceof GuildListItem item) { + setText(item.guild().getName()); + if (item.inVoice() && !isSelected) { + setForeground(VOICE_ACTIVE_COLOR); + } + // Add voice indicator + if (item.inVoice()) { + setText("♪ " + item.guild().getName()); + } + } + + return this; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/panels/SystemHealthPanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/panels/SystemHealthPanel.java new file mode 100644 index 0000000..9672ea5 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/panels/SystemHealthPanel.java @@ -0,0 +1,520 @@ +/* + * 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.gui.panels; + +import com.jagrosh.jmusicbot.audio.GCMonitor; +import com.jagrosh.jmusicbot.audio.SystemHealthMonitor; +import com.jagrosh.jmusicbot.audio.SystemHealthMonitor.HealthSample; +import com.jagrosh.jmusicbot.audio.SystemHealthMonitor.HealthSnapshot; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import java.awt.*; + +/** + * Panel for displaying system health metrics including CPU, memory, GC events. + * @author Arif Banai (arif-banai) + */ +public class SystemHealthPanel extends JPanel { + + private static final Color GOOD_COLOR = new Color(46, 204, 113); + private static final Color WARNING_COLOR = new Color(241, 196, 15); + private static final Color CRITICAL_COLOR = new Color(231, 76, 60); + private static final Color GRAPH_BG = new Color(25, 25, 30); + private static final Color CPU_COLOR = new Color(52, 152, 219); + private static final Color HEAP_COLOR = new Color(155, 89, 182); + private static final Color GC_COLOR = new Color(230, 126, 34); + + private final JComboBox<WindowOption> windowSelector; + + // Charts + private final CpuChartPanel cpuChart; + private final HeapChartPanel heapChart; + private final GcChartPanel gcChart; + + // Stats labels + private final JLabel statusLabel; + private final JLabel cpuLabel; + private final JLabel heapLabel; + private final JLabel gcCountLabel; + private final JLabel gcTimeLabel; + private final JLabel threadsLabel; + private final JLabel allocRateLabel; + + private HealthSnapshot currentSnapshot; + private int selectedWindowSeconds = 60; + + private enum WindowOption { + SECONDS_30(30, "30 seconds"), + MINUTE_1(60, "1 minute"), + MINUTE_2(120, "2 minutes"), + MINUTE_5(300, "5 minutes"); + + private final int seconds; + private final String display; + + WindowOption(int seconds, String display) { + this.seconds = seconds; + this.display = display; + } + + public int getSeconds() { return seconds; } + + @Override + public String toString() { return display; } + } + + public SystemHealthPanel() { + super(new BorderLayout(4, 4)); + setBorder(new EmptyBorder(4, 4, 4, 4)); + + // === TOP: Controls === + JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2)); + + topPanel.add(new JLabel("Window:")); + windowSelector = new JComboBox<>(WindowOption.values()); + windowSelector.setSelectedItem(WindowOption.MINUTE_1); + windowSelector.addActionListener(e -> { + WindowOption sel = (WindowOption) windowSelector.getSelectedItem(); + if (sel != null) { + selectedWindowSeconds = sel.getSeconds(); + refreshMetrics(); + } + }); + topPanel.add(windowSelector); + + JButton refreshBtn = new JButton("Refresh"); + refreshBtn.addActionListener(e -> refreshMetrics()); + topPanel.add(refreshBtn); + + topPanel.add(Box.createHorizontalStrut(8)); + statusLabel = new JLabel("●"); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.BOLD, 16f)); + topPanel.add(statusLabel); + + add(topPanel, BorderLayout.NORTH); + + // === CHARTS (GridBagLayout for better space allocation) === + JPanel chartsPanel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.BOTH; + gbc.insets = new Insets(2, 2, 2, 2); + + // Row 0: CPU and Heap side by side (weight 1.0 each) + cpuChart = new CpuChartPanel(); + gbc.gridx = 0; gbc.gridy = 0; + gbc.weightx = 1.0; gbc.weighty = 1.0; + chartsPanel.add(createChartContainer("CPU Usage (%)", cpuChart), gbc); + + heapChart = new HeapChartPanel(); + gbc.gridx = 1; + chartsPanel.add(createChartContainer("Heap Memory", heapChart), gbc); + + // Row 1: GC Events (full width, weight 1.0) + gcChart = new GcChartPanel(); + gbc.gridx = 0; gbc.gridy = 1; + gbc.gridwidth = 2; + chartsPanel.add(createChartContainer("GC Events", gcChart), gbc); + + // === STATS BAR (3 rows x 4 columns for more info) === + JPanel statsPanel = new JPanel(new GridLayout(3, 4, 8, 2)); + statsPanel.setBorder(new TitledBorder("Current Stats")); + + cpuLabel = createStatLabel("--%"); + heapLabel = createStatLabel("--"); + gcCountLabel = createStatLabel("--"); + gcTimeLabel = createStatLabel("--"); + threadsLabel = createStatLabel("--"); + allocRateLabel = createStatLabel("--"); + + // Row 1: CPU and Heap + statsPanel.add(createFixedLabel("CPU:")); + statsPanel.add(cpuLabel); + statsPanel.add(createFixedLabel("Heap:")); + statsPanel.add(heapLabel); + + // Row 2: GC stats + statsPanel.add(createFixedLabel("GC Count:")); + statsPanel.add(gcCountLabel); + statsPanel.add(createFixedLabel("GC Time:")); + statsPanel.add(gcTimeLabel); + + // Row 3: Threads and Alloc Rate + statsPanel.add(createFixedLabel("Threads:")); + statsPanel.add(threadsLabel); + statsPanel.add(createFixedLabel("Alloc Rate:")); + statsPanel.add(allocRateLabel); + + // Split pane + JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, chartsPanel, statsPanel); + splitPane.setResizeWeight(0.80); + splitPane.setDividerSize(5); + splitPane.setContinuousLayout(true); + + add(splitPane, BorderLayout.CENTER); + } + + private JPanel createChartContainer(String title, JPanel chart) { + JPanel container = new JPanel(new BorderLayout()); + container.setBorder(new TitledBorder(title)); + container.add(chart, BorderLayout.CENTER); + return container; + } + + private JLabel createStatLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(11f)); + return label; + } + + private JLabel createFixedLabel(String text) { + JLabel label = new JLabel(text); + label.setFont(label.getFont().deriveFont(Font.BOLD, 10f)); + label.setForeground(Color.GRAY); + return label; + } + + public void refreshMetrics() { + currentSnapshot = SystemHealthMonitor.getInstance().getSnapshot(selectedWindowSeconds); + updateDisplay(); + } + + private void updateDisplay() { + if (currentSnapshot == null || currentSnapshot.isEmpty()) { + statusLabel.setForeground(Color.GRAY); + cpuLabel.setText("--%"); + heapLabel.setText("--"); + gcCountLabel.setText("--"); + gcTimeLabel.setText("--"); + threadsLabel.setText("--"); + allocRateLabel.setText("--"); + repaintCharts(); + return; + } + + // Update status indicator + statusLabel.setForeground(switch (currentSnapshot.status()) { + case GOOD -> GOOD_COLOR; + case WARNING -> WARNING_COLOR; + case CRITICAL -> CRITICAL_COLOR; + }); + + // Update stat labels + if (currentSnapshot.currentProcessCpu() >= 0) { + cpuLabel.setText(String.format("%.1f%%", currentSnapshot.currentProcessCpu())); + cpuLabel.setForeground(currentSnapshot.currentProcessCpu() > 80 ? CRITICAL_COLOR : + currentSnapshot.currentProcessCpu() > 50 ? WARNING_COLOR : null); + } else { + cpuLabel.setText("N/A"); + } + + heapLabel.setText(currentSnapshot.formattedHeap()); + heapLabel.setForeground(currentSnapshot.currentHeapPercent() > 80 ? CRITICAL_COLOR : + currentSnapshot.currentHeapPercent() > 60 ? WARNING_COLOR : null); + + GCMonitor.GCEvent[] gcEvents = GCMonitor.getInstance().getRecentEvents(selectedWindowSeconds); + gcCountLabel.setText(String.valueOf(gcEvents.length)); + + long totalGcTime = 0; + for (GCMonitor.GCEvent e : gcEvents) { + totalGcTime += e.durationMs(); + } + gcTimeLabel.setText(totalGcTime + " ms"); + gcTimeLabel.setForeground(totalGcTime > 500 ? CRITICAL_COLOR : + totalGcTime > 100 ? WARNING_COLOR : null); + + threadsLabel.setText(String.valueOf(currentSnapshot.currentThreadCount())); + + if (currentSnapshot.avgAllocationRate() > 0) { + double mbPerSec = currentSnapshot.avgAllocationRate() / (1024 * 1024); + allocRateLabel.setText(String.format("%.1f MB/s", mbPerSec)); + } else { + allocRateLabel.setText("--"); + } + + repaintCharts(); + } + + private void repaintCharts() { + cpuChart.repaint(); + heapChart.repaint(); + gcChart.repaint(); + } + + public void updateMetrics() { + refreshMetrics(); + } + + // ===== Chart Panels ===== + + private class CpuChartPanel extends JPanel { + private static final Color GRID_COLOR = new Color(50, 50, 55); + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(), h = getHeight(); + int margin = 30; + int graphW = w - margin - 5; + int graphH = h - 20; + int graphTop = 5; + + g2.setColor(GRAPH_BG); + g2.fillRect(margin, graphTop, graphW, graphH); + + // Draw horizontal grid lines at 25%, 50%, 75% + g2.setFont(g2.getFont().deriveFont(9f)); + for (int pct = 25; pct < 100; pct += 25) { + int y = graphTop + graphH - (int) (pct / 100.0 * graphH); + g2.setColor(GRID_COLOR); + g2.drawLine(margin, y, margin + graphW, y); + g2.setColor(Color.GRAY); + g2.drawString(pct + "", 2, y + 4); + } + + // Y-axis labels + g2.setColor(Color.GRAY); + g2.drawString("100%", 2, graphTop + 10); + g2.drawString("0", 2, graphTop + graphH - 2); + + if (currentSnapshot == null || currentSnapshot.isEmpty()) { + drawCentered(g2, "No data", w, h); + g2.dispose(); + return; + } + + HealthSample[] samples = currentSnapshot.samples(); + if (samples.length < 2) { + drawCentered(g2, "Collecting...", w, h); + g2.dispose(); + return; + } + + // Draw CPU area fill + g2.setColor(new Color(CPU_COLOR.getRed(), CPU_COLOR.getGreen(), CPU_COLOR.getBlue(), 40)); + int[] xPoints = new int[samples.length + 2]; + int[] yPoints = new int[samples.length + 2]; + for (int i = 0; i < samples.length; i++) { + double cpu = Math.max(0, samples[i].processCpuPercent()); + xPoints[i] = margin + (i * graphW / samples.length); + yPoints[i] = graphTop + graphH - (int) (cpu / 100.0 * graphH); + } + xPoints[samples.length] = margin + graphW; + yPoints[samples.length] = graphTop + graphH; + xPoints[samples.length + 1] = margin; + yPoints[samples.length + 1] = graphTop + graphH; + g2.fillPolygon(xPoints, yPoints, samples.length + 2); + + // Draw CPU line + g2.setColor(CPU_COLOR); + g2.setStroke(new BasicStroke(2f)); + + for (int i = 0; i < samples.length - 1; i++) { + double cpu1 = Math.max(0, samples[i].processCpuPercent()); + double cpu2 = Math.max(0, samples[i + 1].processCpuPercent()); + + int x1 = margin + (i * graphW / samples.length); + int x2 = margin + ((i + 1) * graphW / samples.length); + int y1 = graphTop + graphH - (int) (cpu1 / 100.0 * graphH); + int y2 = graphTop + graphH - (int) (cpu2 / 100.0 * graphH); + + g2.drawLine(x1, y1, x2, y2); + } + + g2.setStroke(new BasicStroke(1)); + g2.setColor(Color.GRAY); + g2.drawRect(margin, graphTop, graphW, graphH); + g2.dispose(); + } + } + + private class HeapChartPanel extends JPanel { + private static final Color GRID_COLOR = new Color(50, 50, 55); + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(), h = getHeight(); + int margin = 30; + int graphW = w - margin - 5; + int graphH = h - 20; + int graphTop = 5; + + g2.setColor(GRAPH_BG); + g2.fillRect(margin, graphTop, graphW, graphH); + + // Draw horizontal grid lines at 25%, 50%, 75% + g2.setFont(g2.getFont().deriveFont(9f)); + for (int pct = 25; pct < 100; pct += 25) { + int y = graphTop + graphH - (int) (pct / 100.0 * graphH); + g2.setColor(GRID_COLOR); + g2.drawLine(margin, y, margin + graphW, y); + g2.setColor(Color.GRAY); + g2.drawString(pct + "", 2, y + 4); + } + + // Y-axis labels + g2.setColor(Color.GRAY); + g2.drawString("100%", 2, graphTop + 10); + g2.drawString("0", 2, graphTop + graphH - 2); + + if (currentSnapshot == null || currentSnapshot.isEmpty()) { + drawCentered(g2, "No data", w, h); + g2.dispose(); + return; + } + + HealthSample[] samples = currentSnapshot.samples(); + if (samples.length < 2) { + drawCentered(g2, "Collecting...", w, h); + g2.dispose(); + return; + } + + // Draw heap fill + g2.setColor(new Color(HEAP_COLOR.getRed(), HEAP_COLOR.getGreen(), HEAP_COLOR.getBlue(), 50)); + int[] xPoints = new int[samples.length + 2]; + int[] yPoints = new int[samples.length + 2]; + + for (int i = 0; i < samples.length; i++) { + xPoints[i] = margin + (i * graphW / samples.length); + yPoints[i] = graphTop + graphH - (int) (samples[i].heapUsedPercent() / 100.0 * graphH); + } + xPoints[samples.length] = margin + graphW; + yPoints[samples.length] = graphTop + graphH; + xPoints[samples.length + 1] = margin; + yPoints[samples.length + 1] = graphTop + graphH; + + g2.fillPolygon(xPoints, yPoints, samples.length + 2); + + // Draw heap line + g2.setColor(HEAP_COLOR); + g2.setStroke(new BasicStroke(2f)); + + for (int i = 0; i < samples.length - 1; i++) { + int x1 = margin + (i * graphW / samples.length); + int x2 = margin + ((i + 1) * graphW / samples.length); + int y1 = graphTop + graphH - (int) (samples[i].heapUsedPercent() / 100.0 * graphH); + int y2 = graphTop + graphH - (int) (samples[i + 1].heapUsedPercent() / 100.0 * graphH); + + g2.drawLine(x1, y1, x2, y2); + } + + g2.setStroke(new BasicStroke(1)); + g2.setColor(Color.GRAY); + g2.drawRect(margin, graphTop, graphW, graphH); + g2.dispose(); + } + } + + private class GcChartPanel extends JPanel { + private static final Color GRID_COLOR = new Color(50, 50, 55); + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + int w = getWidth(), h = getHeight(); + int margin = 35; + int graphW = w - margin - 10; + int graphH = h - 25; + int graphTop = 5; + + g2.setColor(GRAPH_BG); + g2.fillRect(margin, graphTop, graphW, graphH); + + GCMonitor.GCEvent[] gcEvents = GCMonitor.getInstance().getRecentEvents(selectedWindowSeconds); + + long windowMs = selectedWindowSeconds * 1000L; + long now = System.currentTimeMillis(); + long startTime = now - windowMs; + + // Find max duration for scaling + long maxDuration = 10; + for (GCMonitor.GCEvent e : gcEvents) { + maxDuration = Math.max(maxDuration, e.durationMs()); + } + // Use adaptive scale + long scaleMax = maxDuration <= 10 ? 10 : maxDuration <= 25 ? 25 : + maxDuration <= 50 ? 50 : maxDuration <= 100 ? 100 : + ((maxDuration / 50) + 1) * 50; + + // Draw horizontal grid lines + g2.setFont(g2.getFont().deriveFont(9f)); + long gridStep = scaleMax <= 25 ? 5 : scaleMax <= 50 ? 10 : scaleMax <= 100 ? 25 : 50; + for (long ms = gridStep; ms < scaleMax; ms += gridStep) { + int y = graphTop + graphH - (int) ((double) ms / scaleMax * graphH); + g2.setColor(GRID_COLOR); + g2.drawLine(margin, y, margin + graphW, y); + g2.setColor(Color.GRAY); + g2.drawString(ms + "", 2, y + 4); + } + + // Y-axis labels + g2.setColor(Color.GRAY); + g2.drawString(scaleMax + "ms", 2, graphTop + 10); + g2.drawString("0", 2, graphTop + graphH - 2); + + // Draw time axis labels + int timeInterval = selectedWindowSeconds <= 30 ? 10 : selectedWindowSeconds <= 60 ? 15 : + selectedWindowSeconds <= 120 ? 30 : 60; + for (int sec = 0; sec <= selectedWindowSeconds; sec += timeInterval) { + int x = margin + (int) ((double) sec / selectedWindowSeconds * graphW); + g2.setColor(GRID_COLOR); + g2.drawLine(x, graphTop, x, graphTop + graphH); + g2.setColor(Color.GRAY); + g2.drawString("-" + (selectedWindowSeconds - sec) + "s", x - 8, h - 3); + } + + if (gcEvents.length == 0) { + g2.setColor(Color.GRAY); + g2.drawString("No GC events", w / 2 - 30, graphTop + graphH / 2); + } else { + // Draw GC bars + for (GCMonitor.GCEvent e : gcEvents) { + if (e.timestamp() < startTime) continue; + + int x = margin + (int) ((e.timestamp() - startTime) / (double) windowMs * graphW); + int barHeight = (int) ((double) e.durationMs() / scaleMax * graphH); + barHeight = Math.min(barHeight, graphH); + + g2.setColor(e.severity() == GCMonitor.GCEvent.Severity.SEVERE ? CRITICAL_COLOR : + e.severity() == GCMonitor.GCEvent.Severity.MODERATE ? WARNING_COLOR : GC_COLOR); + g2.fillRect(x - 3, graphTop + graphH - barHeight, 6, barHeight); + } + } + + g2.setColor(Color.GRAY); + g2.drawRect(margin, graphTop, graphW, graphH); + g2.dispose(); + } + } + + private void drawCentered(Graphics2D g2, String text, int w, int h) { + g2.setColor(Color.GRAY); + int tw = g2.getFontMetrics().stringWidth(text); + g2.drawString(text, w / 2 - tw / 2, h / 2 + 4); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/theme/ThemeManager.java b/src/main/java/com/jagrosh/jmusicbot/gui/theme/ThemeManager.java new file mode 100644 index 0000000..b136603 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/gui/theme/ThemeManager.java @@ -0,0 +1,285 @@ +/* + * 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.gui.theme; + +import com.formdev.flatlaf.FlatDarculaLaf; +import com.formdev.flatlaf.FlatDarkLaf; +import com.formdev.flatlaf.FlatIntelliJLaf; +import com.formdev.flatlaf.FlatLaf; +import com.formdev.flatlaf.FlatLightLaf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.awt.*; +import java.util.List; + +/** + * Manages FlatLaf theme initialization and runtime theme switching. + * This class must be initialized BEFORE any Swing components are created. + * + * @author Arif Banai (arif-banai) + */ +public final class ThemeManager { + + private static final Logger LOG = LoggerFactory.getLogger(ThemeManager.class); + + /** + * Available themes for the application. + */ + public enum Theme { + LIGHT("Light", "light"), + DARK("Dark", "dark"), + DARCULA("Darcula", "darcula"), + INTELLIJ("IntelliJ", "intellij"); + + private final String displayName; + private final String configKey; + + Theme(String displayName, String configKey) { + this.displayName = displayName; + this.configKey = configKey; + } + + public String getDisplayName() { + return displayName; + } + + public String getConfigKey() { + return configKey; + } + + /** + * Parses a theme from its config key, defaulting to DARK if not found. + */ + public static Theme fromConfigKey(String key) { + if (key == null || key.isBlank()) { + return DARK; + } + String normalizedKey = key.toLowerCase().trim(); + for (Theme theme : values()) { + if (theme.configKey.equals(normalizedKey)) { + return theme; + } + } + LOG.warn("Unknown theme '{}', defaulting to Dark", key); + return DARK; + } + + @Override + public String toString() { + return displayName; + } + } + + private static Theme currentTheme = Theme.DARK; + private static boolean initialized = false; + private static int baseFontSize = 12; + + private ThemeManager() { + // Utility class - prevent instantiation + } + + /** + * Initializes FlatLaf with the specified theme. + * MUST be called before any Swing components are created. + * + * @param themeName the theme config key (light, dark, darcula, intellij) + */ + public static void initialize(String themeName) { + initialize(Theme.fromConfigKey(themeName), 12); + } + + /** + * Initializes FlatLaf with the specified theme and font size. + * MUST be called before any Swing components are created. + * + * @param themeName the theme config key (light, dark, darcula, intellij) + * @param fontSize the base font size + */ + public static void initialize(String themeName, int fontSize) { + initialize(Theme.fromConfigKey(themeName), fontSize); + } + + /** + * Initializes FlatLaf with the specified theme. + * MUST be called before any Swing components are created. + * + * @param theme the theme to apply + * @param fontSize the base font size + */ + public static void initialize(Theme theme, int fontSize) { + if (initialized) { + LOG.debug("ThemeManager already initialized, switching theme instead"); + setTheme(theme); + return; + } + + currentTheme = theme; + baseFontSize = Math.max(8, Math.min(24, fontSize)); // Clamp between 8-24 + + // Configure FlatLaf before initialization + configureFlatLaf(); + + try { + applyThemeLookAndFeel(theme); + initialized = true; + LOG.info("Initialized FlatLaf with {} theme (font size: {})", theme.getDisplayName(), baseFontSize); + } catch (Exception e) { + LOG.error("Failed to initialize FlatLaf, falling back to system L&F", e); + tryFallbackLookAndFeel(); + } + } + + /** + * Configures FlatLaf global settings before initialization. + */ + private static void configureFlatLaf() { + // Enable rounded corners for components + UIManager.put("Button.arc", 8); + UIManager.put("Component.arc", 8); + UIManager.put("ProgressBar.arc", 8); + UIManager.put("TextComponent.arc", 8); + + // Enable menu bar embedding on macOS + System.setProperty("apple.laf.useScreenMenuBar", "true"); + System.setProperty("apple.awt.application.name", "JMusicBot"); + + // Enable FlatLaf extras + UIManager.put("TabbedPane.showTabSeparators", true); + UIManager.put("ScrollBar.showButtons", true); + + // Set default font size using UIManager + UIManager.put("defaultFont", new Font(Font.SANS_SERIF, Font.PLAIN, baseFontSize)); + } + + /** + * Applies the specified theme's Look and Feel. + */ + private static void applyThemeLookAndFeel(Theme theme) throws UnsupportedLookAndFeelException { + FlatLaf laf = switch (theme) { + case LIGHT -> new FlatLightLaf(); + case DARK -> new FlatDarkLaf(); + case DARCULA -> new FlatDarculaLaf(); + case INTELLIJ -> new FlatIntelliJLaf(); + }; + UIManager.setLookAndFeel(laf); + } + + /** + * Falls back to system Look and Feel if FlatLaf fails. + */ + private static void tryFallbackLookAndFeel() { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + initialized = true; + } catch (Exception e) { + LOG.error("Failed to set system L&F, using default", e); + } + } + + /** + * Changes the theme at runtime and updates all windows. + * + * @param theme the new theme to apply + */ + public static void setTheme(Theme theme) { + if (theme == currentTheme) { + return; + } + + try { + applyThemeLookAndFeel(theme); + currentTheme = theme; + + // Update all existing windows + updateAllWindows(); + + LOG.info("Switched to {} theme", theme.getDisplayName()); + } catch (Exception e) { + LOG.error("Failed to switch to {} theme", theme.getDisplayName(), e); + } + } + + /** + * Updates the Look and Feel of all existing windows. + */ + private static void updateAllWindows() { + for (Window window : Window.getWindows()) { + SwingUtilities.updateComponentTreeUI(window); + window.pack(); + } + } + + /** + * Gets the current theme. + */ + public static Theme getCurrentTheme() { + return currentTheme; + } + + /** + * Gets all available themes. + */ + public static List<Theme> getAvailableThemes() { + return List.of(Theme.values()); + } + + /** + * Checks if a dark theme is currently active. + */ + public static boolean isDarkTheme() { + return currentTheme == Theme.DARK || currentTheme == Theme.DARCULA; + } + + /** + * Gets the current base font size. + */ + public static int getBaseFontSize() { + return baseFontSize; + } + + /** + * Sets the base font size and updates all windows. + */ + public static void setBaseFontSize(int size) { + int newSize = Math.max(8, Math.min(24, size)); + if (newSize == baseFontSize) { + return; + } + + baseFontSize = newSize; + + // Update font defaults + UIManager.put("defaultFont", new Font(Font.SANS_SERIF, Font.PLAIN, baseFontSize)); + + // Reapply theme to update fonts + try { + applyThemeLookAndFeel(currentTheme); + updateAllWindows(); + LOG.info("Updated base font size to {}", baseFontSize); + } catch (Exception e) { + LOG.error("Failed to update font size", e); + } + } + + /** + * Checks if ThemeManager has been initialized. + */ + public static boolean isInitialized() { + return initialized; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/HistoryInteractionListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/HistoryInteractionListener.java new file mode 100644 index 0000000..684ad6e --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/HistoryInteractionListener.java @@ -0,0 +1,256 @@ +/* + * 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.listener; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.music.HistorySlashCmd; +import com.jagrosh.jmusicbot.listener.interaction.ComponentIdParsers; +import com.jagrosh.jmusicbot.listener.interaction.InteractionGuards; +import com.jagrosh.jmusicbot.listener.interaction.OutputAdapters; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.modals.Modal; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Optional; + +/** + * Handles history embed button and modal interactions (history_*, history_save_*). + */ +public class HistoryInteractionListener extends ListenerAdapter { + + private final Bot bot; + + public HistoryInteractionListener(Bot bot) { + this.bot = bot; + } + + @Override + public void onButtonInteraction(ButtonInteractionEvent event) { + if (!event.getComponentId().startsWith("history_")) { + return; + } + handleHistoryButton(event); + } + + @Override + public void onModalInteraction(@NotNull ModalInteractionEvent event) { + if (!event.getModalId().startsWith("history_save_")) { + return; + } + handleHistorySaveModal(event); + } + + private void handleHistoryButton(ButtonInteractionEvent event) { + if (!InteractionGuards.requireGuildAndMember(event)) { + return; + } + + Optional<ComponentIdParsers.PaginatedButtonId> parsed = ComponentIdParsers.parseHistoryButtonId(event.getComponentId()); + if (parsed.isEmpty()) { + event.reply("Invalid button state.").setEphemeral(true).queue(); + return; + } + ComponentIdParsers.PaginatedButtonId id = parsed.get(); + + if (event.getUser().getIdLong() != id.userId()) { + event.reply("Only the user who ran the command can use these buttons!").setEphemeral(true).queue(); + return; + } + + int page = id.page(); + int selectedTrack = id.selectedTrack(); + long userId = id.userId(); + String action = id.action(); + + MusicService musicService = bot.getMusicService(); + MusicService.HistoryInfo historyInfo = musicService.getHistoryInfo(event.getGuild(), event.getJDA()); + + if (historyInfo == null) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + return; + } + if (historyInfo.isDisabled()) { + event.editMessage(MusicService.HISTORY_DISABLED_MESSAGE).setEmbeds().setComponents().queue(); + return; + } + if (historyInfo.isEmpty()) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + return; + } + + int totalPages = HistorySlashCmd.getTotalPages(historyInfo.tracks.length); + page = Math.max(1, Math.min(page, totalPages)); + + if (action.startsWith("select")) { + int newSelectedTrack; + try { + newSelectedTrack = Integer.parseInt(action.substring(6)); + } catch (NumberFormatException e) { + event.reply("Invalid track selection.").setEphemeral(true).queue(); + return; + } + int tracksOnPage = HistorySlashCmd.getTracksOnPage(page, historyInfo.tracks.length); + int firstOnPage = (page - 1) * HistorySlashCmd.TRACKS_PER_PAGE + 1; + int lastOnPage = firstOnPage + tracksOnPage - 1; + if (newSelectedTrack < firstOnPage || newSelectedTrack > lastOnPage) { + event.reply("That track isn't on this page!").setEphemeral(true).queue(); + return; + } + if (newSelectedTrack == selectedTrack) { + newSelectedTrack = 0; + } + if (newSelectedTrack > historyInfo.tracks.length) { + event.reply("That track doesn't exist!").setEphemeral(true).queue(); + return; + } + updateHistoryEmbed(event, historyInfo, page, totalPages, newSelectedTrack, userId); + } else if (action.equals("prev")) { + int newPage = Math.max(1, page - 1); + updateHistoryEmbed(event, historyInfo, newPage, totalPages, 0, userId); + } else if (action.equals("next")) { + int newPage = Math.min(totalPages, page + 1); + updateHistoryEmbed(event, historyInfo, newPage, totalPages, 0, userId); + } else if (action.equals("queue")) { + if (selectedTrack <= 0 || selectedTrack > historyInfo.tracks.length) { + event.reply("No track selected!").setEphemeral(true).queue(); + return; + } + if (!InteractionGuards.ensureBotInUserVoiceChannel(event, bot)) { + return; + } + MusicService.OutputAdapter adapter = OutputAdapters.forHistoryReply(event); + TextChannel channel = event.getChannel().asTextChannel(); + musicService.queueFromHistory(event.getGuild(), event.getMember(), selectedTrack, channel, adapter); + MusicService.HistoryInfo newInfo = musicService.getHistoryInfo(event.getGuild(), event.getJDA()); + if (newInfo == null) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + } else if (newInfo.isDisabled()) { + event.editMessage(MusicService.HISTORY_DISABLED_MESSAGE).setEmbeds().setComponents().queue(); + } else if (newInfo.isEmpty()) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + } else { + int newTotalPages = HistorySlashCmd.getTotalPages(newInfo.tracks.length); + int safePage = Math.min(page, newTotalPages); + updateHistoryEmbed(event, newInfo, safePage, newTotalPages, 0, userId); + } + } else if (action.equals("playnow")) { + if (selectedTrack <= 0 || selectedTrack > historyInfo.tracks.length) { + event.reply("No track selected!").setEphemeral(true).queue(); + return; + } + if (!InteractionGuards.ensureBotInUserVoiceChannel(event, bot)) { + return; + } + MusicService.OutputAdapter adapter = OutputAdapters.forHistoryReply(event); + TextChannel channel = event.getChannel().asTextChannel(); + musicService.playFromHistoryNow(event.getGuild(), event.getMember(), selectedTrack, channel, adapter); + MusicService.HistoryInfo newInfo = musicService.getHistoryInfo(event.getGuild(), event.getJDA()); + if (newInfo == null) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + } else if (newInfo.isDisabled()) { + event.editMessage(MusicService.HISTORY_DISABLED_MESSAGE).setEmbeds().setComponents().queue(); + } else if (newInfo.isEmpty()) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + } else { + int newTotalPages = HistorySlashCmd.getTotalPages(newInfo.tracks.length); + updateHistoryEmbed(event, newInfo, 1, newTotalPages, 0, userId); + } + } else if (action.equals("queueall")) { + if (!InteractionGuards.ensureBotInUserVoiceChannel(event, bot)) { + return; + } + MusicService.OutputAdapter adapter = OutputAdapters.forHistoryReply(event); + TextChannel channel = event.getChannel().asTextChannel(); + musicService.queueAllFromHistory(event.getGuild(), event.getMember(), channel, adapter); + MusicService.HistoryInfo newInfo = musicService.getHistoryInfo(event.getGuild(), event.getJDA()); + if (newInfo == null) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + } else if (newInfo.isDisabled()) { + event.editMessage(MusicService.HISTORY_DISABLED_MESSAGE).setEmbeds().setComponents().queue(); + } else if (newInfo.isEmpty()) { + event.editMessage("Playback history is now empty!").setEmbeds().setComponents().queue(); + } else { + int newTotalPages = HistorySlashCmd.getTotalPages(newInfo.tracks.length); + int safePage = Math.min(page, newTotalPages); + updateHistoryEmbed(event, newInfo, safePage, newTotalPages, 0, userId); + } + } else if (action.equals("save")) { + TextInput input = TextInput.create("playlist_name", TextInputStyle.SHORT) + .setPlaceholder("e.g. my-history") + .setMinLength(1) + .setMaxLength(100) + .setRequired(true) + .build(); + Modal modal = Modal.create("history_save_" + userId, "Save history as playlist") + .addComponents(Label.of("Playlist name", input)) + .build(); + event.replyModal(modal).queue(); + } + } + + private void handleHistorySaveModal(ModalInteractionEvent event) { + if (!InteractionGuards.requireGuildAndMember(event)) { + return; + } + + Optional<Long> userIdOpt = ComponentIdParsers.parseHistorySaveModalUserId(event.getModalId()); + if (userIdOpt.isEmpty()) { + event.reply("Invalid modal state.").setEphemeral(true).queue(); + return; + } + long userId = userIdOpt.get(); + + if (event.getUser().getIdLong() != userId) { + event.reply("Only the user who opened the save dialog can submit it!").setEphemeral(true).queue(); + return; + } + + String playlistName = event.getValues().stream() + .filter(m -> "playlist_name".equals(m.getCustomId())) + .findFirst() + .map(net.dv8tion.jda.api.interactions.modals.ModalMapping::getAsString) + .orElse("") + .trim(); + + if (playlistName.isEmpty()) { + event.reply("Please enter a playlist name!").setEphemeral(true).queue(); + return; + } + + MusicService.OutputAdapter adapter = OutputAdapters.forHistoryModal(event); + + bot.getMusicService().saveHistoryAsPlaylist(event.getGuild(), event.getMember(), playlistName, adapter); + } + + private void updateHistoryEmbed(ButtonInteractionEvent event, MusicService.HistoryInfo historyInfo, + int page, int totalPages, int selectedTrack, long userId) { + int tracksOnPage = HistorySlashCmd.getTracksOnPage(page, historyInfo.tracks.length); + MessageEmbed embed = HistorySlashCmd.buildHistoryEmbed(historyInfo, page, totalPages, selectedTrack, + event.getMember().getColor()); + List<ActionRow> components = HistorySlashCmd.buildHistoryComponents(page, totalPages, tracksOnPage, selectedTrack, userId); + event.editMessageEmbeds(embed).setComponents(components).queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/NowPlayingCleanupListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/NowPlayingCleanupListener.java new file mode 100644 index 0000000..c1ce92f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/NowPlayingCleanupListener.java @@ -0,0 +1,40 @@ +/* + * 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.listener; + +import com.jagrosh.jmusicbot.Bot; +import net.dv8tion.jda.api.events.message.MessageDeleteEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +/** + * Handles message deletion for now-playing embed cleanup. + */ +public class NowPlayingCleanupListener extends ListenerAdapter { + + private final Bot bot; + + public NowPlayingCleanupListener(Bot bot) { + this.bot = bot; + } + + @Override + public void onMessageDelete(@NotNull MessageDeleteEvent event) { + if (event.isFromGuild()) { + bot.getNowplayingHandler().onMessageDelete(event.getGuild(), event.getMessageIdLong()); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/PlaybackControlsListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/PlaybackControlsListener.java new file mode 100644 index 0000000..0ff77fc --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/PlaybackControlsListener.java @@ -0,0 +1,140 @@ +/* + * 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.listener; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.listener.interaction.ComponentIdParsers; +import com.jagrosh.jmusicbot.listener.interaction.OutputAdapters; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; + +/** + * Handles playback control button interactions (stop, pause, skip, previous, shuffle, repeat, favorite, volume). + */ +public class PlaybackControlsListener extends ListenerAdapter { + + private final Bot bot; + + public PlaybackControlsListener(Bot bot) { + this.bot = bot; + } + + @Override + public void onButtonInteraction(ButtonInteractionEvent event) { + var parsedId = ComponentIdParsers.parseNowPlayingButtonId(event.getComponentId()); + if (parsedId.isEmpty()) { + return; + } + + if (event.getGuild() == null || event.getMember() == null) { + return; + } + + AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); + if (handler == null) { + event.reply("There is no music playing!").setEphemeral(true).queue(); + return; + } + + if (!event.getMember().getVoiceState().inAudioChannel() + || !event.getMember().getVoiceState().getChannel().equals(event.getGuild().getSelfMember().getVoiceState().getChannel())) { + event.reply("You must be in the same voice channel to use this!").setEphemeral(true).queue(); + return; + } + + MusicService musicService = bot.getMusicService(); + + switch (parsedId.get().action()) { + case "previous": + musicService.previous(event.getGuild(), event.getMember(), + suppressSuccess(OutputAdapters.forPlaybackButton(event))); + break; + case "shuffle": + musicService.shuffle(event.getGuild(), event.getMember(), 0, OutputAdapters.forPlaybackButton(event)); + break; + case "repeat": + musicService.cycleRepeatMode(event.getGuild(), event.getMember(), OutputAdapters.forPlaybackButton(event)); + break; + case "favorite": + musicService.addCurrentTrackToFavorites(event.getGuild(), event.getMember(), OutputAdapters.forFavoritePlaybackButton(event)); + break; + case "voldown": + musicService.adjustVolume(event.getGuild(), event.getMember(), -10, OutputAdapters.forPlaybackButton(event)); + break; + case "volup": + musicService.adjustVolume(event.getGuild(), event.getMember(), 10, OutputAdapters.forPlaybackButton(event)); + break; + case "stop": + musicService.stop(event.getGuild(), event.getMember(), OutputAdapters.forPlaybackButton(event)); + break; + case "pause": + musicService.pause(event.getGuild(), event.getMember(), OutputAdapters.forPlaybackButton(event)); + break; + case "skip": + musicService.skip(event.getGuild(), event.getMember(), + suppressSuccess(OutputAdapters.forPlaybackButton(event))); + break; + default: + break; + } + } + + private static MusicService.OutputAdapter suppressSuccess(MusicService.OutputAdapter delegate) { + return new MusicService.OutputAdapter() { + @Override + public void replySuccess(String content) { + // Intentionally silent for targeted now-playing actions. + } + + @Override + public void replyError(String content) { + delegate.replyError(content); + } + + @Override + public void replyWarning(String content) { + delegate.replyWarning(content); + } + + @Override + public void editMessage(String content) { + delegate.editMessage(content); + } + + @Override + public void editMessage(String content, java.util.function.Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + delegate.editMessage(content, onSuccess); + } + + @Override + public void editNowPlaying(AudioHandler handler) { + delegate.editNowPlaying(handler); + } + + @Override + public void editNoMusic(AudioHandler handler) { + delegate.editNoMusic(handler); + } + + @Override + public void onShowHelp() { + delegate.onShowHelp(); + } + }; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/PlaylistsInteractionListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/PlaylistsInteractionListener.java new file mode 100644 index 0000000..4e9747c --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/PlaylistsInteractionListener.java @@ -0,0 +1,761 @@ +/* + * 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.listener; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.music.PlaylistsSlashCmd; +import com.jagrosh.jmusicbot.listener.interaction.ComponentIdParsers; +import com.jagrosh.jmusicbot.listener.interaction.InteractionGuards; +import com.jagrosh.jmusicbot.listener.interaction.OutputAdapters; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Handles playlists embed button interactions (playlists_*). + */ +public class PlaylistsInteractionListener extends ListenerAdapter +{ + private final Bot bot; + + public PlaylistsInteractionListener(Bot bot) + { + this.bot = bot; + } + + @Override + public void onButtonInteraction(ButtonInteractionEvent event) + { + String componentId = event.getComponentId(); + if (componentId.startsWith("playlists_")) + { + handlePlaylistsButton(event); + return; + } + if (componentId.startsWith("playlistdetails_")) + { + handlePlaylistDetailsButton(event); + return; + } + } + + @Override + public void onStringSelectInteraction(@NotNull StringSelectInteractionEvent event) + { + if (!event.getComponentId().startsWith("playlistdetails_move_select_")) + { + return; + } + handlePlaylistDetailsMoveSelect(event); + } + + private void handlePlaylistDetailsButton(ButtonInteractionEvent event) + { + if (!InteractionGuards.requireGuildAndMember(event)) + { + return; + } + + Optional<ComponentIdParsers.PlaylistDetailsBackId> parsedBack = + ComponentIdParsers.parsePlaylistDetailsBackId(event.getComponentId()); + if (parsedBack.isPresent()) + { + ComponentIdParsers.PlaylistDetailsBackId backId = parsedBack.get(); + if (event.getUser().getIdLong() != backId.userId()) + { + event.reply("Only the user who ran the command can use these buttons!").setEphemeral(true).queue(); + return; + } + MusicService.PlaylistNamesInfo namesInfo = loadPlaylistNames(); + if (!namesInfo.hasError() + && backId.listSelectedIndex() > 0 + && backId.listSelectedIndex() <= namesInfo.names.size()) + { + String playlistName = namesInfo.names.get(backId.listSelectedIndex() - 1); + MusicService.PlaylistDraftContext draftContext = buildDraftContext(event, backId.userId(), playlistName); + if (bot.getMusicService().isPlaylistDraftDirty(draftContext)) + { + bot.getMusicService().discardPlaylistDraft(draftContext); + } + } + renderPlaylistListView(event, backId.listPage(), backId.listSelectedIndex(), backId.userId()); + return; + } + + Optional<ComponentIdParsers.PlaylistDetailsButtonId> parsed = + ComponentIdParsers.parsePlaylistDetailsButtonId(event.getComponentId()); + if (parsed.isEmpty()) + { + event.reply("Invalid button state.").setEphemeral(true).queue(); + return; + } + ComponentIdParsers.PlaylistDetailsButtonId id = parsed.get(); + if (event.getUser().getIdLong() != id.userId()) + { + event.reply("Only the user who ran the command can use these buttons!").setEphemeral(true).queue(); + return; + } + + MusicService.PlaylistNamesInfo playlistNamesInfo = loadPlaylistNames(); + if (playlistNamesInfo.hasError()) + { + event.reply(playlistNamesInfo.errorMessage).setEphemeral(true).queue(); + return; + } + List<String> playlists = playlistNamesInfo.names; + if (id.playlistIndex() <= 0 || id.playlistIndex() > playlists.size()) + { + event.reply("Playlist no longer exists. Click Back and Refresh.").setEphemeral(true).queue(); + return; + } + + String playlistName = playlists.get(id.playlistIndex() - 1); + MusicService.PlaylistDetailsInfo detailsInfo = bot.getMusicService().getPlaylistDetails(playlistName); + if (detailsInfo == null) + { + event.reply("Playlist no longer exists. Click Back and Refresh.").setEphemeral(true).queue(); + return; + } + + MusicService.PlaylistDraftContext draftContext = buildDraftContext(event, id.userId(), playlistName); + int totalItems = bot.getMusicService().getPlaylistTrackCount(draftContext); + if (totalItems < 0) + { + event.reply("Playlist no longer exists. Click Back and Refresh.").setEphemeral(true).queue(); + return; + } + boolean canEditPlaylist = bot.getMusicService().canEditPlaylistEntries(event.getGuild(), event.getMember()); + int totalPages = Math.max(1, PlaylistsSlashCmd.getPlaylistTrackTotalPages(totalItems)); + int page = Math.max(1, Math.min(id.detailsPage(), totalPages)); + int selectedTrack = id.selectedTrack(); + String action = id.action(); + + if (action.startsWith("select")) + { + int newSelectedTrack; + try + { + newSelectedTrack = Integer.parseInt(action.substring(6)); + } + catch (NumberFormatException e) + { + event.reply("Invalid track selection.").setEphemeral(true).queue(); + return; + } + int tracksOnPage = PlaylistsSlashCmd.getPlaylistTracksOnPage(page, totalItems); + int firstOnPage = (page - 1) * PlaylistsSlashCmd.PLAYLIST_TRACKS_PER_PAGE + 1; + int lastOnPage = firstOnPage + tracksOnPage - 1; + if (newSelectedTrack < firstOnPage || newSelectedTrack > lastOnPage) + { + event.reply("That track isn't on this page!").setEphemeral(true).queue(); + return; + } + if (newSelectedTrack == selectedTrack) + { + newSelectedTrack = 0; + } + if (newSelectedTrack > totalItems) + { + event.reply("That track doesn't exist!").setEphemeral(true).queue(); + return; + } + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, newSelectedTrack, id.userId(), playlistName, draftContext); + return; + } + + if (action.equals("prev")) + { + int newPage = Math.max(1, page - 1); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), newPage, 0, id.userId(), playlistName, draftContext); + return; + } + if (action.equals("next")) + { + int newPage = Math.min(totalPages, page + 1); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), newPage, 0, id.userId(), playlistName, draftContext); + return; + } + + MusicService musicService = bot.getMusicService(); + if (action.equals("queueall") || action.equals("playall")) + { + if (!InteractionGuards.ensureBotInUserVoiceChannel(event, bot)) + { + return; + } + + TextChannel channel = event.getChannel().asTextChannel(); + MusicService.OutputAdapter output = OutputAdapters.forPlaylistsReply(event); + if (action.equals("queueall")) + { + musicService.queuePlaylist(event.getGuild(), event.getMember(), playlistName, channel, output); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, 0, id.userId(), playlistName, draftContext); + } + else + { + musicService.playPlaylistNow(event.getGuild(), event.getMember(), playlistName, channel, output); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), 1, 0, id.userId(), playlistName, draftContext); + } + return; + } + + if (action.equals("save") || action.equals("discard")) + { + if (!canEditPlaylist) + { + event.reply("You need to be a DJ or the bot owner to edit playlists!").setEphemeral(true).queue(); + return; + } + if (action.equals("save")) + { + MusicService.PlaylistDraftMutationResult saveResult = musicService.savePlaylistDraft(draftContext); + if (!saveResult.success) + { + event.reply(saveResult.errorMessage).setEphemeral(true).queue(); + return; + } + } + else + { + musicService.discardPlaylistDraft(draftContext); + } + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, 0, id.userId(), playlistName, draftContext); + return; + } + + if (action.equals("unselect")) + { + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, 0, id.userId(), playlistName, draftContext); + return; + } + + if (selectedTrack <= 0 || selectedTrack > totalItems) + { + event.reply("No track selected!").setEphemeral(true).queue(); + return; + } + + if (action.equals("remove")) + { + if (!canEditPlaylist) + { + event.reply("You need to be a DJ or the bot owner to edit playlists!").setEphemeral(true).queue(); + return; + } + MusicService.PlaylistDraftMutationResult removeResult = musicService.removePlaylistDraftItem(draftContext, selectedTrack); + if (!removeResult.success) + { + event.reply(removeResult.errorMessage).setEphemeral(true).queue(); + return; + } + int newTotalPages = Math.max(1, PlaylistsSlashCmd.getPlaylistTrackTotalPages(removeResult.totalItems)); + int safePage = Math.min(page, newTotalPages); + int newSelectedTrack = removeResult.totalItems == 0 ? 0 : Math.min(selectedTrack, removeResult.totalItems); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), safePage, newSelectedTrack, id.userId(), playlistName, draftContext); + return; + } + + if (action.equals("move")) + { + if (!canEditPlaylist) + { + event.reply("You need to be a DJ or the bot owner to edit playlists!").setEphemeral(true).queue(); + return; + } + renderPlaylistMoveMenu(event, draftContext, id.playlistIndex(), id.listPage(), page, selectedTrack, id.userId()); + return; + } + if (action.equals("cancelmove")) + { + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, selectedTrack, id.userId(), playlistName, draftContext); + return; + } + + if (action.equals("movetop") || action.equals("moveup") || action.equals("movedown") || action.equals("movebottom")) + { + if (!canEditPlaylist) + { + event.reply("You need to be a DJ or the bot owner to edit playlists!").setEphemeral(true).queue(); + return; + } + int target = switch (action) + { + case "movetop" -> 1; + case "moveup" -> Math.max(1, selectedTrack - 1); + case "movedown" -> Math.min(totalItems, selectedTrack + 1); + case "movebottom" -> totalItems; + default -> selectedTrack; + }; + MusicService.PlaylistDraftMutationResult moveResult = + musicService.movePlaylistDraftItem(draftContext, selectedTrack, target); + if (!moveResult.success) + { + event.reply(moveResult.errorMessage).setEphemeral(true).queue(); + return; + } + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, target, id.userId(), playlistName, draftContext); + return; + } + + if (!InteractionGuards.ensureBotInUserVoiceChannel(event, bot)) + { + return; + } + + TextChannel channel = event.getChannel().asTextChannel(); + MusicService.OutputAdapter output = OutputAdapters.forPlaylistsReply(event); + String selectedTrackUrl = musicService.getPlaylistTrackUrlAtPosition(draftContext, selectedTrack); + if (selectedTrackUrl == null || selectedTrackUrl.isBlank()) + { + event.reply("That playlist entry could not be loaded.").setEphemeral(true).queue(); + return; + } + + if (action.equals("queue")) + { + musicService.play(event.getGuild(), event.getMember(), selectedTrackUrl, channel, output); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, selectedTrack, id.userId(), playlistName, draftContext); + return; + } + if (action.equals("playnext")) + { + musicService.playNext(event.getGuild(), event.getMember(), selectedTrackUrl, channel, output); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, selectedTrack, id.userId(), playlistName, draftContext); + return; + } + if (action.equals("playnow")) + { + musicService.playNowFromUrl(event.getGuild(), event.getMember(), selectedTrackUrl, channel, output); + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), page, selectedTrack, id.userId(), playlistName, draftContext); + } + } + + private void handlePlaylistDetailsMoveSelect(StringSelectInteractionEvent event) + { + if (!InteractionGuards.requireGuildAndMember(event)) + { + return; + } + + Optional<ComponentIdParsers.PlaylistDetailsMoveSelectId> parsed = + ComponentIdParsers.parsePlaylistDetailsMoveSelectId(event.getComponentId()); + if (parsed.isEmpty()) + { + event.reply("Invalid selection state.").setEphemeral(true).queue(); + return; + } + ComponentIdParsers.PlaylistDetailsMoveSelectId id = parsed.get(); + if (event.getUser().getIdLong() != id.userId()) + { + event.reply("Only the user who ran the command can use this!").setEphemeral(true).queue(); + return; + } + + MusicService musicService = bot.getMusicService(); + if (!musicService.canEditPlaylistEntries(event.getGuild(), event.getMember())) + { + event.reply("You need to be a DJ or the bot owner to edit playlists!").setEphemeral(true).queue(); + return; + } + + MusicService.PlaylistNamesInfo namesInfo = loadPlaylistNames(); + if (namesInfo.hasError()) + { + event.reply(namesInfo.errorMessage).setEphemeral(true).queue(); + return; + } + if (id.playlistIndex() <= 0 || id.playlistIndex() > namesInfo.names.size()) + { + event.reply("Playlist no longer exists. Click Back and Refresh.").setEphemeral(true).queue(); + return; + } + String playlistName = namesInfo.names.get(id.playlistIndex() - 1); + + int toPosition; + try + { + toPosition = Integer.parseInt(event.getValues().get(0)); + } + catch (NumberFormatException e) + { + event.reply("Invalid position selected.").setEphemeral(true).queue(); + return; + } + + MusicService.PlaylistDraftContext draftContext = buildDraftContext(event, id.userId(), playlistName); + MusicService.PlaylistDraftMutationResult moveResult = + musicService.movePlaylistDraftItem(draftContext, id.fromTrack(), toPosition); + if (!moveResult.success) + { + event.reply(moveResult.errorMessage).setEphemeral(true).queue(); + return; + } + + renderPlaylistDetailsView(event, id.playlistIndex(), id.listPage(), id.detailsPage(), toPosition, id.userId(), playlistName, draftContext); + } + + private void handlePlaylistsButton(ButtonInteractionEvent event) + { + if (!InteractionGuards.requireGuildAndMember(event)) + { + return; + } + + Optional<ComponentIdParsers.PaginatedButtonId> parsed = ComponentIdParsers.parsePlaylistsButtonId(event.getComponentId()); + if (parsed.isEmpty()) + { + event.reply("Invalid button state.").setEphemeral(true).queue(); + return; + } + ComponentIdParsers.PaginatedButtonId id = parsed.get(); + if (event.getUser().getIdLong() != id.userId()) + { + event.reply("Only the user who ran the command can use these buttons!").setEphemeral(true).queue(); + return; + } + + MusicService.PlaylistNamesInfo playlistNamesInfo = loadPlaylistNames(); + if (playlistNamesInfo.hasError()) + { + event.reply(playlistNamesInfo.errorMessage).setEphemeral(true).queue(); + return; + } + List<String> playlists = playlistNamesInfo.names; + if (playlists.isEmpty()) + { + event.editMessage("There are no playlists in the Playlists folder!").setEmbeds().setComponents().queue(); + return; + } + + int requestedPage = id.page(); + int selectedIndex = id.selectedTrack(); + long userId = id.userId(); + String action = id.action(); + + int totalPages = PlaylistsSlashCmd.getTotalPages(playlists.size()); + int page = Math.max(1, Math.min(requestedPage, totalPages)); + + if (action.startsWith("select")) + { + int newSelectedIndex; + try + { + newSelectedIndex = Integer.parseInt(action.substring(6)); + } + catch (NumberFormatException e) + { + event.reply("Invalid playlist selection.").setEphemeral(true).queue(); + return; + } + int playlistsOnPage = PlaylistsSlashCmd.getPlaylistsOnPage(page, playlists.size()); + int firstOnPage = (page - 1) * PlaylistsSlashCmd.PLAYLISTS_PER_PAGE + 1; + int lastOnPage = firstOnPage + playlistsOnPage - 1; + if (newSelectedIndex < firstOnPage || newSelectedIndex > lastOnPage) + { + event.reply("That playlist isn't on this page!").setEphemeral(true).queue(); + return; + } + if (newSelectedIndex == selectedIndex) + { + newSelectedIndex = 0; + } + if (newSelectedIndex > playlists.size()) + { + event.reply("That playlist doesn't exist!").setEphemeral(true).queue(); + return; + } + updatePlaylistsEmbed(event, playlists, page, totalPages, newSelectedIndex, userId); + return; + } + + if (action.equals("prev")) + { + int newPage = Math.max(1, page - 1); + updatePlaylistsEmbed(event, playlists, newPage, totalPages, 0, userId); + return; + } + if (action.equals("next")) + { + int newPage = Math.min(totalPages, page + 1); + updatePlaylistsEmbed(event, playlists, newPage, totalPages, 0, userId); + return; + } + if (action.equals("refresh")) + { + int safePage = Math.min(page, totalPages); + updatePlaylistsEmbed(event, playlists, safePage, totalPages, 0, userId); + return; + } + + if (selectedIndex <= 0 || selectedIndex > playlists.size()) + { + event.reply("No playlist selected!").setEphemeral(true).queue(); + return; + } + + String selectedPlaylist = playlists.get(selectedIndex - 1); + MusicService musicService = bot.getMusicService(); + if (action.equals("details")) + { + if (musicService.getPlaylistDetails(selectedPlaylist) == null) + { + event.reply("Playlist no longer exists. Click Refresh.").setEphemeral(true).queue(); + return; + } + MusicService.PlaylistDraftContext draftContext = buildDraftContext(event, userId, selectedPlaylist); + event.deferEdit().queue(hook -> renderPlaylistDetailsView( + hook, + event, + selectedIndex, + page, + 1, + 0, + userId, + selectedPlaylist, + draftContext + )); + return; + } + + if (!InteractionGuards.ensureBotInUserVoiceChannel(event, bot)) + { + return; + } + + TextChannel channel = event.getChannel().asTextChannel(); + MusicService.OutputAdapter output = OutputAdapters.forPlaylistsReply(event); + if (action.equals("queue")) + { + musicService.queuePlaylist(event.getGuild(), event.getMember(), selectedPlaylist, channel, output); + updatePlaylistsEmbed(event, playlists, page, totalPages, 0, userId); + return; + } + if (action.equals("playnow")) + { + musicService.playPlaylistNow(event.getGuild(), event.getMember(), selectedPlaylist, channel, output); + updatePlaylistsEmbed(event, playlists, page, totalPages, 0, userId); + } + } + + private MusicService.PlaylistNamesInfo loadPlaylistNames() + { + return bot.getMusicService().getAvailablePlaylistNames(); + } + + private void updatePlaylistsEmbed(ButtonInteractionEvent event, List<String> playlists, + int page, int totalPages, int selectedIndex, long userId) + { + int playlistsOnPage = PlaylistsSlashCmd.getPlaylistsOnPage(page, playlists.size()); + MessageEmbed embed = PlaylistsSlashCmd.buildPlaylistsEmbed(playlists, page, totalPages, selectedIndex, + event.getMember().getColor()); + List<ActionRow> components = PlaylistsSlashCmd.buildPlaylistsComponents(page, totalPages, playlistsOnPage, selectedIndex, userId); + event.editMessageEmbeds(embed).setComponents(components).queue(); + } + + private void renderPlaylistListView(ButtonInteractionEvent event, int listPage, int listSelectedIndex, long userId) + { + MusicService.PlaylistNamesInfo playlistNamesInfo = loadPlaylistNames(); + if (playlistNamesInfo.hasError()) + { + event.reply(playlistNamesInfo.errorMessage).setEphemeral(true).queue(); + return; + } + List<String> playlists = playlistNamesInfo.names; + if (playlists.isEmpty()) + { + event.editMessage("There are no playlists in the Playlists folder!").setEmbeds().setComponents().queue(); + return; + } + int totalPages = PlaylistsSlashCmd.getTotalPages(playlists.size()); + int safePage = Math.max(1, Math.min(listPage, totalPages)); + int safeSelected = listSelectedIndex > playlists.size() ? 0 : Math.max(0, listSelectedIndex); + + int playlistsOnPage = PlaylistsSlashCmd.getPlaylistsOnPage(safePage, playlists.size()); + int firstOnPage = (safePage - 1) * PlaylistsSlashCmd.PLAYLISTS_PER_PAGE + 1; + int lastOnPage = firstOnPage + playlistsOnPage - 1; + if (safeSelected < firstOnPage || safeSelected > lastOnPage) + { + safeSelected = 0; + } + updatePlaylistsEmbed(event, playlists, safePage, totalPages, safeSelected, userId); + } + + private void renderPlaylistDetailsView(ButtonInteractionEvent event, int playlistIndex, int listPage, + int detailsPage, int selectedTrack, long userId, String playlistName, + MusicService.PlaylistDraftContext draftContext) + { + renderPlaylistDetailsView(result -> + event.editMessageEmbeds(result.embed()).setComponents(result.components()).queue(), + message -> event.editMessage(message).setEmbeds().setComponents().queue(), + event.getGuild(), event.getMember(), playlistIndex, listPage, detailsPage, selectedTrack, userId, + playlistName, draftContext); + } + + private void renderPlaylistDetailsView(StringSelectInteractionEvent event, int playlistIndex, int listPage, + int detailsPage, int selectedTrack, long userId, String playlistName, + MusicService.PlaylistDraftContext draftContext) + { + renderPlaylistDetailsView(result -> + event.editMessageEmbeds(result.embed()).setComponents(result.components()).queue(), + message -> event.editMessage(message).setEmbeds().setComponents().queue(), + event.getGuild(), event.getMember(), playlistIndex, listPage, detailsPage, selectedTrack, userId, + playlistName, draftContext); + } + + private void renderPlaylistDetailsView(net.dv8tion.jda.api.interactions.InteractionHook hook, + ButtonInteractionEvent event, + int playlistIndex, int listPage, int detailsPage, + int selectedTrack, long userId, String playlistName, + MusicService.PlaylistDraftContext draftContext) + { + renderPlaylistDetailsView(result -> + hook.editOriginalEmbeds(result.embed()).setComponents(result.components()).queue(), + message -> hook.editOriginal(message).setEmbeds(java.util.Collections.emptyList()).setComponents(java.util.Collections.emptyList()).queue(), + event.getGuild(), event.getMember(), playlistIndex, listPage, detailsPage, selectedTrack, userId, + playlistName, draftContext); + } + + private void renderPlaylistDetailsView(java.util.function.Consumer<PlaylistDetailsRenderResult> renderTarget, + Consumer<String> onMissingPlaylist, + net.dv8tion.jda.api.entities.Guild guild, + net.dv8tion.jda.api.entities.Member member, + int playlistIndex, int listPage, int detailsPage, + int selectedTrack, long userId, String playlistName, + MusicService.PlaylistDraftContext draftContext) + { + MusicService musicService = bot.getMusicService(); + musicService.loadPlaylistPageWithTracks( + draftContext, detailsPage, PlaylistsSlashCmd.PLAYLIST_TRACKS_PER_PAGE, result -> + { + if (result == null) + { + onMissingPlaylist.accept("Playlist no longer exists. Click Back and Refresh."); + return; + } + int totalPages = Math.max(1, PlaylistsSlashCmd.getPlaylistTrackTotalPages(result.totalItems)); + int safePage = Math.max(1, Math.min(detailsPage, totalPages)); + int tracksOnPage = PlaylistsSlashCmd.getPlaylistTracksOnPage(safePage, result.totalItems); + int firstOnPage = (safePage - 1) * PlaylistsSlashCmd.PLAYLIST_TRACKS_PER_PAGE + 1; + int lastOnPage = firstOnPage + tracksOnPage - 1; + int safeSelected = (selectedTrack >= firstOnPage && selectedTrack <= lastOnPage) ? selectedTrack : 0; + + java.awt.Color color = member != null + ? member.getColor() + : null; + MessageEmbed embed = PlaylistsSlashCmd.buildPlaylistTracksEmbed( + result.playlistName, result.totalItems, safePage, totalPages, result.formattedLines, + safeSelected, result.dirty, color); + boolean canEdit = guild != null + && member != null + && musicService.canEditPlaylistEntries(guild, member); + List<ActionRow> components = PlaylistsSlashCmd.buildPlaylistDetailsComponents( + playlistIndex, listPage, safePage, totalPages, tracksOnPage, safeSelected, userId, + canEdit, result.dirty); + renderTarget.accept(new PlaylistDetailsRenderResult(embed, components)); + }); + } + + private void renderPlaylistMoveMenu(ButtonInteractionEvent event, MusicService.PlaylistDraftContext draftContext, + int playlistIndex, int listPage, int detailsPage, int selectedTrack, + long userId) + { + bot.getMusicService().loadPlaylistPageWithTracks( + draftContext, detailsPage, PlaylistsSlashCmd.PLAYLIST_TRACKS_PER_PAGE, result -> + { + if (result == null) + { + event.reply("Playlist no longer exists. Click Back and Refresh.").setEphemeral(true).queue(); + return; + } + int totalPages = Math.max(1, PlaylistsSlashCmd.getPlaylistTrackTotalPages(result.totalItems)); + int safePage = Math.max(1, Math.min(detailsPage, totalPages)); + int firstOnPage = (safePage - 1) * PlaylistsSlashCmd.PLAYLIST_TRACKS_PER_PAGE + 1; + int tracksOnPage = PlaylistsSlashCmd.getPlaylistTracksOnPage(safePage, result.totalItems); + int lastOnPage = firstOnPage + tracksOnPage - 1; + int safeSelected = (selectedTrack >= firstOnPage && selectedTrack <= lastOnPage) ? selectedTrack : 0; + + MessageEmbed embed = PlaylistsSlashCmd.buildPlaylistTracksEmbed( + result.playlistName, + result.totalItems, + safePage, + totalPages, + result.formattedLines, + safeSelected, + result.dirty, + event.getMember() != null ? event.getMember().getColor() : null + ); + + StringSelectMenu.Builder menuBuilder = StringSelectMenu + .create("playlistdetails_move_select_" + playlistIndex + "_" + listPage + "_" + + safeSelected + "_" + safePage + "_" + userId) + .setPlaceholder("Select new position") + .setMinValues(1) + .setMaxValues(1); + int maxOptions = Math.min(result.totalItems, 25); + for (int i = 1; i <= maxOptions; i++) + { + if (i != safeSelected) + { + menuBuilder.addOption("Position " + i, String.valueOf(i)); + } + } + String cancelMoveId = "playlistdetails_" + playlistIndex + "_" + listPage + "_cancelmove_" + + safePage + "_" + safeSelected + "_" + userId; + event.editMessageEmbeds(embed) + .setComponents( + ActionRow.of(menuBuilder.build()), + ActionRow.of(net.dv8tion.jda.api.components.buttons.Button.secondary(cancelMoveId, "Back")) + ) + .queue(); + }); + } + + private MusicService.PlaylistDraftContext buildDraftContext(ButtonInteractionEvent event, long userId, String playlistName) + { + return bot.getMusicService().buildPlaylistDraftContext( + event.getGuild().getIdLong(), + event.getChannel().getIdLong(), + event.getMessageIdLong(), + userId, + playlistName + ); + } + + private MusicService.PlaylistDraftContext buildDraftContext(StringSelectInteractionEvent event, long userId, String playlistName) + { + return bot.getMusicService().buildPlaylistDraftContext( + event.getGuild().getIdLong(), + event.getChannel().getIdLong(), + event.getMessageIdLong(), + userId, + playlistName + ); + } + + private record PlaylistDetailsRenderResult(MessageEmbed embed, List<ActionRow> components) + { + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/QueueInteractionListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/QueueInteractionListener.java new file mode 100644 index 0000000..ee28478 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/QueueInteractionListener.java @@ -0,0 +1,264 @@ +/* + * 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.listener; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.music.QueueSlashCmd; +import com.jagrosh.jmusicbot.listener.interaction.ComponentIdParsers; +import com.jagrosh.jmusicbot.listener.interaction.InteractionGuards; +import com.jagrosh.jmusicbot.listener.interaction.OutputAdapters; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Optional; + +/** + * Handles queue embed button and select-menu interactions (queue_*, queue_move_select_*). + */ +public class QueueInteractionListener extends ListenerAdapter { + + private final Bot bot; + + public QueueInteractionListener(Bot bot) { + this.bot = bot; + } + + @Override + public void onButtonInteraction(ButtonInteractionEvent event) { + if (!event.getComponentId().startsWith("queue_")) { + return; + } + handleQueueButton(event); + } + + @Override + public void onStringSelectInteraction(@NotNull StringSelectInteractionEvent event) { + if (!event.getComponentId().startsWith("queue_move_select_")) { + return; + } + handleQueueMoveSelect(event); + } + + private void handleQueueButton(ButtonInteractionEvent event) { + if (!InteractionGuards.requireGuildAndMember(event)) { + return; + } + + Optional<ComponentIdParsers.PaginatedButtonId> parsed = ComponentIdParsers.parseQueueButtonId(event.getComponentId()); + if (parsed.isEmpty()) { + event.reply("Invalid button state.").setEphemeral(true).queue(); + return; + } + ComponentIdParsers.PaginatedButtonId id = parsed.get(); + + if (event.getUser().getIdLong() != id.userId()) { + event.reply("Only the user who ran the command can use these buttons!").setEphemeral(true).queue(); + return; + } + + if (!InteractionGuards.requireSameVoiceChannel(event)) { + return; + } + + int page = id.page(); + int selectedTrack = id.selectedTrack(); + long userId = id.userId(); + String action = id.action(); + + MusicService musicService = bot.getMusicService(); + MusicService.QueueInfo queueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA()); + + if (queueInfo == null || queueInfo.isEmpty()) { + event.editMessage("The queue is now empty!").setEmbeds().setComponents().queue(); + return; + } + + int totalPages = QueueSlashCmd.getTotalPages(queueInfo.tracks.length); + page = Math.max(1, Math.min(page, totalPages)); + + if (action.startsWith("select")) { + int newSelectedTrack; + try { + newSelectedTrack = Integer.parseInt(action.substring(6)); + } catch (NumberFormatException e) { + event.reply("Invalid track selection.").setEphemeral(true).queue(); + return; + } + int tracksOnPage = QueueSlashCmd.getTracksOnPage(page, queueInfo.tracks.length); + int firstOnPage = (page - 1) * QueueSlashCmd.TRACKS_PER_PAGE + 1; + int lastOnPage = firstOnPage + tracksOnPage - 1; + if (newSelectedTrack < firstOnPage || newSelectedTrack > lastOnPage) { + event.reply("That track isn't on this page!").setEphemeral(true).queue(); + return; + } + if (newSelectedTrack == selectedTrack) { + newSelectedTrack = 0; + } + if (newSelectedTrack > queueInfo.tracks.length) { + event.reply("That track doesn't exist!").setEphemeral(true).queue(); + return; + } + updateQueueEmbed(event, queueInfo, page, totalPages, newSelectedTrack, userId); + } else if (action.equals("prev")) { + int newPage = Math.max(1, page - 1); + updateQueueEmbed(event, queueInfo, newPage, totalPages, 0, userId); + } else if (action.equals("next")) { + int newPage = Math.min(totalPages, page + 1); + updateQueueEmbed(event, queueInfo, newPage, totalPages, 0, userId); + } else if (action.equals("shuffle")) { + MusicService.OutputAdapter adapter = OutputAdapters.forQueueEmbed(event); + musicService.shuffle(event.getGuild(), event.getMember(), 0, adapter); + MusicService.QueueInfo newQueueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA()); + if (newQueueInfo != null && !newQueueInfo.isEmpty()) { + int newTotalPages = QueueSlashCmd.getTotalPages(newQueueInfo.tracks.length); + int safePage = Math.min(page, newTotalPages); + updateQueueEmbed(event, newQueueInfo, safePage, newTotalPages, 0, userId); + } + } else if (action.equals("remove")) { + if (selectedTrack <= 0 || selectedTrack > queueInfo.tracks.length) { + event.reply("No track selected!").setEphemeral(true).queue(); + return; + } + MusicService.OutputAdapter adapter = OutputAdapters.forQueueEmbed(event); + musicService.removeTrack(event.getGuild(), event.getMember(), selectedTrack, adapter); + MusicService.QueueInfo newQueueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA()); + if (newQueueInfo == null || newQueueInfo.isEmpty()) { + event.editMessage("The queue is now empty!").setEmbeds().setComponents().queue(); + } else { + int newTotalPages = QueueSlashCmd.getTotalPages(newQueueInfo.tracks.length); + int safePage = Math.min(page, newTotalPages); + updateQueueEmbed(event, newQueueInfo, safePage, newTotalPages, 0, userId); + } + } else if (action.equals("playnext")) { + if (selectedTrack <= 0 || selectedTrack > queueInfo.tracks.length) { + event.reply("No track selected!").setEphemeral(true).queue(); + return; + } + MusicService.OutputAdapter adapter = OutputAdapters.forQueueEmbed(event); + musicService.playNext(event.getGuild(), event.getMember(), selectedTrack, adapter); + MusicService.QueueInfo newQueueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA()); + if (newQueueInfo != null && !newQueueInfo.isEmpty()) { + int newTotalPages = QueueSlashCmd.getTotalPages(newQueueInfo.tracks.length); + updateQueueEmbed(event, newQueueInfo, 1, newTotalPages, 0, userId); + } + } else if (action.equals("move")) { + if (selectedTrack <= 0 || selectedTrack > queueInfo.tracks.length) { + event.reply("No track selected!").setEphemeral(true).queue(); + return; + } + StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("queue_move_select_" + selectedTrack + "_" + page + "_" + userId) + .setPlaceholder("Select new position") + .setMinValues(1) + .setMaxValues(1); + int maxOptions = Math.min(queueInfo.tracks.length, 25); + for (int i = 1; i <= maxOptions; i++) { + if (i != selectedTrack) { + menuBuilder.addOption("Position " + i, String.valueOf(i)); + } + } + MessageEmbed embed = QueueSlashCmd.buildQueueEmbed(queueInfo, page, totalPages, selectedTrack, + event.getMember().getColor()); + event.editMessageEmbeds(embed) + .setComponents(ActionRow.of(menuBuilder.build())) + .queue(); + } else if (action.equals("playnow")) { + if (selectedTrack <= 0 || selectedTrack > queueInfo.tracks.length) { + event.reply("No track selected!").setEphemeral(true).queue(); + return; + } + MusicService.OutputAdapter adapter = OutputAdapters.forQueueEmbed(event); + musicService.playNow(event.getGuild(), event.getMember(), selectedTrack, adapter); + MusicService.QueueInfo newQueueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA()); + if (newQueueInfo == null || newQueueInfo.isEmpty()) { + event.editMessage("The queue is now empty!").setEmbeds().setComponents().queue(); + } else { + int newTotalPages = QueueSlashCmd.getTotalPages(newQueueInfo.tracks.length); + updateQueueEmbed(event, newQueueInfo, 1, newTotalPages, 0, userId); + } + } + } + + private void handleQueueMoveSelect(StringSelectInteractionEvent event) { + if (!InteractionGuards.requireGuildAndMember(event)) { + return; + } + + Optional<ComponentIdParsers.QueueMoveSelectId> parsed = ComponentIdParsers.parseQueueMoveSelectId(event.getComponentId()); + if (parsed.isEmpty()) { + event.reply("Invalid selection state.").setEphemeral(true).queue(); + return; + } + ComponentIdParsers.QueueMoveSelectId id = parsed.get(); + + if (event.getUser().getIdLong() != id.userId()) { + event.reply("Only the user who ran the command can use this!").setEphemeral(true).queue(); + return; + } + + if (!InteractionGuards.requireSameVoiceChannel(event)) { + return; + } + + int fromPosition = id.fromPosition(); + int page = id.page(); + long userId = id.userId(); + + int toPosition; + try { + toPosition = Integer.parseInt(event.getValues().get(0)); + } catch (NumberFormatException e) { + event.reply("Invalid position selected.").setEphemeral(true).queue(); + return; + } + + MusicService musicService = bot.getMusicService(); + MusicService.OutputAdapter adapter = OutputAdapters.forQueueMoveSelect(event); + + musicService.moveTrack(event.getGuild(), event.getMember(), fromPosition, toPosition, adapter); + + MusicService.QueueInfo queueInfo = musicService.getQueueInfo(event.getGuild(), event.getJDA()); + if (queueInfo == null || queueInfo.isEmpty()) { + event.editMessage("The queue is now empty!").setEmbeds().setComponents().queue(); + return; + } + + int totalPages = QueueSlashCmd.getTotalPages(queueInfo.tracks.length); + int safePage = Math.min(page, totalPages); + int tracksOnPage = QueueSlashCmd.getTracksOnPage(safePage, queueInfo.tracks.length); + + MessageEmbed embed = QueueSlashCmd.buildQueueEmbed(queueInfo, safePage, totalPages, 0, + event.getMember().getColor()); + List<ActionRow> components = QueueSlashCmd.buildQueueComponents(safePage, totalPages, tracksOnPage, 0, userId); + + event.editMessageEmbeds(embed).setComponents(components).queue(); + } + + private void updateQueueEmbed(ButtonInteractionEvent event, MusicService.QueueInfo queueInfo, + int page, int totalPages, int selectedTrack, long userId) { + int tracksOnPage = QueueSlashCmd.getTracksOnPage(page, queueInfo.tracks.length); + MessageEmbed embed = QueueSlashCmd.buildQueueEmbed(queueInfo, page, totalPages, selectedTrack, + event.getMember().getColor()); + List<ActionRow> components = QueueSlashCmd.buildQueueComponents(page, totalPages, tracksOnPage, selectedTrack, userId); + event.editMessageEmbeds(embed).setComponents(components).queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/SettingsInteractionListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/SettingsInteractionListener.java new file mode 100644 index 0000000..d5cad2f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/SettingsInteractionListener.java @@ -0,0 +1,524 @@ +package com.jagrosh.jmusicbot.listener; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.commands.v2.admin.SettingsPanelRenderer; +import com.jagrosh.jmusicbot.listener.interaction.ComponentIdParsers; +import com.jagrosh.jmusicbot.listener.interaction.InteractionGuards; +import com.jagrosh.jmusicbot.settings.NowPlayingButtonsMode; +import com.jagrosh.jmusicbot.settings.NowPlayingLayoutMode; +import com.jagrosh.jmusicbot.settings.QueueType; +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; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.modals.Modal; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageEditBuilder; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Optional; + +public class SettingsInteractionListener extends ListenerAdapter +{ + private final Bot bot; + + public SettingsInteractionListener(Bot bot) + { + this.bot = bot; + } + + @Override + public void onButtonInteraction(@NotNull ButtonInteractionEvent event) + { + if (!event.getComponentId().startsWith("settings_")) + return; + handleSettingsButton(event); + } + + @Override + public void onModalInteraction(@NotNull ModalInteractionEvent event) + { + if (!event.getModalId().startsWith("settings_modal_")) + return; + handleSettingsModal(event); + } + + @Override + public void onEntitySelectInteraction(@NotNull EntitySelectInteractionEvent event) + { + if (!event.getComponentId().startsWith("settings_entity_")) + return; + handleSettingsEntitySelect(event); + } + + private void handleSettingsButton(ButtonInteractionEvent event) + { + if (!InteractionGuards.requireGuildAndMember(event)) + return; + + Optional<ComponentIdParsers.SettingsButtonId> parsed = ComponentIdParsers.parseSettingsButtonId(event.getComponentId()); + if (parsed.isEmpty()) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + ComponentIdParsers.SettingsButtonId id = parsed.get(); + if (event.getUser().getIdLong() != id.userId()) + { + event.reply("Only the user who opened this settings panel can use it.").setEphemeral(true).queue(); + return; + } + if (!hasSettingsPermission(event.getMember(), event.getUser().getIdLong())) + { + event.reply("You need the Manage Server permission to change settings.").setEphemeral(true).queue(); + return; + } + + Settings settings = bot.getSettingsManager().getSettings(event.getGuild()); + + switch (id.action()) + { + case SettingsPanelRenderer.ACTION_ENUM -> { + if (!applyEnumUpdate(id.key(), id.value(), settings, event.getGuild())) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + refreshPanel(event, settings); + } + case SettingsPanelRenderer.ACTION_TOGGLE -> { + if (!applyToggleCycle(id.key(), settings, event.getGuild())) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + refreshPanel(event, settings); + } + case SettingsPanelRenderer.ACTION_OPEN -> { + if ("settc".equals(id.key()) || "setvc".equals(id.key()) || "setdj".equals(id.key())) + { + long originalPanelMessageId = event.getMessageIdLong(); + var menu = SettingsPanelRenderer.buildEntitySelectMenu(id.key(), id.userId(), originalPanelMessageId); + var clearButton = SettingsPanelRenderer.buildEntityClearButton(id.key(), id.userId(), originalPanelMessageId); + if (menu == null || clearButton == null) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + String prompt = switch (id.key()) + { + case "settc" -> "Select a text channel:"; + case "setvc" -> "Select a voice channel:"; + case "setdj" -> "Select the DJ role:"; + default -> null; + }; + if (prompt == null) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + event.reply(prompt) + .setEphemeral(false) + .addComponents(net.dv8tion.jda.api.components.actionrow.ActionRow.of(menu), + net.dv8tion.jda.api.components.actionrow.ActionRow.of(clearButton)) + .queue(); + return; + } + Modal modal = SettingsPanelRenderer.buildModal(id.key(), id.userId()); + if (modal == null) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + event.replyModal(modal).queue(); + } + case SettingsPanelRenderer.ACTION_CLEAR -> { + if (!applyClear(id.key(), settings)) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + + if (("settc".equals(id.key()) || "setvc".equals(id.key()) || "setdj".equals(id.key())) + && id.value() != null && !id.value().isBlank()) + { + long originalPanelMessageId; + try + { + originalPanelMessageId = Long.parseLong(id.value()); + } + catch (NumberFormatException ex) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + + String invokerName = event.getMember() != null ? event.getMember().getEffectiveName() : event.getUser().getName(); + event.deferEdit().queue(hook -> { + event.getMessage().delete().queue( + ignored -> { + }, + err -> { + }); + refreshPanelByMessageId(event.getGuild(), event.getChannel(), + originalPanelMessageId, id.userId(), invokerName, hook); + }); + return; + } + + refreshPanel(event, settings); + } + case SettingsPanelRenderer.ACTION_REFRESH -> refreshPanel(event, settings); + case SettingsPanelRenderer.ACTION_CLOSE -> event.deferEdit().queue(hook -> + event.getMessage().delete().queue( + ignored -> { + }, + err -> hook.sendMessage("Failed to close settings panel.").setEphemeral(true).queue() + )); + default -> event.reply(expiredMessage()).setEphemeral(true).queue(); + } + } + + private void handleSettingsModal(ModalInteractionEvent event) + { + if (!InteractionGuards.requireGuildAndMember(event)) + return; + + Optional<ComponentIdParsers.SettingsModalId> parsed = ComponentIdParsers.parseSettingsModalId(event.getModalId()); + if (parsed.isEmpty()) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + + ComponentIdParsers.SettingsModalId id = parsed.get(); + if (event.getUser().getIdLong() != id.userId()) + { + event.reply("Only the user who opened this settings dialog can submit it.").setEphemeral(true).queue(); + return; + } + if (!hasSettingsPermission(event.getMember(), event.getUser().getIdLong())) + { + event.reply("You need the Manage Server permission to change settings.").setEphemeral(true).queue(); + return; + } + + Settings settings = bot.getSettingsManager().getSettings(event.getGuild()); + String error = applyModalUpdate(event, id.key(), settings); + if (error != null) + { + event.reply(error).setEphemeral(true).queue(); + return; + } + + String invokerName = event.getMember() != null ? event.getMember().getEffectiveName() : event.getUser().getName(); + event.reply(new MessageCreateBuilder() + .setContent("Settings updated.") + .setComponents(SettingsPanelRenderer.buildSettingsMessageComponents( + event.getGuild(), settings, bot.getConfig(), id.userId(), invokerName)) + .useComponentsV2() + .build()) + .setEphemeral(true) + .queue(); + } + + private void handleSettingsEntitySelect(EntitySelectInteractionEvent event) + { + if (!InteractionGuards.requireGuildAndMember(event)) + return; + + Optional<ComponentIdParsers.SettingsEntitySelectId> parsed = + ComponentIdParsers.parseSettingsEntitySelectId(event.getComponentId()); + if (parsed.isEmpty()) + { + event.reply(expiredMessage()).setEphemeral(true).queue(); + return; + } + + ComponentIdParsers.SettingsEntitySelectId id = parsed.get(); + if (event.getUser().getIdLong() != id.userId()) + { + event.reply("Only the user who opened this settings panel can use it.").setEphemeral(true).queue(); + return; + } + if (!hasSettingsPermission(event.getMember(), event.getUser().getIdLong())) + { + event.reply("You need the Manage Server permission to change settings.").setEphemeral(true).queue(); + return; + } + + Settings settings = bot.getSettingsManager().getSettings(event.getGuild()); + String error = applyEntitySelectUpdate(event, id.key(), settings); + if (error != null) + { + event.reply(error).setEphemeral(true).queue(); + return; + } + Long originalPanelMessageId = id.originalPanelMessageId(); + if (originalPanelMessageId == null || originalPanelMessageId <= 0) + { + String invokerName = event.getMember() != null ? event.getMember().getEffectiveName() : event.getUser().getName(); + event.editMessage(new MessageEditBuilder() + .setContent("") + .setEmbeds(List.of()) + .setComponents(SettingsPanelRenderer.buildSettingsMessageComponents( + event.getGuild(), settings, bot.getConfig(), id.userId(), invokerName)) + .useComponentsV2() + .build()) + .queue(); + return; + } + + String invokerName = event.getMember() != null ? event.getMember().getEffectiveName() : event.getUser().getName(); + event.deferEdit().queue(hook -> { + event.getMessage().delete().queue( + ignored -> { + }, + err -> { + }); + refreshPanelByMessageId(event.getGuild(), event.getChannel(), + originalPanelMessageId, id.userId(), invokerName, hook); + }); + } + + private boolean applyEnumUpdate(String key, String value, Settings settings, Guild guild) + { + switch (key) + { + case "queue" -> { + QueueType type; + if ("linear".equals(value)) + type = QueueType.LINEAR; + else if ("fair".equals(value)) + type = QueueType.FAIR; + else + return false; + + if (settings.getQueueType() != type) + { + settings.setQueueType(type); + AudioHandler handler = (AudioHandler) guild.getAudioManager().getSendingHandler(); + if (handler != null) + handler.setQueueType(type); + } + return true; + } + case "layout" -> { + NowPlayingLayoutMode mode = NowPlayingLayoutMode.fromInput(value); + if (mode == null) + return false; + settings.setNowPlayingLayoutMode(mode); + return true; + } + case "npbuttons" -> { + NowPlayingButtonsMode mode = NowPlayingButtonsMode.fromInput(value); + if (mode == null) + return false; + settings.setNowPlayingButtonsMode(mode); + return true; + } + default -> { + return false; + } + } + } + + private boolean applyToggleCycle(String key, Settings settings, Guild guild) + { + switch (key) + { + case "queue" -> { + QueueType next = settings.getQueueType() == QueueType.LINEAR ? QueueType.FAIR : QueueType.LINEAR; + settings.setQueueType(next); + AudioHandler handler = (AudioHandler) guild.getAudioManager().getSendingHandler(); + if (handler != null) + handler.setQueueType(next); + return true; + } + case "layout" -> { + NowPlayingLayoutMode current = settings.getNowPlayingLayoutMode(); + NowPlayingLayoutMode next = switch (current) + { + case FULL -> NowPlayingLayoutMode.MINIMAL; + case MINIMAL -> NowPlayingLayoutMode.INHERIT; + case INHERIT -> NowPlayingLayoutMode.FULL; + }; + settings.setNowPlayingLayoutMode(next); + return true; + } + case "npbuttons" -> { + NowPlayingButtonsMode current = settings.getNowPlayingButtonsMode(); + NowPlayingButtonsMode next = switch (current) + { + case ON -> NowPlayingButtonsMode.OFF; + case OFF -> NowPlayingButtonsMode.INHERIT; + case INHERIT -> NowPlayingButtonsMode.ON; + }; + settings.setNowPlayingButtonsMode(next); + return true; + } + default -> { + return false; + } + } + } + + private boolean applyClear(String key, Settings settings) + { + switch (key) + { + case "settc" -> settings.setTextChannel(null); + case "setvc" -> settings.setVoiceChannel(null); + case "setdj" -> settings.setDJRole(null); + case "prefix" -> settings.setPrefix(null); + default -> { + return false; + } + } + return true; + } + + private String applyModalUpdate(ModalInteractionEvent event, String key, Settings settings) + { + Guild guild = event.getGuild(); + assert guild != null; + + switch (key) + { + case "prefix" -> { + String value = SettingsPanelRenderer.modalValue(event, "prefix_value"); + settings.setPrefix(value.isEmpty() ? null : value); + return null; + } + case "setskip" -> { + String value = SettingsPanelRenderer.modalValue(event, "setskip_value"); + if (value.isEmpty()) + return "Please enter an integer between 0 and 100."; + try + { + int percent = Integer.parseInt(value); + if (percent < 0 || percent > 100) + return "Skip percentage must be between 0 and 100."; + settings.setSkipRatio(percent / 100.0); + return null; + } + catch (NumberFormatException ex) + { + return "Please enter an integer between 0 and 100."; + } + } + default -> { + return expiredMessage(); + } + } + } + + private String applyEntitySelectUpdate(EntitySelectInteractionEvent event, String key, Settings settings) + { + switch (key) + { + case "settc" -> { + var channels = event.getMentions().getChannels(TextChannel.class); + if (channels.isEmpty()) + return "Please choose a text channel."; + TextChannel textChannel = channels.get(0); + settings.setTextChannel(textChannel); + return null; + } + case "setvc" -> { + var channels = event.getMentions().getChannels(VoiceChannel.class); + if (channels.isEmpty()) + return "Please choose a voice channel."; + VoiceChannel voiceChannel = channels.get(0); + settings.setVoiceChannel(voiceChannel); + return null; + } + case "setdj" -> { + var roles = event.getMentions().getRoles(); + if (roles.isEmpty()) + return "Please choose a role."; + Role role = roles.get(0); + settings.setDJRole(role); + return null; + } + default -> { + return expiredMessage(); + } + } + } + + private void refreshPanel(ButtonInteractionEvent event, Settings settings) + { + String invokerName = event.getMember() != null ? event.getMember().getEffectiveName() : event.getUser().getName(); + event.editMessage(new MessageEditBuilder() + .setContent("") + .setEmbeds(List.of()) + .setComponents(SettingsPanelRenderer.buildSettingsMessageComponents( + event.getGuild(), settings, bot.getConfig(), event.getUser().getIdLong(), invokerName)) + .useComponentsV2() + .build()) + .queue(); + } + + private void refreshPanelByMessageId(Guild guild, MessageChannel channel, long panelMessageId, long userId, + String invokerName, net.dv8tion.jda.api.interactions.InteractionHook hook) + { + if (guild == null || channel == null) + { + hook.sendMessage(expiredMessage()).setEphemeral(true).queue(); + return; + } + + Settings settings = bot.getSettingsManager().getSettings(guild); + channel.retrieveMessageById(panelMessageId) + .queue(message -> message.editMessage(new MessageEditBuilder() + .setContent("") + .setEmbeds(List.of()) + .setComponents(SettingsPanelRenderer.buildSettingsMessageComponents( + guild, settings, bot.getConfig(), userId, invokerName)) + .useComponentsV2() + .build()) + .queue(), + err -> { + if (err instanceof ErrorResponseException ex + && ex.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) + { + hook.sendMessage(expiredMessage()).setEphemeral(true).queue(); + } + else + { + hook.sendMessage("Failed to refresh settings panel. Run `/settings` again.") + .setEphemeral(true).queue(); + } + }); + } + + private String expiredMessage() + { + return "This settings panel is expired. Run `/settings` again."; + } + + /** + * Same permission as admin settings commands: bot owner or MANAGE_SERVER. + */ + private boolean hasSettingsPermission(Member member, long userId) + { + if (member == null) + return false; + boolean isOwner = userId == bot.getConfig().getOwnerId(); + boolean hasPermission = member.hasPermission(Permission.MANAGE_SERVER); + return isOwner || hasPermission; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/StartupLifecycleListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/StartupLifecycleListener.java new file mode 100644 index 0000000..a0630e8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/StartupLifecycleListener.java @@ -0,0 +1,151 @@ +/* + * 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.listener; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.JMusicBot; +import com.jagrosh.jmusicbot.commands.SlashCommandRegistry; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; +import com.jagrosh.jmusicbot.utils.OtherUtil; +import com.jagrosh.jmusicbot.utils.YoutubeOauth2TokenHandler; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.guild.GuildJoinEvent; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.events.session.SessionDisconnectEvent; +import net.dv8tion.jda.api.events.session.ShutdownEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.CloseCode; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +/** + * Handles startup and lifecycle events: ready, session disconnect, shutdown, guild join. + */ +public class StartupLifecycleListener extends ListenerAdapter { + + private final Bot bot; + + public StartupLifecycleListener(Bot bot) { + this.bot = bot; + } + + @Override + public void onReady(ReadyEvent event) { + if (event.getJDA().getGuildCache().isEmpty()) { + Logger log = LoggerFactory.getLogger("MusicBot"); + String inviteUrl = event.getJDA().getInviteUrl(JMusicBot.RECOMMENDED_PERMS); + log.warn("This bot is not on any guilds! Use the following link to add the bot to your guilds!"); + log.warn(inviteUrl); + bot.getUserInteraction().alert(Level.WARNING, "Setup", + "This bot is not on any guilds!\n\nUse this link to add the bot to your server:\n" + inviteUrl); + } + + if (bot.getCommandClient() != null) { + SlashCommandRegistry.registerIfChanged(event.getJDA(), bot.getCommandClient()); + } + + credit(event.getJDA()); + event.getJDA().getGuilds().forEach((Guild guild) -> { + try { + String defpl = bot.getSettingsManager().getSettings(guild).getDefaultPlaylist(); + VoiceChannel vc = bot.getSettingsManager().getSettings(guild).getVoiceChannel(guild); + if (defpl != null && vc != null && bot.getPlayerManager().setUpHandler(guild).playFromDefault()) { + guild.getAudioManager().openAudioConnection(vc); + } + } catch (Exception ignore) { + } + }); + if (bot.getConfig().useUpdateAlerts()) { + bot.getThreadpool().scheduleWithFixedDelay(() -> { + try { + User owner = bot.getJDA().retrieveUserById(bot.getConfig().getOwnerId()).complete(); + String currentVersion = OtherUtil.getCurrentVersion(); + String latestVersion = OtherUtil.getLatestVersion(bot.getConfig()); + if (latestVersion != null && OtherUtil.isNewerVersion(currentVersion, latestVersion)) { + String msg = String.format(OtherUtil.NEW_VERSION_AVAILABLE, currentVersion, latestVersion); + owner.openPrivateChannel().queue(pc -> pc.sendMessage(msg).queue()); + } + } catch (Exception ignored) { + } + }, 0, 24, TimeUnit.HOURS); + } + if (bot.getConfig().useYouTubeOauth()) { + YoutubeOauth2TokenHandler.Data data = bot.getYouTubeOauth2Handler().getData(); + if (data != null) { + try { + PrivateChannel channel = bot.getJDA().openPrivateChannelById(bot.getConfig().getOwnerId()).complete(); + channel + .sendMessage( + "# DO NOT AUTHORISE THIS WITH YOUR MAIN GOOGLE ACCOUNT!!!\n" + + "## Create or use an alternative/burner Google account!\n" + + "To give JMusicBot access to your Google account, go to " + + data.getAuthorisationUrl() + + " and enter the code **" + data.getCode() + "**") + .queue(); + } catch (Exception ignored) { + } + } + } + } + + @Override + public void onSessionDisconnect(@NotNull SessionDisconnectEvent event) { + CloseCode closeCode = event.getCloseCode(); + if (closeCode == CloseCode.DISALLOWED_INTENTS) { + bot.getUserInteraction().alert( + Level.ERROR, + "JMusicBot", + "Your bot is missing required Discord intents!\n\n" + + "To fix this:\n" + + "1. Go to https://discord.com/developers/applications\n" + + "2. Select your bot application\n" + + "3. Go to 'Bot' settings\n" + + "4. Enable 'MESSAGE CONTENT INTENT' under Privileged Gateway Intents\n" + + "5. Save changes and restart JMusicBot" + ); + } + } + + @Override + public void onShutdown(@NotNull ShutdownEvent event) { + bot.shutdown(); + } + + @Override + public void onGuildJoin(GuildJoinEvent event) { + credit(event.getJDA()); + } + + private void credit(JDA jda) { + Guild dbots = jda.getGuildById(110373943822540800L); + if (dbots == null) { + return; + } + if (bot.getConfig().getDBots()) { + return; + } + jda.getTextChannelById(119222314964353025L) + .sendMessage("This account is running JMusicBot. Please do not list bot clones on this server, <@" + bot.getConfig().getOwnerId() + ">.").complete(); + dbots.leave().queue(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/VoiceStateListener.java b/src/main/java/com/jagrosh/jmusicbot/listener/VoiceStateListener.java new file mode 100644 index 0000000..1a897c4 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/VoiceStateListener.java @@ -0,0 +1,38 @@ +/* + * 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.listener; + +import com.jagrosh.jmusicbot.Bot; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +/** + * Handles voice state updates (alone-in-voice logic). + */ +public class VoiceStateListener extends ListenerAdapter { + + private final Bot bot; + + public VoiceStateListener(Bot bot) { + this.bot = bot; + } + + @Override + public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + bot.getAloneInVoiceHandler().onVoiceUpdate(event); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/interaction/ComponentIdParsers.java b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/ComponentIdParsers.java new file mode 100644 index 0000000..4d171f3 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/ComponentIdParsers.java @@ -0,0 +1,393 @@ +/* + * 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.listener.interaction; + +import java.util.Optional; + +/** + * Parsing and validation for component IDs used by queue and history interactions. + * Format contracts: queue_{action}_{page}_{selectedTrack}_{userId}, history_{action}_{page}_{selectedTrack}_{userId}, + * playlists_{action}_{page}_{selectedIndex}_{userId}, queue_move_select_{fromPosition}_{page}_{userId}, + * history_save_{userId}, settings_{action}_{key}_{value?}_{userId}, settings_modal_{key}_{userId}, + * settings_entity_{key}_{userId}, np_{action}. + */ +public final class ComponentIdParsers { + + private static final String QUEUE_PREFIX = "queue_"; + private static final String HISTORY_PREFIX = "history_"; + private static final String PLAYLISTS_PREFIX = "playlists_"; + private static final String PLAYLIST_DETAILS_PREFIX = "playlistdetails_"; + private static final String PLAYLIST_DETAILS_BACK_PREFIX = "playlistdetails_back_"; + private static final String PLAYLIST_DETAILS_MOVE_SELECT_PREFIX = "playlistdetails_move_select_"; + private static final String QUEUE_MOVE_SELECT_PREFIX = "queue_move_select_"; + private static final String HISTORY_SAVE_PREFIX = "history_save_"; + private static final String SETTINGS_PREFIX = "settings_"; + private static final String SETTINGS_MODAL_PREFIX = "settings_modal_"; + private static final String SETTINGS_ENTITY_PREFIX = "settings_entity_"; + private static final String NOW_PLAYING_PREFIX = "np_"; + + private ComponentIdParsers() { + } + + /** + * Parsed queue or history button ID: action, page, selectedTrack, userId. + */ + public record PaginatedButtonId(String action, int page, int selectedTrack, long userId) { + } + + /** + * Parsed queue move select ID: fromPosition, page, userId. + */ + public record QueueMoveSelectId(int fromPosition, int page, long userId) { + } + + /** + * Parsed playlist-details button ID: + * playlistIndex, listPage, action, detailsPage, selectedTrack, userId. + */ + public record PlaylistDetailsButtonId( + int playlistIndex, + int listPage, + String action, + int detailsPage, + int selectedTrack, + long userId + ) { + } + + /** + * Parsed playlist-details back button ID: listPage, listSelectedIndex, userId. + */ + public record PlaylistDetailsBackId(int listPage, int listSelectedIndex, long userId) { + } + + /** + * Parsed playlist-details move-select ID. + */ + public record PlaylistDetailsMoveSelectId( + int playlistIndex, + int listPage, + int fromTrack, + int detailsPage, + long userId + ) { + } + + /** + * Parsed settings button ID: action, key, value (optional), userId. + */ + public record SettingsButtonId(String action, String key, String value, long userId) { + } + + /** + * Parsed settings modal ID: key, userId. + */ + public record SettingsModalId(String key, long userId) { + } + + /** + * Parsed settings entity-select ID: key, userId. + */ + public record SettingsEntitySelectId(String key, Long originalPanelMessageId, long userId) { + } + + /** + * Parsed now-playing button ID: action. + */ + public record NowPlayingButtonId(String action) { + } + + /** + * Parses queue_* button component ID. Expected format: queue_action_page_selectedTrack_userId. + */ + public static Optional<PaginatedButtonId> parseQueueButtonId(String componentId) { + if (!componentId.startsWith(QUEUE_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length < 5) { + return Optional.empty(); + } + try { + String action = parts[1]; + int page = Integer.parseInt(parts[2]); + int selectedTrack = Integer.parseInt(parts[3]); + long userId = Long.parseLong(parts[4]); + return Optional.of(new PaginatedButtonId(action, page, selectedTrack, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses history_* button component ID. Expected format: history_action_page_selectedTrack_userId. + */ + public static Optional<PaginatedButtonId> parseHistoryButtonId(String componentId) { + if (!componentId.startsWith(HISTORY_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length < 5) { + return Optional.empty(); + } + try { + String action = parts[1]; + int page = Integer.parseInt(parts[2]); + int selectedTrack = Integer.parseInt(parts[3]); + long userId = Long.parseLong(parts[4]); + return Optional.of(new PaginatedButtonId(action, page, selectedTrack, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses playlists_* button component ID. Expected format: playlists_action_page_selectedIndex_userId. + */ + public static Optional<PaginatedButtonId> parsePlaylistsButtonId(String componentId) { + if (!componentId.startsWith(PLAYLISTS_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length < 5) { + return Optional.empty(); + } + try { + String action = parts[1]; + int page = Integer.parseInt(parts[2]); + int selectedIndex = Integer.parseInt(parts[3]); + long userId = Long.parseLong(parts[4]); + return Optional.of(new PaginatedButtonId(action, page, selectedIndex, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses playlistdetails_* button IDs except back. + * Expected format: + * playlistdetails_playlistIndex_listPage_action_detailsPage_selectedTrack_userId. + */ + public static Optional<PlaylistDetailsButtonId> parsePlaylistDetailsButtonId(String componentId) { + if (!componentId.startsWith(PLAYLIST_DETAILS_PREFIX) + || componentId.startsWith(PLAYLIST_DETAILS_BACK_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length != 7) { + return Optional.empty(); + } + try { + int playlistIndex = Integer.parseInt(parts[1]); + int listPage = Integer.parseInt(parts[2]); + String action = parts[3]; + int detailsPage = Integer.parseInt(parts[4]); + int selectedTrack = Integer.parseInt(parts[5]); + long userId = Long.parseLong(parts[6]); + return Optional.of(new PlaylistDetailsButtonId( + playlistIndex, listPage, action, detailsPage, selectedTrack, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses playlistdetails_back_* IDs. + * Expected format: playlistdetails_back_listPage_listSelectedIndex_userId. + */ + public static Optional<PlaylistDetailsBackId> parsePlaylistDetailsBackId(String componentId) { + if (!componentId.startsWith(PLAYLIST_DETAILS_BACK_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length != 5) { + return Optional.empty(); + } + try { + int listPage = Integer.parseInt(parts[2]); + int listSelectedIndex = Integer.parseInt(parts[3]); + long userId = Long.parseLong(parts[4]); + return Optional.of(new PlaylistDetailsBackId(listPage, listSelectedIndex, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses playlistdetails_move_select_* IDs. + * Expected format: playlistdetails_move_select_playlistIndex_listPage_fromTrack_detailsPage_userId. + */ + public static Optional<PlaylistDetailsMoveSelectId> parsePlaylistDetailsMoveSelectId(String componentId) { + if (!componentId.startsWith(PLAYLIST_DETAILS_MOVE_SELECT_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length != 8) { + return Optional.empty(); + } + try { + int playlistIndex = Integer.parseInt(parts[3]); + int listPage = Integer.parseInt(parts[4]); + int fromTrack = Integer.parseInt(parts[5]); + int detailsPage = Integer.parseInt(parts[6]); + long userId = Long.parseLong(parts[7]); + return Optional.of(new PlaylistDetailsMoveSelectId( + playlistIndex, listPage, fromTrack, detailsPage, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses queue_move_select_* component ID. Expected format: queue_move_select_fromPosition_page_userId. + */ + public static Optional<QueueMoveSelectId> parseQueueMoveSelectId(String componentId) { + if (!componentId.startsWith(QUEUE_MOVE_SELECT_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length < 6) { + return Optional.empty(); + } + try { + int fromPosition = Integer.parseInt(parts[3]); + int page = Integer.parseInt(parts[4]); + long userId = Long.parseLong(parts[5]); + return Optional.of(new QueueMoveSelectId(fromPosition, page, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses history_save_* modal ID. Expected format: history_save_userId. + */ + public static Optional<Long> parseHistorySaveModalUserId(String modalId) { + if (!modalId.startsWith(HISTORY_SAVE_PREFIX)) { + return Optional.empty(); + } + try { + return Optional.of(Long.parseLong(modalId.substring(HISTORY_SAVE_PREFIX.length()))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses settings button IDs. + * Supported formats: + * - settings_enum_key_value_userId + * - settings_open_key_userId + * - settings_clear_key_userId + * - settings_refresh_key_userId + */ + public static Optional<SettingsButtonId> parseSettingsButtonId(String componentId) { + if (!componentId.startsWith(SETTINGS_PREFIX) || componentId.startsWith(SETTINGS_MODAL_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length == 5) { + try { + String action = parts[1]; + String key = parts[2]; + String value = parts[3]; + long userId = Long.parseLong(parts[4]); + return Optional.of(new SettingsButtonId(action, key, value, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + if (parts.length == 4) { + try { + String action = parts[1]; + String key = parts[2]; + long userId = Long.parseLong(parts[3]); + return Optional.of(new SettingsButtonId(action, key, null, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + /** + * Parses settings modal IDs. + * Expected format: settings_modal_key_userId. + */ + public static Optional<SettingsModalId> parseSettingsModalId(String modalId) { + if (!modalId.startsWith(SETTINGS_MODAL_PREFIX)) { + return Optional.empty(); + } + String[] parts = modalId.split("_"); + if (parts.length != 4) { + return Optional.empty(); + } + try { + String key = parts[2]; + long userId = Long.parseLong(parts[3]); + return Optional.of(new SettingsModalId(key, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses settings entity-select IDs. + * Expected format: settings_entity_key_userId. + * New format: settings_entity_key_originalPanelMessageId_userId. + */ + public static Optional<SettingsEntitySelectId> parseSettingsEntitySelectId(String componentId) { + if (!componentId.startsWith(SETTINGS_ENTITY_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length == 4) { + try { + String key = parts[2]; + long userId = Long.parseLong(parts[3]); + return Optional.of(new SettingsEntitySelectId(key, null, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + if (parts.length == 5) { + try { + String key = parts[2]; + long originalPanelMessageId = Long.parseLong(parts[3]); + long userId = Long.parseLong(parts[4]); + return Optional.of(new SettingsEntitySelectId(key, originalPanelMessageId, userId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + /** + * Parses now-playing button IDs. + * Expected format: np_action. + */ + public static Optional<NowPlayingButtonId> parseNowPlayingButtonId(String componentId) { + if (!componentId.startsWith(NOW_PLAYING_PREFIX)) { + return Optional.empty(); + } + String[] parts = componentId.split("_"); + if (parts.length != 2 || parts[1].isBlank()) { + return Optional.empty(); + } + return Optional.of(new NowPlayingButtonId(parts[1])); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/interaction/InteractionGuards.java b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/InteractionGuards.java new file mode 100644 index 0000000..9db274f --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/InteractionGuards.java @@ -0,0 +1,138 @@ +/* + * 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.listener.interaction; + +import com.jagrosh.jmusicbot.Bot; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.exceptions.PermissionException; + +/** + * Shared guard logic for interaction handlers: guild/member checks, voice channel checks, + * and ensuring the bot joins the user's voice channel when needed. + */ +public final class InteractionGuards { + + private InteractionGuards() { + } + + /** + * Ensures the interaction is from a guild with a non-null member. If not, replies with an error and returns false. + */ + public static boolean requireGuildAndMember(ButtonInteractionEvent event) { + return requireGuildAndMemberImpl(event.getGuild(), event.getMember(), () -> event.reply("This can only be used in a server!").setEphemeral(true).queue()); + } + + /** + * Ensures the interaction is from a guild with a non-null member. If not, replies with an error and returns false. + */ + public static boolean requireGuildAndMember(StringSelectInteractionEvent event) { + return requireGuildAndMemberImpl(event.getGuild(), event.getMember(), () -> event.reply("This can only be used in a server!").setEphemeral(true).queue()); + } + + /** + * Ensures the interaction is from a guild with a non-null member. If not, replies with an error and returns false. + */ + public static boolean requireGuildAndMember(ModalInteractionEvent event) { + return requireGuildAndMemberImpl(event.getGuild(), event.getMember(), () -> event.reply("This can only be used in a server!").setEphemeral(true).queue()); + } + + /** + * Ensures the interaction is from a guild with a non-null member. If not, replies with an error and returns false. + */ + public static boolean requireGuildAndMember(EntitySelectInteractionEvent event) { + return requireGuildAndMemberImpl(event.getGuild(), event.getMember(), () -> event.reply("This can only be used in a server!").setEphemeral(true).queue()); + } + + private static boolean requireGuildAndMemberImpl(net.dv8tion.jda.api.entities.Guild guild, net.dv8tion.jda.api.entities.Member member, Runnable sendError) { + if (guild == null || member == null) { + sendError.run(); + return false; + } + return true; + } + + /** + * Ensures the user is in the same voice channel as the bot. If not, replies with an error and returns false. + * Assumes guild and member are non-null. + */ + public static boolean requireSameVoiceChannel(ButtonInteractionEvent event) { + return requireSameVoiceChannelImpl(event.getGuild(), event.getMember(), () -> event.reply("You must be in the same voice channel to use this!").setEphemeral(true).queue()); + } + + /** + * Ensures the user is in the same voice channel as the bot. If not, replies with an error and returns false. + * Assumes guild and member are non-null. + */ + public static boolean requireSameVoiceChannel(StringSelectInteractionEvent event) { + return requireSameVoiceChannelImpl(event.getGuild(), event.getMember(), () -> event.reply("You must be in the same voice channel to use this!").setEphemeral(true).queue()); + } + + private static boolean requireSameVoiceChannelImpl(net.dv8tion.jda.api.entities.Guild guild, net.dv8tion.jda.api.entities.Member member, Runnable sendError) { + if (!member.getVoiceState().inAudioChannel() + || guild.getSelfMember().getVoiceState().getChannel() == null + || !member.getVoiceState().getChannel().equals(guild.getSelfMember().getVoiceState().getChannel())) { + sendError.run(); + return false; + } + return true; + } + + /** + * Ensures the bot is in the user's voice channel for playback actions. If the user is not in a voice channel, + * or is in a different channel than the bot when the bot is already connected, replies with an error and returns false. + * If the bot is not connected, joins the user's channel and returns true (or false on permission error). + * + * @param event the button interaction event (must be in a guild with a non-null member) + * @param bot the bot instance + * @return true if the bot is now in the user's voice channel, false if an error was replied + */ + public static boolean ensureBotInUserVoiceChannel(net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent event, Bot bot) { + Guild guild = event.getGuild(); + assert guild != null; + var member = event.getMember(); + assert member != null; + + if (!member.getVoiceState().inAudioChannel()) { + event.reply("You need to be in a voice channel to use this!").setEphemeral(true).queue(); + return false; + } + + AudioChannel botChannel = guild.getSelfMember().getVoiceState().getChannel(); + AudioChannel userChannel = member.getVoiceState().getChannel(); + + if (botChannel != null) { + if (!userChannel.equals(botChannel)) { + event.reply("You must be in the same voice channel to use this!").setEphemeral(true).queue(); + return false; + } + return true; + } + + bot.getPlayerManager().setUpHandler(guild); + try { + guild.getAudioManager().openAudioConnection(userChannel); + return true; + } catch (PermissionException ex) { + event.reply("I cannot connect to " + userChannel.getName() + "! Check my permissions.").setEphemeral(true).queue(); + return false; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/interaction/OutputAdapters.java b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/OutputAdapters.java new file mode 100644 index 0000000..4f372ca --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/OutputAdapters.java @@ -0,0 +1,359 @@ +/* + * 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.listener.interaction; + +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.service.MusicService; +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; + +/** + * Factory methods for MusicService.OutputAdapter used by interaction listeners. + */ +public final class OutputAdapters { + + private OutputAdapters() { + } + + /** + * Adapter for playback control buttons: replies and edits now-playing message. + */ + public static MusicService.OutputAdapter forPlaybackButton(net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent event) { + return new MusicService.OutputAdapter() { + @Override + public void replySuccess(String content) { + event.reply(content).setEphemeral(true).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.editMessage(content).queue(); + } + + @Override + public void editMessage(String content, Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + event.editMessage(content).queue(hook -> hook.retrieveOriginal().queue(onSuccess)); + } + + @Override + public void editNowPlaying(AudioHandler handler) { + event.editMessage(asEditData(handler.getNowPlaying(event.getJDA()))).queue(); + } + + @Override + public void editNoMusic(AudioHandler handler) { + event.editMessage(asEditData(handler.getNoMusicPlaying(event.getJDA()))).queue(); + } + + @Override + public void onShowHelp() { + } + }; + } + + /** + * Adapter for favorite playback button: + * success replies are public, warnings/errors remain ephemeral. + */ + public static MusicService.OutputAdapter forFavoritePlaybackButton(net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent event) { + return new MusicService.OutputAdapter() { + private void sendResponse(String content, boolean ephemeral) { + if (!event.isAcknowledged()) { + var reply = event.reply(content); + if (ephemeral) { + reply.setEphemeral(true); + } + reply.queue(); + return; + } + + var followup = event.getHook().sendMessage(content); + if (ephemeral) { + followup.setEphemeral(true); + } + followup.queue(); + } + + @Override + public void replySuccess(String content) { + sendResponse(content, false); + } + + @Override + public void replyError(String content) { + sendResponse(content, true); + } + + @Override + public void replyWarning(String content) { + sendResponse(content, true); + } + + @Override + public void editMessage(String content) { + event.editMessage(content).queue(); + } + + @Override + public void editMessage(String content, Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + event.editMessage(content).queue(hook -> hook.retrieveOriginal().queue(onSuccess)); + } + + @Override + public void editNowPlaying(AudioHandler handler) { + event.editMessage(asEditData(handler.getNowPlaying(event.getJDA()))).queue(); + } + + @Override + public void editNoMusic(AudioHandler handler) { + event.editMessage(asEditData(handler.getNoMusicPlaying(event.getJDA()))).queue(); + } + + @Override + public void onShowHelp() { + } + }; + } + + /** + * Adapter for queue embed actions: only errors/warnings are replied; success updates the embed instead. + */ + public static MusicService.OutputAdapter forQueueEmbed(net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent event) { + return new MusicService.OutputAdapter() { + @Override + public void replySuccess(String content) { + } + + @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) { + } + + @Override + public void editMessage(String content, Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + } + + @Override + public void editNowPlaying(AudioHandler handler) { + } + + @Override + public void editNoMusic(AudioHandler handler) { + } + + @Override + public void onShowHelp() { + } + }; + } + + /** + * Adapter for history embed actions: all feedback via ephemeral reply. + */ + public static MusicService.OutputAdapter forHistoryReply(net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent event) { + return new MusicService.OutputAdapter() { + @Override + public void replySuccess(String content) { + event.reply(content).setEphemeral(true).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) { + } + + @Override + public void editMessage(String content, Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + } + + @Override + public void editNowPlaying(AudioHandler handler) { + } + + @Override + public void editNoMusic(AudioHandler handler) { + } + + @Override + public void onShowHelp() { + } + }; + } + + /** + * Adapter for playlists embed actions: all feedback via ephemeral reply. + */ + public static MusicService.OutputAdapter forPlaylistsReply(net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent event) { + return new MusicService.OutputAdapter() { + @Override + public void replySuccess(String content) { + event.reply(content).setEphemeral(true).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) { + } + + @Override + public void editMessage(String content, Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + } + + @Override + public void editNowPlaying(AudioHandler handler) { + } + + @Override + public void editNoMusic(AudioHandler handler) { + } + + @Override + public void onShowHelp() { + } + }; + } + + /** + * Adapter for queue move select: only errors/warnings replied. + */ + public static MusicService.OutputAdapter forQueueMoveSelect(net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent event) { + return new MusicService.OutputAdapter() { + @Override + public void replySuccess(String content) { + } + + @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) { + } + + @Override + public void editMessage(String content, Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + } + + @Override + public void editNowPlaying(AudioHandler handler) { + } + + @Override + public void editNoMusic(AudioHandler handler) { + } + + @Override + public void onShowHelp() { + } + }; + } + + /** + * Adapter for history save modal: all feedback via ephemeral reply. + */ + public static MusicService.OutputAdapter forHistoryModal(net.dv8tion.jda.api.events.interaction.ModalInteractionEvent event) { + return new MusicService.OutputAdapter() { + @Override + public void replySuccess(String content) { + event.reply(content).setEphemeral(true).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) { + } + + @Override + public void editMessage(String content, Consumer<net.dv8tion.jda.api.entities.Message> onSuccess) { + } + + @Override + public void editNowPlaying(AudioHandler handler) { + } + + @Override + public void editNoMusic(AudioHandler handler) { + } + + @Override + public void onShowHelp() { + } + }; + } + + private static MessageEditData asEditData(MessageCreateData source) { + return new MessageEditBuilder() + .setContent(source.getContent() == null ? "" : source.getContent()) + .setEmbeds(source.getEmbeds()) + .setComponents(source.getComponents()) + .build(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/listener/interaction/PaginatedListComponents.java b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/PaginatedListComponents.java new file mode 100644 index 0000000..d3614d2 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/listener/interaction/PaginatedListComponents.java @@ -0,0 +1,123 @@ +/* + * 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.listener.interaction; + +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.emoji.Emoji; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Shared builders for paginated list interaction components (selection rows + pagination row). + */ +public final class PaginatedListComponents +{ + private PaginatedListComponents() + { + } + + /** + * Base ID format: {prefix}_{action}_{page}_{selectedIndex}_{userId} + */ + public static String baseId(String prefix, int page, int selectedIndex, long userId) + { + return prefix + "_%s_" + page + "_" + selectedIndex + "_" + userId; + } + + /** + * Builds sparse selection rows for the current page. + * Button labels and select actions use absolute indices to match embed numbering. + */ + public static List<ActionRow> buildSelectRows(String baseId, int page, int pageSize, int itemsOnPage, int selectedIndex) + { + List<ActionRow> rows = new ArrayList<>(); + if (itemsOnPage <= 0) + { + return rows; + } + + int startAbsoluteIndex = (page - 1) * pageSize + 1; + List<Button> firstRow = new ArrayList<>(); + List<Button> secondRow = new ArrayList<>(); + for (int offset = 0; offset < itemsOnPage; offset++) + { + int absoluteIndex = startAbsoluteIndex + offset; + String action = "select" + absoluteIndex; + Button btn = Button.secondary(String.format(baseId, action), String.valueOf(absoluteIndex)); + if (absoluteIndex == selectedIndex) + { + btn = Button.primary(String.format(baseId, action), String.valueOf(absoluteIndex)); + } + + if (offset < 5) + { + firstRow.add(btn); + } + else + { + secondRow.add(btn); + } + } + + if (!firstRow.isEmpty()) + { + rows.add(ActionRow.of(firstRow)); + } + if (!secondRow.isEmpty()) + { + rows.add(ActionRow.of(secondRow)); + } + return rows; + } + + /** + * Builds pagination row with optional extra buttons (e.g. shuffle). + */ + public static ActionRow buildPaginationRow(String baseId, int page, int totalPages, Button... extraButtons) + { + if (totalPages <= 1) + { + if (extraButtons.length == 0) + { + return null; + } + return ActionRow.of(Arrays.asList(extraButtons)); + } + + Button prevBtn = Button.secondary(String.format(baseId, "prev"), Emoji.fromUnicode("⬅️")); + Button nextBtn = Button.secondary(String.format(baseId, "next"), Emoji.fromUnicode("➡️")); + if (page <= 1) + { + prevBtn = prevBtn.asDisabled(); + } + if (page >= totalPages) + { + nextBtn = nextBtn.asDisabled(); + } + + List<Button> buttons = new ArrayList<>(); + buttons.add(prevBtn); + buttons.add(nextBtn); + for (Button extraButton : extraButtons) + { + buttons.add(extraButton); + } + return ActionRow.of(buttons); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/playlist/PlaylistLoader.java b/src/main/java/com/jagrosh/jmusicbot/playlist/PlaylistLoader.java new file mode 100644 index 0000000..730d9de --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/playlist/PlaylistLoader.java @@ -0,0 +1,942 @@ +/* + * Copyright 2018 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.playlist; + +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.utils.OtherUtil; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +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.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * + * @author John Grosh (john.a.grosh@gmail.com) + */ +public class PlaylistLoader +{ + private static final Logger LOG = LoggerFactory.getLogger(PlaylistLoader.class); + private static final String FAVORITES_PLAYLIST_NAME = "favorites"; + private static final byte LF = (byte) '\n'; + private static final byte CR = (byte) '\r'; + + public enum PlaylistErrorType + { + INVALID_CONFIG, + STORAGE_UNAVAILABLE, + PLAYLIST_NOT_FOUND + } + + public static final class PlaylistError + { + private final PlaylistErrorType type; + private final String message; + private final String configuredPath; + private final Throwable cause; + + private PlaylistError(PlaylistErrorType type, String message, String configuredPath, Throwable cause) + { + this.type = type; + this.message = message; + this.configuredPath = configuredPath; + this.cause = cause; + } + + public static PlaylistError of(PlaylistErrorType type, String message, String configuredPath, Throwable cause) + { + return new PlaylistError(type, message, configuredPath, cause); + } + + public PlaylistErrorType getType() + { + return type; + } + + public String getMessage() + { + return message; + } + + public String getConfiguredPath() + { + return configuredPath; + } + + public Throwable getCause() + { + return cause; + } + } + + public static final class PlaylistResult<T> + { + private final T value; + private final PlaylistError error; + + private PlaylistResult(T value, PlaylistError error) + { + this.value = value; + this.error = error; + } + + public static <T> PlaylistResult<T> success(T value) + { + return new PlaylistResult<>(value, null); + } + + public static <T> PlaylistResult<T> failure(PlaylistError error) + { + return new PlaylistResult<>(null, error); + } + + public boolean isSuccess() + { + return error == null; + } + + public T getValue() + { + return value; + } + + public PlaylistError getError() + { + return error; + } + } + + public enum AppendIfAbsentStatus + { + APPENDED, + ALREADY_PRESENT + } + + public static final class AppendIfAbsentResult + { + private final AppendIfAbsentStatus status; + + private AppendIfAbsentResult(AppendIfAbsentStatus status) + { + this.status = status; + } + + public static AppendIfAbsentResult appended() + { + return new AppendIfAbsentResult(AppendIfAbsentStatus.APPENDED); + } + + public static AppendIfAbsentResult alreadyPresent() + { + return new AppendIfAbsentResult(AppendIfAbsentStatus.ALREADY_PRESENT); + } + + public AppendIfAbsentStatus getStatus() + { + return status; + } + } + + private static final class FileSignature + { + private final boolean exists; + private final long size; + private final long lastModifiedMillis; + + private FileSignature(boolean exists, long size, long lastModifiedMillis) + { + this.exists = exists; + this.size = size; + this.lastModifiedMillis = lastModifiedMillis; + } + + private static FileSignature missing() + { + return new FileSignature(false, 0L, 0L); + } + + private static FileSignature of(Path path) throws IOException + { + if(!Files.exists(path)) + return missing(); + FileTime lastModified = Files.getLastModifiedTime(path); + return new FileSignature(true, Files.size(path), lastModified.toMillis()); + } + + @Override + public boolean equals(Object other) + { + if(this == other) + return true; + if(!(other instanceof FileSignature that)) + return false; + return exists == that.exists + && size == that.size + && lastModifiedMillis == that.lastModifiedMillis; + } + + @Override + public int hashCode() + { + return Objects.hash(exists, size, lastModifiedMillis); + } + } + + private static final class FavoritesCache + { + private final FileSignature signature; + private final Set<String> entries; + + private FavoritesCache(FileSignature signature, Set<String> entries) + { + this.signature = signature; + this.entries = entries; + } + } + + private final BotConfig config; + private volatile PlaylistError lastStorageError; + private volatile FavoritesCache favoritesCache; + private final Map<Path, Object> inProcessFileLocks = new ConcurrentHashMap<>(); + + public PlaylistLoader(BotConfig config) + { + this.config = config; + } + + public PlaylistResult<Path> ensureStorageReady() + { + return ensureStorageReady(true); + } + + public PlaylistResult<Path> checkStorageReady() + { + return ensureStorageReady(false); + } + + private PlaylistResult<Path> ensureStorageReady(boolean createIfMissing) + { + String configuredPath = config.getPlaylistsFolder(); + if(configuredPath == null || configuredPath.trim().isEmpty()) + { + return rememberError(PlaylistErrorType.INVALID_CONFIG, "Playlists folder is not configured.", configuredPath, null); + } + + Path folderPath; + try + { + folderPath = OtherUtil.getPath(configuredPath).toAbsolutePath().normalize(); + } + catch(Exception ex) + { + return rememberError(PlaylistErrorType.INVALID_CONFIG, + "Playlists folder path is invalid: " + ex.getMessage(), configuredPath, ex); + } + + try + { + if(createIfMissing) + { + Files.createDirectories(folderPath); + } + else if(!Files.exists(folderPath)) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Playlists directory does not exist and could not be accessed.", configuredPath, null); + } + + if(!Files.isDirectory(folderPath)) + { + return rememberError(PlaylistErrorType.INVALID_CONFIG, + "Configured playlists path is not a directory.", configuredPath, null); + } + if(!Files.isReadable(folderPath) || !Files.isWritable(folderPath)) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Playlists directory is not readable and writable.", configuredPath, null); + } + + lastStorageError = null; + return PlaylistResult.success(folderPath); + } + catch(IOException ex) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Failed to access playlists directory: " + ex.getMessage(), configuredPath, ex); + } + } + + public PlaylistResult<List<String>> getPlaylistNamesResult() + { + PlaylistResult<Path> readiness = ensureStorageReady(); + if(!readiness.isSuccess()) + return PlaylistResult.failure(readiness.getError()); + + File folder = readiness.getValue().toFile(); + File[] files = folder.listFiles((pathname) -> pathname.getName().endsWith(".txt")); + if(files == null) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Failed to list playlists directory contents.", config.getPlaylistsFolder(), null); + } + + List<String> favoriteNames = new ArrayList<>(1); + List<String> otherNames = new ArrayList<>(files.length); + for (File file : files) + { + String name = file.getName().substring(0, file.getName().length() - 4); + if (name.equalsIgnoreCase(FAVORITES_PLAYLIST_NAME)) + { + favoriteNames.add(name); + } + else + { + otherNames.add(name); + } + } + + List<String> orderedNames = new ArrayList<>(favoriteNames.size() + otherNames.size()); + orderedNames.addAll(favoriteNames); + orderedNames.addAll(otherNames); + return PlaylistResult.success(orderedNames); + } + + public List<String> getPlaylistNames() + { + PlaylistResult<List<String>> result = getPlaylistNamesResult(); + return result.isSuccess() ? result.getValue() : null; + } + + public void createFolder() + { + ensureStorageReady(); + } + + public boolean folderExists() + { + String path = config.getPlaylistsFolder(); + if(path == null || path.trim().isEmpty()) + return false; + try + { + return Files.isDirectory(OtherUtil.getPath(path)); + } + catch(Exception ex) + { + return false; + } + } + + public void createPlaylist(String name) throws IOException + { + PlaylistResult<Void> result = createPlaylistResult(name); + if(!result.isSuccess()) + throw toIOException(result.getError()); + } + + public void deletePlaylist(String name) throws IOException + { + PlaylistResult<Void> result = deletePlaylistResult(name); + if(!result.isSuccess()) + throw toIOException(result.getError()); + } + + public void writePlaylist(String name, String text) throws IOException + { + PlaylistResult<Void> result = writePlaylistResult(name, text); + if(!result.isSuccess()) + throw toIOException(result.getError()); + } + + public PlaylistResult<Void> createPlaylistResult(String name) + { + PlaylistResult<Path> readiness = ensureStorageReady(); + if(!readiness.isSuccess()) + return PlaylistResult.failure(readiness.getError()); + try + { + Files.createFile(playlistPath(readiness.getValue(), name)); + invalidateFavoritesCacheIfNeeded(name); + return PlaylistResult.success(null); + } + catch(IOException ex) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Failed to create playlist `" + name + "`: " + ex.getMessage(), config.getPlaylistsFolder(), ex); + } + } + + public PlaylistResult<Void> deletePlaylistResult(String name) + { + PlaylistResult<Path> readiness = ensureStorageReady(); + if(!readiness.isSuccess()) + return PlaylistResult.failure(readiness.getError()); + Path path = playlistPath(readiness.getValue(), name); + if(!Files.exists(path)) + { + return PlaylistResult.failure(new PlaylistError(PlaylistErrorType.PLAYLIST_NOT_FOUND, + "Playlist `" + name + "` does not exist.", config.getPlaylistsFolder(), null)); + } + try + { + Files.delete(path); + invalidateFavoritesCacheIfNeeded(name); + return PlaylistResult.success(null); + } + catch(IOException ex) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Failed to delete playlist `" + name + "`: " + ex.getMessage(), config.getPlaylistsFolder(), ex); + } + } + + public PlaylistResult<Void> writePlaylistResult(String name, String text) + { + PlaylistResult<Path> readiness = ensureStorageReady(); + if(!readiness.isSuccess()) + return PlaylistResult.failure(readiness.getError()); + + Path path = playlistPath(readiness.getValue(), name); + if(!Files.exists(path)) + { + return PlaylistResult.failure(new PlaylistError(PlaylistErrorType.PLAYLIST_NOT_FOUND, + "Playlist `" + name + "` does not exist.", config.getPlaylistsFolder(), null)); + } + try + { + byte[] content = text.trim().getBytes(StandardCharsets.UTF_8); + Files.write(path, content); + refreshOrInvalidateFavoritesCacheAfterWrite(name, path, content); + return PlaylistResult.success(null); + } + catch(IOException ex) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Failed to write playlist `" + name + "`: " + ex.getMessage(), config.getPlaylistsFolder(), ex); + } + } + + public Playlist getPlaylist(String name) + { + PlaylistResult<Playlist> result = getPlaylistResult(name); + return result.isSuccess() ? result.getValue() : null; + } + + public PlaylistResult<Playlist> getPlaylistResult(String name) + { + PlaylistResult<Path> readiness = ensureStorageReady(); + if(!readiness.isSuccess()) + return PlaylistResult.failure(readiness.getError()); + Path path = playlistPath(readiness.getValue(), name); + if(!Files.exists(path)) + { + return PlaylistResult.failure(new PlaylistError(PlaylistErrorType.PLAYLIST_NOT_FOUND, + "Playlist `" + name + "` does not exist.", config.getPlaylistsFolder(), null)); + } + try + { + ParsedPlaylist parsed = parsePlaylistLines(Files.readAllLines(path), true); + List<String> list = parsed.items; + boolean shuffle = parsed.shuffle; + if(shuffle) + shuffle(list); + return PlaylistResult.success(new Playlist(name, list, shuffle)); + } + catch(IOException e) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Failed to read playlist `" + name + "`: " + e.getMessage(), config.getPlaylistsFolder(), e); + } + } + + public PlaylistResult<AppendIfAbsentResult> appendItemIfAbsentResult(String name, String item) + { + PlaylistResult<Path> readiness = ensureStorageReady(); + if(!readiness.isSuccess()) + return PlaylistResult.failure(readiness.getError()); + + String normalizedItem = item == null ? "" : item.trim(); + if(normalizedItem.isEmpty()) + { + return rememberError(PlaylistErrorType.INVALID_CONFIG, + "Cannot append an empty playlist item.", config.getPlaylistsFolder(), null); + } + + Path path = playlistPath(readiness.getValue(), name); + boolean favoritesPlaylist = isFavoritesPlaylist(name); + try + { + if(favoritesPlaylist && isFavoritesCacheHit(path, normalizedItem)) + { + LOG.debug("Favorites append fast-path duplicate hit (pre-lock): entry={}", normalizedItem); + return PlaylistResult.success(AppendIfAbsentResult.alreadyPresent()); + } + + Set<String> itemsAfterOperation; + AppendIfAbsentResult operationResult; + Path lockKey = path.toAbsolutePath().normalize(); + Object inProcessLock = inProcessFileLocks.computeIfAbsent(lockKey, ignored -> new Object()); + synchronized(inProcessLock) + { + try(FileChannel channel = FileChannel.open(path, + StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE); + FileLock lock = channel.lock()) + { + if(!lock.isValid()) + { + throw new IOException("Failed to acquire a valid lock for playlist file."); + } + + CacheAppendResult cacheAppendResult = favoritesPlaylist + ? tryAppendWithFreshFavoritesCache(path, channel, normalizedItem) + : null; + if(cacheAppendResult != null) + { + LOG.debug("Favorites append lock-path used warm cache: result={}, entry={}", + cacheAppendResult.result.getStatus(), normalizedItem); + itemsAfterOperation = cacheAppendResult.entries; + operationResult = cacheAppendResult.result; + } + else + { + if(favoritesPlaylist) + { + LOG.debug("Favorites append cache unavailable/stale under lock; reading full file for authoritative check."); + } + Set<String> entries = readPlaylistItemsFromChannel(channel); + if(entries.contains(normalizedItem)) + { + itemsAfterOperation = entries; + operationResult = AppendIfAbsentResult.alreadyPresent(); + } + else + { + appendLine(channel, normalizedItem); + entries.add(normalizedItem); + itemsAfterOperation = entries; + operationResult = AppendIfAbsentResult.appended(); + } + } + } + } + if(favoritesPlaylist) + refreshFavoritesCache(path, itemsAfterOperation); + return PlaylistResult.success(operationResult); + } + catch(IOException ex) + { + return rememberError(PlaylistErrorType.STORAGE_UNAVAILABLE, + "Failed to append item to playlist `" + name + "`: " + ex.getMessage(), + config.getPlaylistsFolder(), ex); + } + } + + public Optional<PlaylistError> getLastStorageError() + { + return Optional.ofNullable(lastStorageError); + } + + private <T> PlaylistResult<T> rememberError(PlaylistErrorType type, String message, String configuredPath, Throwable cause) + { + PlaylistError error = PlaylistError.of(type, message, configuredPath, cause); + if(type != PlaylistErrorType.PLAYLIST_NOT_FOUND) + { + lastStorageError = error; + if(cause == null) + LOG.warn("Playlist storage issue ({}): path={}, message={}", type, configuredPath, message); + else + LOG.warn("Playlist storage issue ({}): path={}, message={}, cause={}", + type, configuredPath, message, cause.getMessage()); + } + return PlaylistResult.failure(error); + } + + private Path playlistPath(Path folder, String name) + { + return folder.resolve(name + ".txt"); + } + + private void appendLine(FileChannel channel, String value) throws IOException + { + long size = channel.size(); + channel.position(size); + if(size > 0) + { + ByteBuffer last = ByteBuffer.allocate(1); + channel.position(size - 1); + channel.read(last); + last.flip(); + byte lastByte = last.get(); + channel.position(size); + if(lastByte == CR) + channel.write(ByteBuffer.wrap(new byte[]{LF})); + else if(lastByte != LF) + channel.write(ByteBuffer.wrap(new byte[]{CR, LF})); + } + channel.write(ByteBuffer.wrap(value.getBytes(StandardCharsets.UTF_8))); + } + + private Set<String> readPlaylistItemsFromChannel(FileChannel channel) throws IOException + { + long size = channel.size(); + if(size <= 0) + return new LinkedHashSet<>(); + if(size > Integer.MAX_VALUE) + { + throw new IOException("Playlist file is too large to process in memory: " + size + " bytes"); + } + + channel.position(0); + ByteBuffer bytes = ByteBuffer.allocate((int) size); + while(bytes.hasRemaining() && channel.read(bytes) != -1) + { + // read until full buffer + } + bytes.flip(); + String content = StandardCharsets.UTF_8.decode(bytes).toString(); + List<String> lines = Arrays.asList(content.split("\\R")); + ParsedPlaylist parsed = parsePlaylistLines(lines, false); + return new LinkedHashSet<>(parsed.items); + } + + private CacheAppendResult tryAppendWithFreshFavoritesCache(Path path, FileChannel channel, String item) throws IOException + { + FavoritesCache cacheSnapshot = favoritesCache; + if(cacheSnapshot == null) + { + LOG.debug("Favorites append lock-path cache miss: no cache snapshot."); + return null; + } + + FileSignature currentSignature = FileSignature.of(path); + if(!cacheSnapshot.signature.equals(currentSignature)) + { + LOG.debug("Favorites append lock-path cache stale: signature mismatch."); + return null; + } + + Set<String> entries = new LinkedHashSet<>(cacheSnapshot.entries); + if(entries.contains(item)) + { + return new CacheAppendResult(entries, AppendIfAbsentResult.alreadyPresent()); + } + + appendLine(channel, item); + entries.add(item); + return new CacheAppendResult(entries, AppendIfAbsentResult.appended()); + } + + private boolean isFavoritesCacheHit(Path favoritesPath, String item) throws IOException + { + FavoritesCache cacheSnapshot = favoritesCache; + if(cacheSnapshot == null) + return false; + + FileSignature currentSignature = FileSignature.of(favoritesPath); + if(!cacheSnapshot.signature.equals(currentSignature)) + return false; + return cacheSnapshot.entries.contains(item); + } + + private ParsedPlaylist parsePlaylistLines(List<String> rawLines, boolean includeShuffleDirective) + { + boolean shuffle = false; + List<String> items = new ArrayList<>(); + for(String line : rawLines) + { + String value = line.trim(); + if(value.isEmpty()) + continue; + if(value.startsWith("#") || value.startsWith("//")) + { + if(includeShuffleDirective) + { + String compact = value.replaceAll("\\s+", ""); + if(compact.equalsIgnoreCase("#shuffle") || compact.equalsIgnoreCase("//shuffle")) + shuffle = true; + } + continue; + } + items.add(value); + } + return new ParsedPlaylist(items, shuffle); + } + + private void refreshFavoritesCache(Path path, Set<String> entries) throws IOException + { + if(!Files.exists(path)) + { + favoritesCache = new FavoritesCache(FileSignature.missing(), Collections.emptySet()); + LOG.debug("Favorites cache refreshed to empty snapshot because favorites file is missing."); + return; + } + FileSignature signature = FileSignature.of(path); + Set<String> snapshot = Collections.unmodifiableSet(new LinkedHashSet<>(entries)); + favoritesCache = new FavoritesCache(signature, snapshot); + LOG.debug("Favorites cache refreshed: entries={}, size={}, lastModifiedMillis={}", + snapshot.size(), signature.size, signature.lastModifiedMillis); + } + + private void refreshOrInvalidateFavoritesCacheAfterWrite(String playlistName, Path path, byte[] content) throws IOException + { + if(!isFavoritesPlaylist(playlistName)) + return; + if(content.length == 0) + { + favoritesCache = new FavoritesCache(FileSignature.of(path), Collections.emptySet()); + return; + } + + List<String> lines = Arrays.asList(new String(content, StandardCharsets.UTF_8).split("\\R")); + ParsedPlaylist parsed = parsePlaylistLines(lines, false); + refreshFavoritesCache(path, new LinkedHashSet<>(parsed.items)); + } + + private void invalidateFavoritesCacheIfNeeded(String playlistName) + { + if(isFavoritesPlaylist(playlistName)) + { + favoritesCache = null; + LOG.debug("Favorites cache invalidated due to playlist structural change: {}", playlistName); + } + } + + private boolean isFavoritesPlaylist(String name) + { + return FAVORITES_PLAYLIST_NAME.equalsIgnoreCase(name); + } + + private static final class ParsedPlaylist + { + private final List<String> items; + private final boolean shuffle; + + private ParsedPlaylist(List<String> items, boolean shuffle) + { + this.items = items; + this.shuffle = shuffle; + } + } + + private static final class CacheAppendResult + { + private final Set<String> entries; + private final AppendIfAbsentResult result; + + private CacheAppendResult(Set<String> entries, AppendIfAbsentResult result) + { + this.entries = entries; + this.result = result; + } + } + + private IOException toIOException(PlaylistError error) + { + if(error.getCause() instanceof IOException ioEx) + return ioEx; + return new IOException(error.getMessage(), error.getCause()); + } + + + private static <T> void shuffle(List<T> list) + { + for(int first =0; first<list.size(); first++) + { + int second = (int)(Math.random()*list.size()); + T tmp = list.get(first); + list.set(first, list.get(second)); + list.set(second, tmp); + } + } + + + public class Playlist + { + private final String name; + private final List<String> items; + private final boolean shuffle; + private final List<AudioTrack> tracks = new LinkedList<>(); + private final List<PlaylistLoadError> errors = new LinkedList<>(); + private boolean loaded = false; + + private Playlist(String name, List<String> items, boolean shuffle) + { + this.name = name; + this.items = items; + this.shuffle = shuffle; + } + + public void loadTracks(AudioPlayerManager manager, Consumer<AudioTrack> consumer, Runnable callback) + { + if(loaded) + return; + loaded = true; + if(items.isEmpty()) + { + if(callback != null) + callback.run(); + return; + } + AtomicInteger pendingItems = new AtomicInteger(items.size()); + for(int i=0; i<items.size(); i++) + { + int index = i; + manager.loadItemOrdered(name, items.get(i), new AudioLoadResultHandler() + { + private void done() + { + if(pendingItems.decrementAndGet() == 0) + { + if(shuffle) + shuffleTracks(); + if(callback != null) + callback.run(); + } + } + + @Override + public void trackLoaded(AudioTrack at) + { + if(config.isTooLong(at)) + errors.add(new PlaylistLoadError(index, items.get(index), "This track is longer than the allowed maximum")); + else + { + at.setUserData(0L); + tracks.add(at); + consumer.accept(at); + } + done(); + } + + @Override + public void playlistLoaded(AudioPlaylist ap) + { + if(ap.isSearchResult()) + { + trackLoaded(ap.getTracks().get(0)); + } + else if(ap.getSelectedTrack()!=null) + { + trackLoaded(ap.getSelectedTrack()); + } + else + { + List<AudioTrack> loaded = new ArrayList<>(ap.getTracks()); + if(shuffle) + for(int first =0; first<loaded.size(); first++) + { + int second = (int)(Math.random()*loaded.size()); + AudioTrack tmp = loaded.get(first); + loaded.set(first, loaded.get(second)); + loaded.set(second, tmp); + } + loaded.removeIf(track -> config.isTooLong(track)); + loaded.forEach(at -> at.setUserData(0L)); + tracks.addAll(loaded); + loaded.forEach(at -> consumer.accept(at)); + } + done(); + } + + @Override + public void noMatches() + { + errors.add(new PlaylistLoadError(index, items.get(index), "No matches found.")); + done(); + } + + @Override + public void loadFailed(FriendlyException fe) + { + errors.add(new PlaylistLoadError(index, items.get(index), "Failed to load track: "+fe.getLocalizedMessage())); + done(); + } + }); + } + } + + public void shuffleTracks() + { + shuffle(tracks); + } + + public String getName() + { + return name; + } + + public List<String> getItems() + { + return items; + } + + public List<AudioTrack> getTracks() + { + return tracks; + } + + public List<PlaylistLoadError> getErrors() + { + return errors; + } + } + + public class PlaylistLoadError + { + private final int number; + private final String item; + private final String reason; + + private PlaylistLoadError(int number, String item, String reason) + { + this.number = number; + this.item = item; + this.reason = reason; + } + + public int getIndex() + { + return number; + } + + public String getItem() + { + return item; + } + + public String getReason() + { + return reason; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java b/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java new file mode 100644 index 0000000..9d5f2bf --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java @@ -0,0 +1,220 @@ +/* + * Copyright 2022 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.queue; + +import com.jagrosh.jmusicbot.audio.QueuedTrack; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/** + * + * @author Wolfgang Schwendtbauer + * @param <T> + */ +public abstract class AbstractQueue<T extends Queueable> +{ + /** + * Creates a new queue, optionally copying state from an existing queue. + * + * @param queue The previous queue to copy state from, or null for initial creation + * @param maxHistorySize The maximum history size (only used when queue is null) + */ + protected AbstractQueue(AbstractQueue<T> queue, int maxHistorySize) + { + // Use ArrayList for O(1) random access (display, shuffle, etc.) + // Copy the list when switching queue types to avoid shared mutable state + this.list = queue != null ? new ArrayList<>(queue.getList()) : new ArrayList<>(); + // Reuse history when switching queue types; always apply configured max size so transitions + // (including maxHistorySize=0 disabled mode) take effect immediately. + this.history = queue != null ? queue.getHistory() : new PlaybackHistory<>(maxHistorySize, qt -> qt instanceof QueuedTrack ? ((QueuedTrack) qt).getTrack().getIdentifier() : qt.getIdentifier()); + this.history.setMaxSize(maxHistorySize); + } + + protected final List<T> list; + protected final PlaybackHistory<T> history; + + public abstract int add(T item); + + public void addAt(int index, T item) + { + if(index >= list.size()) + list.add(item); + else + list.add(index, item); + } + + public int size() { + return list.size(); + } + + public T pull() { + if (list.isEmpty()) + return null; + return list.remove(0); + } + + public void addToHistory(T item) + { + history.add(item); + } + + public void setMaxHistorySize(int size) + { + history.setMaxSize(size); + } + + /** + * Clears playback history entries. + */ + public void clearHistory() + { + history.clear(); + } + + public T removeLastPlayed() + { + return history.removeFirst(); + } + + /** + * Removes the history entry at the given index (0 = most recent). + * Used when a user replays a track from history so it is not retained. + * + * @param index The history index to remove + * @return The removed item, or null if index is out of range + */ + public T removeFromHistoryAt(int index) + { + return history.removeAt(index); + } + + /** + * Removes the first history entry that matches the predicate. + * Used when a track starts playing to avoid duplicate entries in history. + * + * @param predicate The condition to match (e.g. same track identifier) + * @return true if an entry was removed, false otherwise + */ + public boolean removeFromHistoryFirstMatch(Predicate<T> predicate) + { + return history.removeFirstMatching(predicate); + } + + /** + * Rewinds the queue by taking the last played item from history + * and optionally pushing the current item back to the front of the queue. + * @param current The currently playing item to push back to the queue + * @return The previous item to play, or null if history is empty + */ + public T rewind(T current) + { + T prev = history.removeFirst(); + if (prev != null && current != null) + { + list.add(0, current); + } + return prev; + } + + public boolean isEmpty() + { + return list.isEmpty(); + } + + public List<T> getList() + { + return list; + } + + public PlaybackHistory<T> getHistory() + { + return history; + } + + public T get(int index) { + return list.get(index); + } + + public T remove(int index) + { + return list.remove(index); + } + + public int removeAll(long identifier) + { + int count = 0; + for(int i=list.size()-1; i>=0; i--) + { + if(list.get(i).getIdentifier()==identifier) + { + list.remove(i); + count++; + } + } + return count; + } + + public void clear() + { + list.clear(); + } + + public void clearAll() + { + list.clear(); + history.clear(); + } + + public int shuffle(long identifier) + { + List<Integer> iset = new ArrayList<>(); + for(int i=0; i<list.size(); i++) + { + if(identifier == 0 || list.get(i).getIdentifier()==identifier) + iset.add(i); + } + for(int j=0; j<iset.size(); j++) + { + int first = iset.get(j); + int second = iset.get((int)(Math.random()*iset.size())); + T temp = list.get(first); + list.set(first, list.get(second)); + list.set(second, temp); + } + return iset.size(); + } + + public void skip(int number) + { + if (number > 0) { + list.subList(0, number).clear(); + } + } + + /** + * Move an item to a different position in the list + * @param from The position of the item + * @param to The new position of the item + * @return the moved item + */ + public T moveItem(int from, int to) + { + T item = list.remove(from); + list.add(to, item); + return item; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/FairQueue.java b/src/main/java/com/jagrosh/jmusicbot/queue/FairQueue.java new file mode 100644 index 0000000..ccdd109 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/queue/FairQueue.java @@ -0,0 +1,62 @@ +/* + * 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.queue; + +import java.util.HashSet; +import java.util.Set; + +/** + * + * @author John Grosh (jagrosh) + * @param <T> + */ +public class FairQueue<T extends Queueable> extends AbstractQueue<T> +{ + public FairQueue(AbstractQueue<T> queue, int maxHistorySize) + { + super(queue, maxHistorySize); + } + + /** + * Factory method for use with QueueSupplier. + */ + public static <T extends Queueable> FairQueue<T> create(AbstractQueue<T> queue, int maxHistorySize) + { + return new FairQueue<>(queue, maxHistorySize); + } + + protected final Set<Long> set = new HashSet<>(); + + @Override + public int add(T item) + { + int lastIndex; + for(lastIndex=list.size()-1; lastIndex>-1; lastIndex--) + if(list.get(lastIndex).getIdentifier() == item.getIdentifier()) + break; + lastIndex++; + set.clear(); + for(; lastIndex<list.size(); lastIndex++) + { + if(set.contains(list.get(lastIndex).getIdentifier())) + break; + set.add(list.get(lastIndex).getIdentifier()); + } + list.add(lastIndex, item); + return lastIndex; + } + +} diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/IndexedDeque.java b/src/main/java/com/jagrosh/jmusicbot/queue/IndexedDeque.java new file mode 100644 index 0000000..daa7d51 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/queue/IndexedDeque.java @@ -0,0 +1,275 @@ +package com.jagrosh.jmusicbot.queue; + +import java.util.AbstractCollection; +import java.util.Arrays; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; + +/** + * A deque backed by a circular buffer that supports O(1) access by index + * in addition to O(1) amortized add/remove at both ends. + * <p> + * Index 0 is the "first" (head) element, index size()-1 is the "last" (tail). + * Capacity grows by a factor of 2 when full; the backing array is never shrunk + * by this class. + * + * @param <T> element type + */ +public class IndexedDeque<T> extends AbstractCollection<T> { + + private static final int DEFAULT_INITIAL_CAPACITY = 16; + + private Object[] buffer; + private int head; // index of first element + private int size; + + public IndexedDeque() { + this(DEFAULT_INITIAL_CAPACITY); + } + + public IndexedDeque(int initialCapacity) { + if (initialCapacity < 1) { + throw new IllegalArgumentException("initialCapacity must be positive, got: " + initialCapacity); + } + this.buffer = new Object[initialCapacity]; + this.head = 0; + this.size = 0; + } + + /** + * Returns the element at the given index. Index 0 is the first (head) element. + * + * @param index index in range [0, size()) + * @return the element at that index + * @throws IndexOutOfBoundsException if index is out of range + */ + @SuppressWarnings("unchecked") + public T get(int index) { + checkIndex(index); + return (T) buffer[physicalIndex(index)]; + } + + /** + * Inserts the element at the front of the deque. + */ + public void addFirst(T e) { + Objects.requireNonNull(e, "element"); + ensureCapacity(size + 1); + head = decrement(head); + buffer[head] = e; + size++; + } + + /** + * Appends the element at the end of the deque. + */ + public void addLast(T e) { + Objects.requireNonNull(e, "element"); + ensureCapacity(size + 1); + buffer[physicalIndex(size)] = e; + size++; + } + + /** + * Removes and returns the first element. + * + * @throws NoSuchElementException if the deque is empty + */ + @SuppressWarnings("unchecked") + public T removeFirst() { + if (size == 0) { + throw new NoSuchElementException("deque is empty"); + } + T e = (T) buffer[head]; + buffer[head] = null; + head = increment(head); + size--; + return e; + } + + /** + * Removes and returns the first element, or null if the deque is empty. + */ + public T pollFirst() { + return size == 0 ? null : removeFirst(); + } + + /** + * Removes and returns the last element. + * + * @throws NoSuchElementException if the deque is empty + */ + @SuppressWarnings("unchecked") + public T removeLast() { + if (size == 0) { + throw new NoSuchElementException("deque is empty"); + } + int last = physicalIndex(size - 1); + T e = (T) buffer[last]; + buffer[last] = null; + size--; + return e; + } + + /** + * Removes and returns the last element, or null if the deque is empty. + */ + public T pollLast() { + return size == 0 ? null : removeLast(); + } + + /** + * Returns the first element without removing it. + */ + @SuppressWarnings("unchecked") + public T peekFirst() { + if (size == 0) { + return null; + } + return (T) buffer[head]; + } + + /** + * Returns the last element without removing it. + */ + @SuppressWarnings("unchecked") + public T peekLast() { + if (size == 0) { + return null; + } + return (T) buffer[physicalIndex(size - 1)]; + } + + /** + * Removes and returns the element at the given index. + * Shifts the smaller segment to minimize copy count (O(min(i, n-i))). + * + * @param index index in range [0, size()) + * @return the removed element + * @throws IndexOutOfBoundsException if index is out of range + */ + @SuppressWarnings("unchecked") + public T removeAt(int index) { + checkIndex(index); + int physical = physicalIndex(index); + T removed = (T) buffer[physical]; + + if (index < size / 2) { + // Shift [0..index-1] one slot toward the tail (right in logical order) + for (int i = index; i > 0; i--) { + int to = physicalIndex(i); + int from = physicalIndex(i - 1); + buffer[to] = buffer[from]; + } + buffer[head] = null; + head = increment(head); + } else { + // Shift [index+1..size-1] one slot toward the head (left in logical order) + for (int i = index; i < size - 1; i++) { + int to = physicalIndex(i); + int from = physicalIndex(i + 1); + buffer[to] = buffer[from]; + } + buffer[physicalIndex(size - 1)] = null; + } + size--; + return removed; + } + + /** + * Adds the element at the front (same as addFirst). + * Required by AbstractCollection for add() at the "beginning" of the collection view. + */ + @Override + public boolean add(T e) { + addFirst(e); + return true; + } + + @Override + public int size() { + return size; + } + + @Override + public void clear() { + if (head + size <= buffer.length) { + Arrays.fill(buffer, head, head + size, null); + } else { + Arrays.fill(buffer, head, buffer.length, null); + Arrays.fill(buffer, 0, head + size - buffer.length, null); + } + size = 0; + } + + @Override + public Iterator<T> iterator() { + return new IndexedDequeIterator(); + } + + private final class IndexedDequeIterator implements Iterator<T> { + private int cursor; + private int lastReturned = -1; + + IndexedDequeIterator() { + this.cursor = 0; + } + + @Override + public boolean hasNext() { + return cursor < size; + } + + @Override + @SuppressWarnings("unchecked") + public T next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + lastReturned = cursor; + return (T) buffer[physicalIndex(cursor++)]; + } + + @Override + public void remove() { + if (lastReturned < 0) { + throw new IllegalStateException("no element to remove"); + } + IndexedDeque.this.removeAt(lastReturned); + cursor = lastReturned; + lastReturned = -1; + } + } + + private int physicalIndex(int logicalIndex) { + return (head + logicalIndex) % buffer.length; + } + + private int increment(int i) { + return (i + 1) % buffer.length; + } + + private int decrement(int i) { + return (i - 1 + buffer.length) % buffer.length; + } + + private void checkIndex(int index) { + if (index < 0 || index >= size) { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); + } + } + + private void ensureCapacity(int minCapacity) { + if (minCapacity <= buffer.length) { + return; + } + int newCapacity = Math.max(buffer.length * 2, minCapacity); + Object[] newBuffer = new Object[newCapacity]; + for (int i = 0; i < size; i++) { + newBuffer[i] = buffer[physicalIndex(i)]; + } + buffer = newBuffer; + head = 0; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/LinearQueue.java b/src/main/java/com/jagrosh/jmusicbot/queue/LinearQueue.java new file mode 100644 index 0000000..d55dc29 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/queue/LinearQueue.java @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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.queue; + +/** + * + * @author Wolfgang Schwendtbauer + * @param <T> + */ +public class LinearQueue<T extends Queueable> extends AbstractQueue<T> +{ + public LinearQueue(AbstractQueue<T> queue, int maxHistorySize) + { + super(queue, maxHistorySize); + } + + /** + * Factory method for use with QueueSupplier. + */ + public static <T extends Queueable> LinearQueue<T> create(AbstractQueue<T> queue, int maxHistorySize) + { + return new LinearQueue<>(queue, maxHistorySize); + } + + @Override + public int add(T item) + { + list.add(item); + return list.size() - 1; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/PlaybackHistory.java b/src/main/java/com/jagrosh/jmusicbot/queue/PlaybackHistory.java new file mode 100644 index 0000000..bf488ce --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/queue/PlaybackHistory.java @@ -0,0 +1,237 @@ +package com.jagrosh.jmusicbot.queue; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * A bounded LIFO buffer for tracking playback history. + * Most recently played tracks are stored at the front (index 0) and are the first to be removed. + * <p> + * Backed by an {@link IndexedDeque} for O(1) add/remove at both ends and O(1) access by index. + * When a key extractor is provided, at most one entry per key is kept; adding an item with an + * existing key removes the old entry first (de-dup at add time). + * + * @author Arif Banai + * @param <T> The type of items to store in history + */ +public class PlaybackHistory<T> { + private final IndexedDeque<T> history; + private final Function<T, Object> keyExtractor; + private final Set<Object> keySet; + private int maxSize; + + /** + * Creates a new PlaybackHistory with the specified maximum size and no key-based de-dup. + * + * @param maxSize The maximum number of items to keep in history (must be >= 0; 0 disables history storage) + * @throws IllegalArgumentException if maxSize is negative + */ + public PlaybackHistory(int maxSize) { + this(maxSize, null); + } + + /** + * Creates a new PlaybackHistory with the specified maximum size and optional key extractor. + * When keyExtractor is non-null, adding an item will first remove any existing entry with + * the same key, so at most one entry per key is kept (most recent at index 0). + * + * @param maxSize The maximum number of items to keep in history (must be >= 0; 0 disables history storage) + * @param keyExtractor Extracts the key for de-dup; null to disable key-based de-dup + * @throws IllegalArgumentException if maxSize is negative + */ + public PlaybackHistory(int maxSize, Function<T, Object> keyExtractor) { + if (maxSize < 0) { + throw new IllegalArgumentException("Max size must be non-negative, got: " + maxSize); + } + this.maxSize = maxSize; + this.history = new IndexedDeque<>(Math.max(1, maxSize)); + this.keyExtractor = keyExtractor; + this.keySet = keyExtractor != null ? new HashSet<>() : null; + } + + /** + * Adds an item to the history. The item is added at the front (most recent). + * When a key extractor is set, any existing entry with the same key is removed first (de-dup). + * If the history is at max size, the oldest item is removed first to avoid resize. + * + * @param item The item to add to history + * @throws NullPointerException if item is null + */ + public void add(T item) { + Objects.requireNonNull(item, "item"); + if (maxSize == 0) { + return; + } + if (keyExtractor != null) { + removeByKey(keyExtractor.apply(item)); + } + if (history.size() >= maxSize) { + T removed = history.removeLast(); + if (keySet != null && removed != null) { + keySet.remove(keyExtractor.apply(removed)); + } + } + history.addFirst(item); + if (keySet != null) { + keySet.add(keyExtractor.apply(item)); + } + } + + /** + * Removes and returns the most recently added item (from the front). + * This is used for rewinding to previous tracks. + * + * @return The most recently added item, or null if history is empty + */ + public T removeFirst() { + T removed = history.pollFirst(); + if (removed != null && keySet != null) { + keySet.remove(keyExtractor.apply(removed)); + } + return removed; + } + + /** + * Sets the maximum number of items to keep in history. + * If the current history size exceeds the new max size, oldest items are removed. + * + * @param size The maximum number of items to keep (must be >= 0; 0 disables history storage) + * @throws IllegalArgumentException if size is negative + */ + public void setMaxSize(int size) { + if (size < 0) { + throw new IllegalArgumentException("Max size must be non-negative, got: " + size); + } + this.maxSize = size; + while (history.size() > maxSize) { + T removed = history.removeLast(); + if (keySet != null && removed != null) { + keySet.remove(keyExtractor.apply(removed)); + } + } + } + + /** + * Gets the maximum number of items this history can hold. + * + * @return The maximum size + */ + public int getMaxSize() { + return maxSize; + } + + /** + * Clears all items from the history. + */ + public void clear() { + history.clear(); + if (keySet != null) { + keySet.clear(); + } + } + + /** + * Gets the current number of items in history. + * + * @return The current size + */ + public int size() { + return history.size(); + } + + /** + * Checks if the history is empty. + * + * @return true if history is empty, false otherwise + */ + public boolean isEmpty() { + return history.isEmpty(); + } + + /** + * Gets an unmodifiable view of the history list. + * Most recent items are at index 0, oldest at the end. + * + * @return An unmodifiable list of history items + */ + public List<T> getList() { + return List.copyOf(history); + } + + /** + * Gets the item at the specified index. + * Index 0 is the most recent item. O(1). + * + * @param index The index (0 = most recent) + * @return The item at that index + * @throws IndexOutOfBoundsException if index is out of range + */ + public T get(int index) { + return history.get(index); + } + + /** + * Removes the item at the specified index (0 = most recent). + * Used when replaying a track from history so it is not retained in history. + * + * @param index The index to remove (0 = most recent) + * @return The removed item, or null if index is out of range + */ + public T removeAt(int index) { + if (index < 0 || index >= history.size()) { + return null; + } + T removed = history.removeAt(index); + if (removed != null && keySet != null) { + keySet.remove(keyExtractor.apply(removed)); + } + return removed; + } + + /** + * Removes the first history entry whose key equals the given key. + * No-op when no key extractor is set or when the key is not present. O(n) when key is present. + * + * @param key The key to remove (e.g. track identifier) + * @return true if an entry was removed, false otherwise + */ + public boolean removeByKey(Object key) { + if (keySet == null || !keySet.contains(key)) { + return false; + } + for (int i = 0; i < history.size(); i++) { + if (Objects.equals(key, keyExtractor.apply(history.get(i)))) { + history.removeAt(i); + keySet.remove(key); + return true; + } + } + return false; + } + + /** + * Removes the first item that matches the given predicate. + * Used to remove a track from history when it starts playing (de-dup). + * + * @param predicate The condition to match (e.g. same track identifier/URI) + * @return true if an item was removed, false otherwise + */ + public boolean removeFirstMatching(Predicate<T> predicate) { + if (predicate == null) { + return false; + } + Iterator<T> iterator = history.iterator(); + while (iterator.hasNext()) { + if (predicate.test(iterator.next())) { + iterator.remove(); + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/QueueSupplier.java b/src/main/java/com/jagrosh/jmusicbot/queue/QueueSupplier.java new file mode 100644 index 0000000..0930a9c --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/queue/QueueSupplier.java @@ -0,0 +1,34 @@ +/* + * Copyright 2022 John Grosh <john.a.grosh@gmail.com>. + * + * 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.queue; + +/** + * + * @author Wolfgang Schwendtbauer + */ +@FunctionalInterface +public interface QueueSupplier +{ + /** + * Creates a new queue instance. + * + * @param queue The previous queue to copy state from, or null for initial creation + * @param maxHistorySize The maximum history size (only used when queue is null) + * @param <T> The type of items in the queue + * @return A new queue instance + */ + <T extends Queueable> AbstractQueue<T> apply(AbstractQueue<T> queue, int maxHistorySize); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/Queueable.java b/src/main/java/com/jagrosh/jmusicbot/queue/Queueable.java new file mode 100644 index 0000000..b35dfd1 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/queue/Queueable.java @@ -0,0 +1,25 @@ +/* + * Copyright 2016 John Grosh <john.a.grosh@gmail.com>. + * + * 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.queue; + +/** + * + * @author John Grosh <john.a.grosh@gmail.com> + */ +public interface Queueable { + + public long getIdentifier(); +} diff --git a/src/main/java/com/jagrosh/jmusicbot/service/AudioLoadResultHandlers.java b/src/main/java/com/jagrosh/jmusicbot/service/AudioLoadResultHandlers.java new file mode 100644 index 0000000..5044f62 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/service/AudioLoadResultHandlers.java @@ -0,0 +1,353 @@ +/* + * 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.service; + +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.utils.FormatUtil; +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.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.utils.messages.MessageEditBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +/** + * Audio load result handlers for processing track/playlist loading results. + * These handlers are used by MusicService for play and playNext operations. + */ +public final class AudioLoadResultHandlers +{ + private static final Logger LOG = LoggerFactory.getLogger(AudioLoadResultHandlers.class); + + private AudioLoadResultHandlers() + { + // Utility class - prevent instantiation + } + + /** + * Base class for audio load result handlers with shared fields and common logic. + */ + public static abstract class BaseResultHandler implements AudioLoadResultHandler + { + protected final MusicService musicService; + protected final Bot bot; + protected final MusicService.OutputAdapter output; + protected final Guild guild; + protected final Member member; + protected final String args; + protected final boolean ytsearch; + protected final TextChannel channel; + + protected BaseResultHandler(MusicService musicService, Bot bot, MusicService.OutputAdapter output, + Guild guild, Member member, String args, boolean ytsearch, TextChannel channel) + { + this.musicService = musicService; + this.bot = bot; + this.output = output; + this.guild = guild; + this.member = member; + this.args = args; + this.ytsearch = ytsearch; + this.channel = channel; + } + + /** + * Creates a fallback handler for YouTube search when no direct match is found. + */ + protected abstract BaseResultHandler createFallbackHandler(); + + /** + * Returns a descriptive name for logging (e.g., "Track" or "PlayNext"). + */ + protected abstract String getHandlerName(); + + @Override + public void noMatches() + { + if (ytsearch) + { + LOG.debug("{} no matches found: guild={}, query=\"{}\"", getHandlerName(), guild.getId(), args); + output.editMessage(FormatUtil.filter(bot.getConfig().getWarning() + " No results found for `" + args + "`.")); + } + else + { + LOG.debug("{} falling back to YouTube search: guild={}, query=\"{}\"", + getHandlerName(), guild.getId(), args); + bot.getPlayerManager().loadItemOrdered(guild, "ytsearch:" + args, + bot.getAudioLoadWrapper().wrap("ytsearch:" + args, createFallbackHandler())); + } + } + + @Override + public void loadFailed(FriendlyException throwable) + { + if (throwable.severity == Severity.COMMON) + { + LOG.warn("{} load failed (common): guild={}, query=\"{}\", error={}", + getHandlerName(), guild.getId(), args, throwable.getMessage()); + output.editMessage(bot.getConfig().getError() + " Error loading: " + throwable.getMessage()); + } + else + { + LOG.error("{} load failed (severe): guild={}, query=\"{}\"", + getHandlerName(), guild.getId(), args, throwable); + output.editMessage(bot.getConfig().getError() + " Error loading track."); + } + } + } + + /** + * Result handler for standard play command that adds tracks to the end of the queue. + */ + public static class PlayResultHandler extends BaseResultHandler + { + private static final String LOAD = "\uD83D\uDCE5"; // 📥 + private static final String CANCEL = "\uD83D\uDEAB"; // 🚫 + + public PlayResultHandler(MusicService musicService, Bot bot, MusicService.OutputAdapter output, + Guild guild, Member member, String args, boolean ytsearch, TextChannel channel) + { + super(musicService, bot, output, guild, member, args, ytsearch, channel); + } + + @Override + protected BaseResultHandler createFallbackHandler() + { + return new PlayResultHandler(musicService, bot, output, guild, member, args, true, channel); + } + + @Override + protected String getHandlerName() + { + return "Track"; + } + + private void loadSingle(AudioTrack track, AudioPlaylist playlist) + { + MusicService.TrackAddResult result = musicService.addTrackToQueue(guild, member, track, args, channel); + if (result == null) + { + output.editMessage(FormatUtil.filter(bot.getConfig().getWarning() + " " + musicService.formatTooLongError(track))); + return; + } + + String addMsg = FormatUtil.filter(bot.getConfig().getSuccess() + " " + result.formattedMessage); + if (playlist == null || !guild.getSelfMember().hasPermission(channel, Permission.MESSAGE_ADD_REACTION)) + { + output.editMessage(addMsg); + } + else + { + promptForPlaylistLoad(track, playlist, addMsg); + } + } + + private void promptForPlaylistLoad(AudioTrack track, AudioPlaylist playlist, String addMsg) + { + String promptMsg = addMsg + "\n" + bot.getConfig().getWarning() + " This track has a playlist of **" + + playlist.getTracks().size() + "** tracks attached. Select " + LOAD + " to load playlist."; + + MessageEditBuilder editBuilder = new MessageEditBuilder() + .setContent(promptMsg) + .setComponents(ActionRow.of( + Button.success("load_playlist", Emoji.fromUnicode(LOAD)).withLabel("Load Playlist"), + Button.danger("cancel_playlist", Emoji.fromUnicode(CANCEL)).withLabel("Cancel") + )); + + output.editMessage(addMsg, m -> { + m.editMessage(editBuilder.build()).queue(msg -> { + bot.getWaiter().waitForEvent(ButtonInteractionEvent.class, + event -> event.getMessageId().equals(msg.getId()) && + (event.getComponentId().equals("load_playlist") || event.getComponentId().equals("cancel_playlist")) && + event.getUser().getIdLong() == member.getIdLong(), + event -> { + if (event.getComponentId().equals("load_playlist")) + { + int loaded = loadPlaylist(playlist, track); + event.editMessage(addMsg + "\n" + bot.getConfig().getSuccess() + " Loaded **" + loaded + "** additional tracks!").setComponents().queue(); + } + else + { + event.editMessage(addMsg).setComponents().queue(); + } + }, + 30, TimeUnit.SECONDS, + () -> msg.editMessage(addMsg).setComponents().queue()); + }); + }); + } + + private int loadPlaylist(AudioPlaylist playlist, AudioTrack exclude) + { + int[] count = {0}; + AudioHandler handler = (AudioHandler) guild.getAudioManager().getSendingHandler(); + playlist.getTracks().forEach((track) -> { + if (!musicService.isTooLong(track) && !track.equals(exclude)) + { + handler.setLastReason(member.getUser().getName() + " added a playlist."); + handler.addTrack(new QueuedTrack(track, + new RequestMetadata(member.getUser(), + new RequestMetadata.RequestInfo(args, track.getInfo().uri), + channel.getIdLong()))); + count[0]++; + } + }); + return count[0]; + } + + @Override + public void trackLoaded(AudioTrack track) + { + LOG.debug("Track loaded: guild={}, track=\"{}\"", guild.getId(), track.getInfo().title); + loadSingle(track, null); + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) + { + LOG.debug("Playlist loaded: guild={}, name=\"{}\", tracks={}", + guild.getId(), playlist.getName(), playlist.getTracks().size()); + + if (playlist.getTracks().size() == 1 || playlist.isSearchResult()) + { + AudioTrack single = playlist.getSelectedTrack() == null ? playlist.getTracks().get(0) : playlist.getSelectedTrack(); + loadSingle(single, null); + } + else if (playlist.getSelectedTrack() != null) + { + AudioTrack single = playlist.getSelectedTrack(); + loadSingle(single, playlist); + } + else + { + int count = loadPlaylist(playlist, null); + handlePlaylistLoadResult(playlist, count); + } + } + + private void handlePlaylistLoadResult(AudioPlaylist playlist, int count) + { + if (playlist.getTracks().size() == 0) + { + LOG.warn("Playlist empty or could not be loaded: guild={}, name=\"{}\"", + guild.getId(), playlist.getName()); + output.editMessage(FormatUtil.filter(bot.getConfig().getWarning() + " The playlist " + + (playlist.getName() == null ? "" : "(**" + playlist.getName() + "**) ") + + " could not be loaded or contained 0 entries")); + } + else if (count == 0) + { + LOG.warn("All playlist tracks too long: guild={}, name=\"{}\"", + guild.getId(), playlist.getName()); + output.editMessage(FormatUtil.filter(bot.getConfig().getWarning() + " All entries in this playlist " + + (playlist.getName() == null ? "" : "(**" + playlist.getName() + "**) ") + + "were longer than the allowed maximum (`" + bot.getConfig().getMaxTime() + "`)")); + } + else + { + LOG.info("Playlist added to queue: guild={}, name=\"{}\", tracksAdded={}/{}", + guild.getId(), playlist.getName(), count, playlist.getTracks().size()); + output.editMessage(FormatUtil.filter(bot.getConfig().getSuccess() + " Found " + + (playlist.getName() == null ? "a playlist" : "playlist **" + playlist.getName() + "**") + " with `" + + playlist.getTracks().size() + "` entries; added to the queue!" + + (count < playlist.getTracks().size() ? "\n" + bot.getConfig().getWarning() + + " Tracks longer than the allowed maximum (`" + bot.getConfig().getMaxTime() + "`) have been omitted." : ""))); + } + } + } + + /** + * Result handler for playNext command that adds tracks to the front of the queue. + */ + public static class PlayNextResultHandler extends BaseResultHandler + { + public PlayNextResultHandler(MusicService musicService, Bot bot, MusicService.OutputAdapter output, + Guild guild, Member member, String args, boolean ytsearch, TextChannel channel) + { + super(musicService, bot, output, guild, member, args, ytsearch, channel); + } + + @Override + protected BaseResultHandler createFallbackHandler() + { + return new PlayNextResultHandler(musicService, bot, output, guild, member, args, true, channel); + } + + @Override + protected String getHandlerName() + { + return "PlayNext"; + } + + private void loadSingle(AudioTrack track) + { + LOG.debug("PlayNext loading track: guild={}, track=\"{}\"", guild.getId(), track.getInfo().title); + + MusicService.TrackAddResult result = musicService.addTrackToFront(guild, member, track, args, channel); + if (result == null) + { + output.editMessage(FormatUtil.filter(bot.getConfig().getWarning() + " " + musicService.formatTooLongError(track))); + return; + } + + output.editMessage(FormatUtil.filter(bot.getConfig().getSuccess() + " " + result.formattedMessage)); + } + + @Override + public void trackLoaded(AudioTrack track) + { + LOG.debug("PlayNext track loaded: guild={}, track=\"{}\"", guild.getId(), track.getInfo().title); + loadSingle(track); + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) + { + LOG.debug("PlayNext playlist loaded (selecting single): guild={}, name=\"{}\", tracks={}", + guild.getId(), playlist.getName(), playlist.getTracks().size()); + + AudioTrack single; + if (playlist.getTracks().size() == 1 || playlist.isSearchResult()) + { + single = playlist.getSelectedTrack() == null ? playlist.getTracks().get(0) : playlist.getSelectedTrack(); + } + else if (playlist.getSelectedTrack() != null) + { + single = playlist.getSelectedTrack(); + } + else + { + single = playlist.getTracks().get(0); + } + loadSingle(single); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/service/MusicService.java b/src/main/java/com/jagrosh/jmusicbot/service/MusicService.java new file mode 100644 index 0000000..d2596d8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/service/MusicService.java @@ -0,0 +1,2490 @@ +/* + * 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.service; + +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.DJCommand; +import com.jagrosh.jmusicbot.playlist.PlaylistLoader; +import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist; +import com.jagrosh.jmusicbot.queue.AbstractQueue; +import com.jagrosh.jmusicbot.queue.PlaybackHistory; +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 com.jagrosh.jmusicbot.utils.TimeUtil; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.nio.file.Path; +import java.util.regex.Pattern; + +/** + * Unified service for all music operations including player control and queue management. + * This service encapsulates all interactions with AudioHandler. + */ +public class MusicService +{ + public static final String HISTORY_DISABLED_MESSAGE = + "Playback history is disabled by config (playback.maxHistorySize = 0)."; + private static final String FAVORITES_PLAYLIST_NAME = "favorites"; + private static final Pattern WINDOWS_DRIVE_ABSOLUTE_PATH = Pattern.compile("^[A-Za-z]:[\\\\/].*"); + private static final boolean WINDOWS_RUNTIME = + System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + + private static final Logger LOG = LoggerFactory.getLogger(MusicService.class); + + private final Bot bot; + private final Map<PlaylistDraftKey, PlaylistDraftState> playlistDrafts = new ConcurrentHashMap<>(); + + public MusicService(Bot bot) + { + this.bot = bot; + LOG.info("MusicService initialized"); + } + + // ========== Internal Helpers ========== + + /** + * Gets the AudioHandler for a guild. + * + * @param guild The guild + * @return The AudioHandler, or null if none exists + */ + private AudioHandler getHandler(Guild guild) + { + return (AudioHandler) guild.getAudioManager().getSendingHandler(); + } + + /** + * Gets the Settings for a guild. + * + * @param guild The guild + * @return The guild's Settings + */ + private Settings getSettings(Guild guild) + { + return bot.getSettingsManager().getSettings(guild); + } + + /** + * Checks if the member has DJ permission and sends an error if not. + * + * @param guild The guild + * @param member The member to check + * @param output The output adapter for error messages + * @param action Description of the action being attempted (for error message) + * @return true if the member has DJ permission, false otherwise + */ + private boolean requireDJPermission(Guild guild, Member member, OutputAdapter output, String action) + { + if (!DJCommand.checkDJPermission(bot, guild, member)) + { + output.replyError("You need to be a DJ to " + action + "!"); + return false; + } + return true; + } + + /** + * Functional interface for track adding strategies. + */ + @FunctionalInterface + private interface TrackAdder + { + int add(AudioHandler handler, QueuedTrack track); + } + + // ========== Shared Track Utilities ========== + + /** + * Checks if a track exceeds the maximum allowed duration. + * + * @param track The track to check + * @return true if the track is too long + */ + public boolean isTooLong(AudioTrack track) + { + return bot.getConfig().isTooLong(track); + } + + /** + * Formats an error message for a track that is too long. + * + * @param track The track that is too long + * @return Formatted error message + */ + public String formatTooLongError(AudioTrack track) + { + String title = FormatUtil.getTrackTitle(track); + return "This track (**" + title + "**) is longer than the allowed maximum: `" + + TimeUtil.formatTime(track.getDuration()) + "` > `" + bot.getConfig().getMaxTime() + "`"; + } + + /** + * Formats a success message for a track that was added to the queue. + * + * @param title The track title + * @param duration The track duration in milliseconds + * @param position The queue position (0 = now playing, >0 = queue position) + * @return Formatted success message + */ + public String formatTrackAddedMessage(String title, long duration, int position) + { + return "Added **" + FormatUtil.filter(title) + "** (`" + TimeUtil.formatTime(duration) + "`) " + + (position == 0 ? "to begin playing" : " to the queue at position " + position); + } + + /** + * Internal helper that handles common track-add logic. + * + * @param guild The guild + * @param member The member adding the track + * @param track The track to add + * @param queryArgs The original query/args used to find this track + * @param channel The text channel for request metadata + * @param adder The strategy for adding the track to the queue + * @param reason The reason to log (e.g., "added to the queue") + * @param logLocation Description for logging (e.g., "queue" or "front of queue") + * @return TrackAddResult containing position and formatted message, or null if track is too long + */ + private TrackAddResult addTrackInternal(Guild guild, Member member, AudioTrack track, + String queryArgs, TextChannel channel, + TrackAdder adder, String reason, String logLocation) + { + LOG.debug("Adding track to {}: guild={}, user={}, track={}", + logLocation, guild.getId(), member.getUser().getName(), track.getInfo().title); + + if (isTooLong(track)) + { + LOG.warn("Track rejected (too long): {} - duration: {} > max: {}", + track.getInfo().title, TimeUtil.formatTime(track.getDuration()), bot.getConfig().getMaxTime()); + return null; + } + + AudioHandler handler = getHandler(guild); + handler.setLastReason(member.getUser().getName() + " " + reason); + QueuedTrack queuedTrack = new QueuedTrack(track, + new RequestMetadata(member.getUser(), + new RequestMetadata.RequestInfo(queryArgs, track.getInfo().uri), + channel.getIdLong())); + int position = adder.add(handler, queuedTrack) + 1; + + String title = FormatUtil.getTrackTitle(track); + String message = formatTrackAddedMessage(title, track.getDuration(), position); + + LOG.info("Track added to {}: guild={}, user={}, track=\"{}\", position={}", + logLocation, guild.getId(), member.getUser().getName(), title, position); + + return new TrackAddResult(position, message, title); + } + + /** + * Adds a track to the queue and returns the result. + * + * @param guild The guild + * @param member The member adding the track + * @param track The track to add + * @param queryArgs The original query/args used to find this track + * @param channel The text channel for request metadata + * @return TrackAddResult containing position and formatted message, or null if track is too long + */ + public TrackAddResult addTrackToQueue(Guild guild, Member member, AudioTrack track, + String queryArgs, TextChannel channel) + { + return addTrackInternal(guild, member, track, queryArgs, channel, + AudioHandler::addTrack, "added to the queue.", "queue"); + } + + /** + * Adds a track to the front of the queue and returns the result. + * + * @param guild The guild + * @param member The member adding the track + * @param track The track to add + * @param queryArgs The original query/args used to find this track + * @param channel The text channel for request metadata + * @return TrackAddResult containing position and formatted message, or null if track is too long + */ + public TrackAddResult addTrackToFront(Guild guild, Member member, AudioTrack track, + String queryArgs, TextChannel channel) + { + return addTrackInternal(guild, member, track, queryArgs, channel, + AudioHandler::addTrackToFront, "added to the front of the queue.", "front of queue"); + } + + // ========== Player Operations ========== + + public void playNext(Guild guild, Member member, String args, TextChannel channel, OutputAdapter output) + { + LOG.debug("PlayNext requested: guild={}, user={}, query={}", + guild.getId(), member.getUser().getName(), args); + + if (args == null || args.isEmpty()) + { + LOG.debug("PlayNext rejected: empty query"); + output.replyWarning("Please include a song title or URL!"); + return; + } + + if (args.startsWith("<") && args.endsWith(">")) + args = args.substring(1, args.length() - 1); + + LOG.info("Loading track for playNext: guild={}, user={}, query=\"{}\"", + guild.getId(), member.getUser().getName(), args); + + bot.getPlayerManager().loadItemOrdered(guild, args, + bot.getAudioLoadWrapper().wrap(args, new AudioLoadResultHandlers.PlayNextResultHandler(this, bot, output, guild, member, args, false, channel))); + } + + public void play(Guild guild, Member member, String args, TextChannel channel, OutputAdapter output) + { + LOG.debug("Play requested: guild={}, user={}, args={}", + guild.getId(), member.getUser().getName(), args); + + if (args != null && args.startsWith("\"") && args.endsWith("\"")) + args = args.substring(1, args.length() - 1); + + if (args == null || args.isEmpty()) + { + AudioHandler handler = getHandler(guild); + if (handler.getPlayer().getPlayingTrack() != null && handler.getPlayer().isPaused()) + { + if (DJCommand.checkDJPermission(bot, guild, member)) + { + handler.getPlayer().setPaused(false); + LOG.info("Playback resumed: guild={}, user={}, track=\"{}\"", + guild.getId(), member.getUser().getName(), handler.getPlayer().getPlayingTrack().getInfo().title); + String resumedTitle = FormatUtil.getTrackTitle(handler.getPlayer().getPlayingTrack()); + output.replySuccess("Resumed **" + resumedTitle + "**."); + } + else + { + LOG.debug("Resume rejected: user lacks DJ permission"); + output.replyError("Only DJs can unpause the player!"); + } + return; + } + output.onShowHelp(); + return; + } + + LOG.info("Loading track: guild={}, user={}, query=\"{}\"", + guild.getId(), member.getUser().getName(), args); + + bot.getPlayerManager().loadItemOrdered(guild, args, + bot.getAudioLoadWrapper().wrap(args, new AudioLoadResultHandlers.PlayResultHandler(this, bot, output, guild, member, args, false, channel))); + } + + public void previous(Guild guild, Member member, OutputAdapter output) + { + LOG.debug("Previous track requested: guild={}, user={}", + guild.getId(), member.getUser().getName()); + + AudioHandler handler = getHandler(guild); + boolean isDJ = DJCommand.checkDJPermission(bot, guild, member); + + if (!isDJ && handler.getRequestMetadata().getOwner() != member.getIdLong()) + { + LOG.debug("Previous rejected: user lacks permission"); + output.replyError("You need to be a DJ or the requester to go back!"); + return; + } + AudioTrack playing = handler.getPlayer().getPlayingTrack(); + + if (playing != null && playing.getPosition() > 5000) + { + playing.setPosition(0); + LOG.info("Track restarted: guild={}, track=\"{}\"", guild.getId(), playing.getInfo().title); + output.replySuccess("Restarted **" + FormatUtil.getTrackTitle(playing) + "**"); + return; + } + + var history = handler.getQueue().getHistory(); + if (playing != null && !history.isEmpty()) + { + QueuedTrack mostRecent = history.get(0); + AudioTrack mostRecentTrack = mostRecent != null ? mostRecent.getTrack() : null; + if (mostRecentTrack != null + && Objects.equals(mostRecentTrack.getIdentifier(), playing.getIdentifier())) + { + handler.getQueue().removeFromHistoryAt(0); + } + } + + if (history.isEmpty()) + { + LOG.debug("Previous rejected: no history available"); + output.replyError("There are no previous tracks!"); + return; + } + + AudioTrack currentlyPlaying = handler.getPlayer().getPlayingTrack(); + QueuedTrack currentQueued = currentlyPlaying != null + ? new QueuedTrack(currentlyPlaying.makeClone(), handler.getRequestMetadata()) + : null; + + QueuedTrack previous = handler.getQueue().rewind(currentQueued); + if (previous != null) + { + handler.getPlayer().playTrack(previous.getTrack()); + LOG.info("Went to previous track: guild={}, track=\"{}\"", + guild.getId(), previous.getTrack().getInfo().title); + output.replySuccess("Went back to **" + FormatUtil.getTrackTitle(previous.getTrack()) + "**"); + } + else + { + LOG.debug("Previous failed: no previous tracks in history"); + output.replyError("There are no previous tracks!"); + } + } + + public void shuffle(Guild guild, Member member, int startIndex, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "use this button")) + return; + + AudioHandler handler = getHandler(guild); + int s = handler.getQueue().shuffle(startIndex); + output.replySuccess("Shuffled " + s + " tracks!"); + } + + /** + * Shuffles only the tracks added by a specific user. + * + * @param guild The guild + * @param userId The user ID whose tracks to shuffle + * @return The number of tracks shuffled + */ + public int shuffleUserTracks(Guild guild, long userId) + { + LOG.debug("Shuffling user tracks: guild={}, userId={}", guild.getId(), userId); + + AudioHandler handler = getHandler(guild); + int shuffled = handler.getQueue().shuffle(userId); + + LOG.info("User tracks shuffled: guild={}, userId={}, count={}", guild.getId(), userId, shuffled); + + return shuffled; + } + + public void cycleRepeatMode(Guild guild, Member member, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "use this button")) + return; + + AudioHandler handler = getHandler(guild); + RepeatMode mode = getSettings(guild).getRepeatMode(); + RepeatMode nextMode; + switch (mode) { + case OFF: + nextMode = RepeatMode.ALL; + break; + case ALL: + nextMode = RepeatMode.SINGLE; + break; + case SINGLE: + default: + nextMode = RepeatMode.OFF; + break; + } + getSettings(guild).setRepeatMode(nextMode); + output.editNowPlaying(handler); + } + + /** + * Gets the current repeat mode for a guild. + * + * @param guild The guild + * @return The current RepeatMode + */ + public RepeatMode getRepeatMode(Guild guild) + { + return getSettings(guild).getRepeatMode(); + } + + /** + * Sets the repeat mode for a guild. + * + * @param guild The guild + * @param mode The repeat mode to set + */ + public void setRepeatMode(Guild guild, RepeatMode mode) + { + LOG.info("Repeat mode changed: guild={}, mode={}", guild.getId(), mode.getUserFriendlyName()); + getSettings(guild).setRepeatMode(mode); + } + + public void adjustVolume(Guild guild, Member member, int change, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "use this button")) + return; + + AudioHandler handler = getHandler(guild); + int newVol = handler.getPlayer().getVolume() + change; + newVol = Math.max(0, Math.min(150, newVol)); + handler.getPlayer().setVolume(newVol); + getSettings(guild).setVolume(newVol); + output.editNowPlaying(handler); + } + + /** + * Gets the current volume for a guild. + * + * @param guild The guild + * @return The current volume (0-150) + */ + public int getVolume(Guild guild) + { + AudioHandler handler = getHandler(guild); + return handler.getPlayer().getVolume(); + } + + /** + * Sets the volume to an absolute value. + * + * @param guild The guild + * @param volume The new volume (0-150) + * @return VolumeResult containing the old and new volume, or null if invalid + */ + public VolumeResult setVolume(Guild guild, int volume) + { + LOG.debug("Volume change requested: guild={}, volume={}", guild.getId(), volume); + + if (volume < 0 || volume > 150) + { + LOG.warn("Volume change rejected: invalid value {} (must be 0-150)", volume); + return null; + } + + AudioHandler handler = getHandler(guild); + int oldVolume = handler.getPlayer().getVolume(); + handler.getPlayer().setVolume(volume); + getSettings(guild).setVolume(volume); + + LOG.info("Volume changed: guild={}, oldVolume={}, newVolume={}", guild.getId(), oldVolume, volume); + + return new VolumeResult(oldVolume, volume); + } + + /** + * Result of a volume change operation. + */ + public static class VolumeResult + { + public final int oldVolume; + public final int newVolume; + + public VolumeResult(int oldVolume, int newVolume) + { + this.oldVolume = oldVolume; + this.newVolume = newVolume; + } + } + + public void stop(Guild guild, Member member, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "use this button")) + return; + + AudioHandler handler = getHandler(guild); + handler.stopAndClearQueuePreserveHistory(); + guild.getAudioManager().closeAudioConnection(); + output.editNoMusic(handler); + } + + /** + * Stops playback and clears the queue (simple version without permission check). + * Use this when DJ permission is already verified by the caller. + * + * @param guild The guild + */ + public void stopAndClear(Guild guild) + { + LOG.info("Stopping playback and clearing queue: guild={}", guild.getId()); + + AudioHandler handler = getHandler(guild); + handler.stopAndClearQueuePreserveHistory(); + guild.getAudioManager().closeAudioConnection(); + + LOG.debug("Audio connection closed: guild={}", guild.getId()); + } + + public void pause(Guild guild, Member member, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "use this button")) + return; + + AudioHandler handler = getHandler(guild); + handler.getPlayer().setPaused(!handler.getPlayer().isPaused()); + output.editNowPlaying(handler); + } + + /** + * Checks if the player is currently paused. + * + * @param guild The guild + * @return true if paused, false otherwise + */ + public boolean isPaused(Guild guild) + { + AudioHandler handler = getHandler(guild); + return handler.getPlayer().isPaused(); + } + + /** + * Sets the paused state of the player. + * + * @param guild The guild + * @param paused true to pause, false to resume + * @return The title of the currently playing track, or null if nothing is playing + */ + public String setPaused(Guild guild, boolean paused) + { + AudioHandler handler = getHandler(guild); + handler.getPlayer().setPaused(paused); + AudioTrack track = handler.getPlayer().getPlayingTrack(); + String trackTitle = track != null ? FormatUtil.getTrackTitle(track) : null; + + LOG.info("Player {} : guild={}, track=\"{}\"", + paused ? "paused" : "resumed", guild.getId(), trackTitle); + + return trackTitle; + } + + public void skip(Guild guild, Member member, OutputAdapter output) + { + AudioHandler handler = getHandler(guild); + boolean isDJ = DJCommand.checkDJPermission(bot, guild, member); + + RequestMetadata skipRm = handler.getRequestMetadata(); + if (!isDJ && skipRm.getOwner() != member.getIdLong()) + { + output.replyError("You need to be a DJ or the requester to skip!"); + return; + } + if (getSettings(guild).getRepeatMode() == RepeatMode.ALL) + { + var track = handler.getPlayer().getPlayingTrack(); + if (track != null) + handler.addTrack(new QueuedTrack(track.makeClone(), track.getUserData(RequestMetadata.class))); + } + handler.setLastReason(member.getUser().getName() + " skipped forward."); + handler.getPlayer().stopTrack(); + output.replySuccess("Skipped!"); + } + + /** + * Force skips the currently playing track (no permission check). + * Use this when DJ permission is already verified by the caller. + * + * @param guild The guild + * @return ForceSkipResult containing track info and requester, or null if nothing playing + */ + public ForceSkipResult forceSkip(Guild guild) + { + LOG.debug("Force skip requested: guild={}", guild.getId()); + + AudioHandler handler = getHandler(guild); + AudioTrack track = handler.getPlayer().getPlayingTrack(); + if (track == null) + { + LOG.debug("Force skip: nothing playing in guild={}", guild.getId()); + return null; + } + + RequestMetadata rm = handler.getRequestMetadata(); + String trackTitle = FormatUtil.getTrackTitle(track); + String requesterInfo = rm.getOwner() == 0L ? "(autoplay)" : "(requested by **" + FormatUtil.formatUsername(rm.user) + "**)"; + + handler.getPlayer().stopTrack(); + + LOG.info("Track force-skipped: guild={}, track=\"{}\"", guild.getId(), trackTitle); + + return new ForceSkipResult(trackTitle, requesterInfo); + } + + /** + * Result of a force skip operation. + */ + public static class ForceSkipResult + { + public final String trackTitle; + public final String requesterInfo; + + public ForceSkipResult(String trackTitle, String requesterInfo) + { + this.trackTitle = trackTitle; + this.requesterInfo = requesterInfo; + } + } + + public void skipWithVote(Guild guild, Member member, int listeners, OutputAdapter output) + { + LOG.debug("Skip vote requested: guild={}, user={}, listeners={}", + guild.getId(), member.getUser().getName(), listeners); + + AudioHandler handler = getHandler(guild); + RequestMetadata rm = handler.getRequestMetadata(); + + double skipRatio = getSettings(guild).getSkipRatio(); + if (skipRatio == -1) + { + skipRatio = bot.getConfig().getSkipRatio(); + } + + if (member.getIdLong() == rm.getOwner() || skipRatio == 0) + { + String trackTitle = FormatUtil.getTrackTitle(handler.getPlayer().getPlayingTrack()); + handler.getPlayer().stopTrack(); + LOG.info("Track skipped by owner/instant skip: guild={}, user={}, track=\"{}\"", + guild.getId(), member.getUser().getName(), trackTitle); + output.replySuccess("Skipped **" + trackTitle + "**"); + return; + } + + String oderId = member.getId(); + boolean alreadyVoted = handler.getVotes().contains(oderId); + + if (!alreadyVoted) + { + handler.getVotes().add(oderId); + } + + int skippers = (int) handler.getVotes().stream() + .filter(id -> guild.getMemberById(id) != null && + guild.getMemberById(id).getVoiceState() != null && + guild.getMemberById(id).getVoiceState().getChannel() != null) + .count(); + int required = (int) Math.ceil(listeners * skipRatio); + + String voteStatus = "[" + skippers + " votes, " + required + "/" + listeners + " needed]"; + + if (alreadyVoted) + { + LOG.debug("Duplicate skip vote: guild={}, user={}", guild.getId(), member.getUser().getName()); + output.replyWarning("You already voted to skip this song `" + voteStatus + "`"); + } + else if (skippers >= required) + { + String trackTitle = FormatUtil.getTrackTitle(handler.getPlayer().getPlayingTrack()); + String requester = rm.getOwner() == 0L ? "(autoplay)" : "(requested by **" + FormatUtil.formatUsername(rm.user) + "**)"; + handler.getPlayer().stopTrack(); + LOG.info("Track skipped by vote: guild={}, track=\"{}\", votes={}/{}", + guild.getId(), trackTitle, skippers, required); + output.replySuccess("You voted to skip the song `" + voteStatus + "`\nSkipped **" + trackTitle + "** " + requester); + } + else + { + LOG.debug("Skip vote registered: guild={}, user={}, votes={}/{}", + guild.getId(), member.getUser().getName(), skippers, required); + output.replySuccess("You voted to skip the song `" + voteStatus + "`"); + } + } + + public void addCurrentTrackToFavorites(Guild guild, Member member, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "add the current track to favorites")) + return; + + AudioHandler handler = getHandler(guild); + if (handler == null) + { + output.replyError("There is no player in this server!"); + return; + } + + AudioTrack currentTrack = handler.getPlayer().getPlayingTrack(); + if (currentTrack == null) + { + output.replyError("There is no track currently playing!"); + return; + } + + String favoriteEntry = currentTrack.getInfo().uri; + if (favoriteEntry == null || favoriteEntry.isBlank()) + { + output.replyError("The current track does not have a valid URI or absolute path to save."); + return; + } + if (!isSupportedFavoriteEntry(favoriteEntry)) + { + output.replyError("Only URI references or absolute file paths for this OS can be added to favorites."); + return; + } + + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> appendResult = + bot.getPlaylistLoader().appendItemIfAbsentResult(FAVORITES_PLAYLIST_NAME, favoriteEntry); + if (!appendResult.isSuccess()) + { + output.replyError(mapPlaylistErrorToMessage(appendResult.getError())); + return; + } + + if (appendResult.getValue().getStatus() == PlaylistLoader.AppendIfAbsentStatus.ALREADY_PRESENT) + { + handler.markCurrentTrackFavorited(favoriteEntry); + output.editNowPlaying(handler); + output.replyWarning("This track is already favorited"); + return; + } + + handler.markCurrentTrackFavorited(favoriteEntry); + output.editNowPlaying(handler); + output.replySuccess("Added " + FormatUtil.filter(FormatUtil.getTrackTitle(currentTrack)) + " to Favorites"); + } + + private static boolean isSupportedFavoriteEntry(String value) + { + String candidate = value == null ? "" : value.trim(); + if (candidate.isEmpty()) + return false; + return isUriLikeReference(candidate) || isOsNativeAbsolutePath(candidate); + } + + private static boolean isUriLikeReference(String value) + { + // Avoid treating Windows drive paths (e.g. C:\music\song.mp3) as URI schemes. + if (isWindowsAbsolutePathSyntax(value)) + return false; + int colonIndex = value.indexOf(':'); + if (colonIndex <= 0) + return false; + for (int i = 0; i < colonIndex; i++) + { + char c = value.charAt(i); + if (!Character.isLetterOrDigit(c) && c != '+' && c != '-' && c != '.') + return false; + } + return true; + } + + private static boolean isOsNativeAbsolutePath(String value) + { + try + { + if (!Path.of(value).isAbsolute()) + return false; + } + catch (RuntimeException ex) + { + return false; + } + + if (WINDOWS_RUNTIME) + return isWindowsAbsolutePathSyntax(value); + return value.startsWith("/"); + } + + private static boolean isWindowsAbsolutePathSyntax(String value) + { + return WINDOWS_DRIVE_ABSOLUTE_PATH.matcher(value).matches() || value.startsWith("\\\\"); + } + + public void seek(Guild guild, Member member, String timeString, OutputAdapter output) + { + LOG.debug("Seek requested: guild={}, user={}, time={}", + guild.getId(), member.getUser().getName(), timeString); + + AudioHandler handler = getHandler(guild); + AudioTrack playingTrack = handler.getPlayer().getPlayingTrack(); + + if (playingTrack == null) + { + LOG.debug("Seek rejected: no track playing in guild={}", guild.getId()); + output.replyError("There is no track currently playing!"); + return; + } + + if (!playingTrack.isSeekable()) + { + LOG.debug("Seek rejected: track not seekable - track=\"{}\"", playingTrack.getInfo().title); + output.replyError("This track is not seekable."); + return; + } + + boolean isDJ = DJCommand.checkDJPermission(bot, guild, member); + RequestMetadata rm = playingTrack.getUserData(RequestMetadata.class); + if (!isDJ && (rm == null || rm.getOwner() != member.getIdLong())) + { + LOG.debug("Seek rejected: user lacks permission - user={}, track=\"{}\"", + member.getUser().getName(), playingTrack.getInfo().title); + output.replyError("You cannot seek **" + FormatUtil.getTrackTitle(playingTrack) + "** because you didn't add it!"); + return; + } + + TimeUtil.SeekTime seekTime = TimeUtil.parseTime(timeString); + if (seekTime == null) + { + LOG.debug("Seek rejected: invalid time format - input=\"{}\"", timeString); + output.replyError("Invalid seek! Expected format: [+ | -] <HH:MM:SS | MM:SS | SS> or <0h0m0s>\nExamples: `1:02:23` `+1:10` `-90`, `1h10m`, `+90s`"); + return; + } + + long currentPosition = playingTrack.getPosition(); + long trackDuration = playingTrack.getDuration(); + long seekMilliseconds = seekTime.relative ? currentPosition + seekTime.milliseconds : seekTime.milliseconds; + + if (seekMilliseconds < 0) + { + seekMilliseconds = 0; + } + if (seekMilliseconds > trackDuration) + { + LOG.debug("Seek rejected: position {} exceeds track duration {}", + TimeUtil.formatTime(seekMilliseconds), TimeUtil.formatTime(trackDuration)); + output.replyError("Cannot seek to `" + TimeUtil.formatTime(seekMilliseconds) + "` because the current track is `" + TimeUtil.formatTime(trackDuration) + "` long!"); + return; + } + + try + { + playingTrack.setPosition(seekMilliseconds); + LOG.info("Seek successful: guild={}, user={}, track=\"{}\", position={}", + guild.getId(), member.getUser().getName(), playingTrack.getInfo().title, + TimeUtil.formatTime(playingTrack.getPosition())); + output.replySuccess("Successfully seeked to `" + TimeUtil.formatTime(playingTrack.getPosition()) + "/" + TimeUtil.formatTime(trackDuration) + "`!"); + } + catch (Exception e) + { + LOG.error("Seek failed: guild={}, track=\"{}\", error={}", + guild.getId(), playingTrack.getInfo().title, e.getMessage(), e); + output.replyError("An error occurred while trying to seek: " + e.getMessage()); + } + } + + // ========== Queue Operations ========== + + public void removeTrack(Guild guild, Member member, int position, OutputAdapter output) + { + AudioHandler handler = getHandler(guild); + + if (!requireNonEmptyQueue(handler, output)) + return; + + if (!validateQueuePosition(handler, position, output)) + return; + + boolean isDJ = DJCommand.checkDJPermission(bot, guild, member); + QueuedTrack qt = handler.getQueue().get(position - 1); + + if (qt.getIdentifier() == member.getIdLong()) + { + handler.getQueue().remove(position - 1); + output.replySuccess("Removed **" + FormatUtil.getTrackTitle(qt.getTrack()) + "** from the queue"); + } + else if (isDJ) + { + handler.getQueue().remove(position - 1); + User u = null; + try + { + u = guild.getJDA().getUserById(qt.getIdentifier()); + } + catch (Exception ignored) {} + + output.replySuccess("Removed **" + FormatUtil.getTrackTitle(qt.getTrack()) + + "** from the queue (requested by " + (u == null ? "someone" : "**" + u.getName() + "**") + ")"); + } + else + { + output.replyError("You cannot remove **" + FormatUtil.getTrackTitle(qt.getTrack()) + "** because you didn't add it!"); + } + } + + public void removeAllTracks(Guild guild, Member member, OutputAdapter output) + { + AudioHandler handler = getHandler(guild); + + if (!requireNonEmptyQueue(handler, output)) + return; + + int count = handler.getQueue().removeAll(member.getIdLong()); + if (count == 0) + { + output.replyWarning("You don't have any songs in the queue!"); + } + else + { + output.replySuccess("Successfully removed your " + count + " entries."); + } + } + + /** + * Removes all tracks from a specific user (for DJ force remove). + * + * @param guild The guild + * @param userId The user ID whose tracks to remove + * @return The number of tracks removed + */ + public int removeAllTracksByUser(Guild guild, long userId) + { + LOG.debug("Removing all tracks by user: guild={}, userId={}", guild.getId(), userId); + + AudioHandler handler = getHandler(guild); + int count = handler.getQueue().removeAll(userId); + + LOG.info("Removed {} tracks by user: guild={}, userId={}", count, guild.getId(), userId); + + return count; + } + + /** + * Checks if the queue is empty. + * + * @param guild The guild + * @return true if the queue is empty + */ + public boolean isQueueEmpty(Guild guild) + { + AudioHandler handler = getHandler(guild); + return handler == null || handler.getQueue().isEmpty(); + } + + public void moveTrack(Guild guild, Member member, int from, int to, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "move tracks")) + return; + + if (from == to) + { + output.replyError("Can't move a track to the same position."); + return; + } + + AudioHandler handler = getHandler(guild); + AbstractQueue<QueuedTrack> queue = handler.getQueue(); + + if (isInvalidPosition(queue, from)) + { + output.replyError("`" + from + "` is not a valid position in the queue!"); + return; + } + if (isInvalidPosition(queue, to)) + { + output.replyError("`" + to + "` is not a valid position in the queue!"); + return; + } + + QueuedTrack track = queue.moveItem(from - 1, to - 1); + String trackTitle = FormatUtil.getTrackTitle(track.getTrack()); + output.replySuccess("Moved **" + trackTitle + "** from position `" + from + "` to `" + to + "`."); + } + + /** + * Moves a track to position 1, making it play next. + * Convenience method that wraps moveTrack. + * + * @param guild The guild + * @param member The member executing the command + * @param position The 1-based position of the track to move + * @param output The output adapter for responses + */ + public void playNext(Guild guild, Member member, int position, OutputAdapter output) + { + if (position == 1) + { + output.replyWarning("This track is already next!"); + return; + } + moveTrack(guild, member, position, 1, output); + } + + /** + * Plays a track from the queue immediately while preserving the rest of the queue. + * Moves the track to position 1 and skips the currently playing track. + * + * @param guild The guild + * @param member The member executing the command + * @param position The 1-based position of the track to play now + * @param output The output adapter for responses + */ + public void playNow(Guild guild, Member member, int position, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "play a track immediately")) + return; + + AudioHandler handler = getHandler(guild); + AbstractQueue<QueuedTrack> queue = handler.getQueue(); + + if (isInvalidPosition(queue, position)) + { + output.replyError("`" + position + "` is not a valid position in the queue!"); + return; + } + + // Get the track info before moving + QueuedTrack queuedTrack = queue.get(position - 1); + String trackTitle = FormatUtil.getTrackTitle(queuedTrack.getTrack()); + + // Move the track to position 1 + if (position > 1) + { + queue.moveItem(position - 1, 0); + } + + // Skip the currently playing track to start the moved track immediately + handler.getPlayer().stopTrack(); + + output.replySuccess("Now playing **" + trackTitle + "**"); + } + + /** + * Moves a track from one position to another (no permission check). + * Use this when DJ permission is already verified by the caller. + * + * @param guild The guild + * @param from The 1-based source position + * @param to The 1-based destination position + * @return The title of the moved track, or null if invalid positions + */ + public String moveTrackPosition(Guild guild, int from, int to) + { + LOG.debug("Moving track: guild={}, from={}, to={}", guild.getId(), from, to); + + AudioHandler handler = getHandler(guild); + AbstractQueue<QueuedTrack> queue = handler.getQueue(); + + if (isInvalidPosition(queue, from) || isInvalidPosition(queue, to)) + { + LOG.debug("Move rejected: invalid position(s) - from={}, to={}, queueSize={}", + from, to, queue.size()); + return null; + } + + QueuedTrack track = queue.moveItem(from - 1, to - 1); + String title = FormatUtil.getTrackTitle(track.getTrack()); + + LOG.info("Track moved: guild={}, track=\"{}\", from={}, to={}", + guild.getId(), title, from, to); + + return title; + } + + /** + * Checks if a position is valid in the queue. + * + * @param guild The guild + * @param position The 1-based position to check + * @return true if the position is valid + */ + public boolean isValidQueuePosition(Guild guild, int position) + { + AudioHandler handler = getHandler(guild); + return handler != null && position >= 1 && position <= handler.getQueue().size(); + } + + public void skipTo(Guild guild, Member member, int position, OutputAdapter output) + { + if (!requireDJPermission(guild, member, output, "skip to a specific position")) + return; + + AudioHandler handler = getHandler(guild); + + if (!validateQueuePosition(handler, position, output)) + return; + + handler.getQueue().skip(position - 1); + String trackTitle = FormatUtil.getTrackTitle(handler.getQueue().get(0).getTrack()); + handler.getPlayer().stopTrack(); + output.replySuccess("Skipped to **" + trackTitle + "**"); + } + + /** + * Skips to a specific position in the queue (no permission check). + * Use this when DJ permission is already verified by the caller. + * + * @param guild The guild + * @param position The 1-based position to skip to + * @return The title of the track skipped to, or null if invalid position + */ + public String skipToPosition(Guild guild, int position) + { + LOG.debug("Skip to position: guild={}, position={}", guild.getId(), position); + + AudioHandler handler = getHandler(guild); + int queueSize = handler.getQueue().size(); + + if (position < 1 || position > queueSize) + { + LOG.debug("Skip to position rejected: invalid position {} (queueSize={})", + position, queueSize); + return null; + } + + handler.getQueue().skip(position - 1); + String trackTitle = FormatUtil.getTrackTitle(handler.getQueue().get(0).getTrack()); + handler.getPlayer().stopTrack(); + + LOG.info("Skipped to position: guild={}, position={}, track=\"{}\"", + guild.getId(), position, trackTitle); + + return trackTitle; + } + + /** + * Gets the current queue size. + * + * @param guild The guild + * @return The number of tracks in the queue + */ + public int getQueueSize(Guild guild) + { + AudioHandler handler = getHandler(guild); + return handler != null ? handler.getQueue().size() : 0; + } + + // ========== Now Playing ========== + + /** + * Gets the now playing message for a guild. + * + * @param guild The guild + * @param jda The JDA instance + * @return NowPlayingInfo containing the message data, or null if no handler + */ + public NowPlayingInfo getNowPlayingInfo(Guild guild, JDA jda) + { + AudioHandler handler = getHandler(guild); + if (handler == null) + { + return null; + } + + return new NowPlayingInfo( + handler.getNowPlaying(jda), + handler.getNoMusicPlaying(jda), + handler.getPlayer().getPlayingTrack() != null + ); + } + + /** + * Data class containing now playing information. + */ + public static class NowPlayingInfo + { + public final net.dv8tion.jda.api.utils.messages.MessageCreateData nowPlayingMessage; + public final net.dv8tion.jda.api.utils.messages.MessageCreateData noMusicMessage; + public final boolean isPlaying; + + public NowPlayingInfo(net.dv8tion.jda.api.utils.messages.MessageCreateData nowPlayingMessage, + net.dv8tion.jda.api.utils.messages.MessageCreateData noMusicMessage, + boolean isPlaying) + { + this.nowPlayingMessage = nowPlayingMessage; + this.noMusicMessage = noMusicMessage; + this.isPlaying = isPlaying; + } + } + + // ========== Queue Info ========== + + public QueueInfo getQueueInfo(Guild guild, JDA jda) + { + AudioHandler handler = getHandler(guild); + if (handler == null) + { + return null; + } + + List<QueuedTrack> list = handler.getQueue().getList(); + Settings settings = getSettings(guild); + + long totalDuration = 0; + String[] trackStrings = new String[list.size()]; + for (int i = 0; i < list.size(); i++) + { + totalDuration += list.get(i).getTrack().getDuration(); + trackStrings[i] = list.get(i).toString(); + } + + String nowPlayingTitle = null; + String statusEmoji = handler.getStatusEmoji(); + if (handler.getPlayer().getPlayingTrack() != null) + { + nowPlayingTitle = FormatUtil.getTrackTitle(handler.getPlayer().getPlayingTrack()); + } + + return new QueueInfo( + trackStrings, + totalDuration, + nowPlayingTitle, + statusEmoji, + settings.getRepeatMode(), + settings.getQueueType(), + handler.getNowPlaying(jda), + handler.getNoMusicPlaying(jda) + ); + } + + public String formatQueueTitle(QueueInfo info, String successEmoji) + { + StringBuilder sb = new StringBuilder(); + if (info.nowPlayingTitle != null) + { + sb.append(info.statusEmoji).append(" **").append(info.nowPlayingTitle).append("**\n"); + } + + return FormatUtil.filter(sb.append(successEmoji).append(" Current Queue | ").append(info.tracks.length) + .append(" entries | `").append(TimeUtil.formatTime(info.totalDuration)).append("` ") + .append("| ").append(info.queueType.getEmoji()).append(" `").append(info.queueType.getUserFriendlyName()).append('`') + .append(info.repeatMode.getEmoji() != null ? " | " + info.repeatMode.getEmoji() : "").toString()); + } + + // ========== History Info ========== + + /** + * Gets playback history information for display (e.g. history command embed). + * History index 0 is most recent. Does not require music to be playing. + * + * @param guild The guild + * @param jda The JDA instance (for consistency with getQueueInfo) + * @return HistoryInfo, or null if no handler exists + */ + public HistoryInfo getHistoryInfo(Guild guild, JDA jda) + { + AudioHandler handler = getHandler(guild); + if (handler == null) + { + return null; + } + + List<QueuedTrack> list = handler.getPreviousTracks(); + int maxSize = handler.getQueue().getHistory().getMaxSize(); + boolean disabled = maxSize == 0; + + long totalDuration = 0; + String[] trackStrings = new String[list.size()]; + for (int i = 0; i < list.size(); i++) + { + totalDuration += list.get(i).getTrack().getDuration(); + trackStrings[i] = list.get(i).toString(); + } + + return new HistoryInfo(trackStrings, totalDuration, maxSize, disabled); + } + + /** + * Adds the track at the given 1-based history position to the queue, and removes it from history (de-dup). + * + * @param guild The guild + * @param member The member adding the track + * @param historyPosition 1-based position (1 = most recent) + * @param channel The text channel for request metadata + * @param output The output adapter + */ + public void queueFromHistory(Guild guild, Member member, int historyPosition, TextChannel channel, OutputAdapter output) + { + AudioHandler handler = getHandler(guild); + if (handler == null) + { + output.replyError("There is no player in this server!"); + return; + } + + var history = handler.getQueue().getHistory(); + if (!requireHistoryEnabled(history, output)) + { + return; + } + if (history.isEmpty()) + { + output.replyError("Playback history is empty!"); + return; + } + if (historyPosition < 1 || historyPosition > history.size()) + { + output.replyError("Position must be between 1 and " + history.size() + "!"); + return; + } + + int index = historyPosition - 1; + QueuedTrack qt = history.get(index); + handler.getQueue().removeFromHistoryAt(index); + + AudioTrack track = qt.getTrack().makeClone(); + if (isTooLong(track)) + { + output.replyError(formatTooLongError(track)); + return; + } + + RequestMetadata rm = new RequestMetadata(member.getUser(), + new RequestMetadata.RequestInfo(qt.getTrack().getInfo().uri, qt.getTrack().getInfo().uri), + channel.getIdLong()); + QueuedTrack newQt = new QueuedTrack(track, rm); + handler.setLastReason(member.getUser().getName() + " added from history."); + int position = handler.addTrack(newQt) + 1; + String title = FormatUtil.getTrackTitle(track); + output.replySuccess(formatTrackAddedMessage(title, track.getDuration(), position)); + } + + /** + * Adds all tracks from playback history to the queue, removing them from history (de-dup). + * Tracks that exceed the max duration are skipped. Order in queue is most recent first (playback will be chronological). + * + * @param guild The guild + * @param member The member adding the tracks + * @param channel The text channel for request metadata + * @param output The output adapter + */ + public void queueAllFromHistory(Guild guild, Member member, TextChannel channel, OutputAdapter output) + { + AudioHandler handler = getHandler(guild); + if (handler == null) + { + output.replyError("There is no player in this server!"); + return; + } + + var history = handler.getQueue().getHistory(); + if (!requireHistoryEnabled(history, output)) + { + return; + } + if (history.isEmpty()) + { + output.replyError("Playback history is empty!"); + return; + } + + handler.setLastReason(member.getUser().getName() + " added entire history to queue."); + int added = 0; + int skipped = 0; + + // Work from a stable snapshot and clear source entries before adding tracks so + // onTrackStart history updates cannot be accidentally removed by this operation. + List<QueuedTrack> historySnapshot = new ArrayList<>(history.getList()); + handler.getQueue().clearHistory(); + for (QueuedTrack qt : historySnapshot) + { + AudioTrack track = qt.getTrack().makeClone(); + if (isTooLong(track)) + { + skipped++; + continue; + } + + RequestMetadata rm = new RequestMetadata(member.getUser(), + new RequestMetadata.RequestInfo(qt.getTrack().getInfo().uri, qt.getTrack().getInfo().uri), + channel.getIdLong()); + QueuedTrack newQt = new QueuedTrack(track, rm); + handler.addTrack(newQt); + added++; + } + + if (added == 0 && skipped > 0) + { + output.replyError("All " + skipped + " track(s) were too long to add."); + return; + } + String message = "Added **" + added + "** track(s) to the queue."; + if (skipped > 0) + { + message += " Skipped " + skipped + " track(s) (too long)."; + } + output.replySuccess(message); + if (handler.getPlayer().getPlayingTrack() != null) + { + bot.getNowplayingHandler().requestReconcile(guild.getIdLong(), "history-queueall-loaded"); + } + } + + /** + * Plays the track at the given 1-based history position immediately, and removes it from history (de-dup). + * + * @param guild The guild + * @param member The member + * @param historyPosition 1-based position (1 = most recent) + * @param channel The text channel for request metadata + * @param output The output adapter + */ + public void playFromHistoryNow(Guild guild, Member member, int historyPosition, TextChannel channel, OutputAdapter output) + { + AudioHandler handler = getHandler(guild); + if (handler == null) + { + output.replyError("There is no player in this server!"); + return; + } + + var history = handler.getQueue().getHistory(); + if (!requireHistoryEnabled(history, output)) + { + return; + } + if (history.isEmpty()) + { + output.replyError("Playback history is empty!"); + return; + } + if (historyPosition < 1 || historyPosition > history.size()) + { + output.replyError("Position must be between 1 and " + history.size() + "!"); + return; + } + + int index = historyPosition - 1; + QueuedTrack qt = history.get(index); + handler.getQueue().removeFromHistoryAt(index); + + AudioTrack track = qt.getTrack().makeClone(); + if (isTooLong(track)) + { + output.replyError(formatTooLongError(track)); + return; + } + + RequestMetadata rm = new RequestMetadata(member.getUser(), + new RequestMetadata.RequestInfo(qt.getTrack().getInfo().uri, qt.getTrack().getInfo().uri), + channel.getIdLong()); + QueuedTrack newQt = new QueuedTrack(track, rm); + handler.setLastReason(member.getUser().getName() + " playing from history."); + handler.addTrackToFront(newQt); + if (handler.getPlayer().getPlayingTrack() != null) + { + handler.getPlayer().stopTrack(); + } + String title = FormatUtil.getTrackTitle(track); + output.replySuccess("Now playing **" + FormatUtil.filter(title) + "**"); + } + + private String mapPlaylistErrorToMessage(PlaylistLoader.PlaylistError error) + { + if (error == null) + { + return "Playlist storage is unavailable."; + } + String path = error.getConfiguredPath() == null ? "(not configured)" : error.getConfiguredPath(); + return switch (error.getType()) + { + case INVALID_CONFIG -> + "Playlists storage is misconfigured (`" + path + "`). " + error.getMessage(); + case STORAGE_UNAVAILABLE -> + "Playlists storage is unavailable (`" + path + "`). " + error.getMessage(); + case PLAYLIST_NOT_FOUND -> error.getMessage(); + }; + } + + public PlaylistNamesInfo getAvailablePlaylistNames() + { + PlaylistLoader.PlaylistResult<List<String>> result = bot.getPlaylistLoader().getPlaylistNamesResult(); + if (!result.isSuccess()) + { + return PlaylistNamesInfo.error(mapPlaylistErrorToMessage(result.getError())); + } + return PlaylistNamesInfo.success(result.getValue()); + } + + /** + * Queues all tracks from a named saved playlist. + * + * @param guild The guild + * @param member The member requesting playback + * @param playlistName The saved playlist name + * @param channel The text channel for request metadata + * @param output The output adapter + */ + public void queuePlaylist(Guild guild, Member member, String playlistName, TextChannel channel, OutputAdapter output) + { + PlaylistLoader.PlaylistResult<Playlist> playlistResult = bot.getPlaylistLoader().getPlaylistResult(playlistName); + if (!playlistResult.isSuccess()) + { + output.replyError(mapPlaylistErrorToMessage(playlistResult.getError())); + return; + } + Playlist playlist = playlistResult.getValue(); + + AudioHandler handler = getHandler(guild); + if (handler == null) + { + handler = bot.getPlayerManager().setUpHandler(guild); + } + if (handler == null) + { + output.replyError("There is no player in this server!"); + return; + } + + AudioHandler finalHandler = handler; + playlist.loadTracks(bot.getPlayerManager(), at -> { + RequestMetadata metadata = new RequestMetadata(member.getUser(), + new RequestMetadata.RequestInfo("playlist " + playlist.getName(), at.getInfo().uri), + channel.getIdLong()); + finalHandler.addTrack(new QueuedTrack(at, metadata)); + }, () -> { + int loadedCount = playlist.getTracks().size(); + int errorCount = playlist.getErrors().size(); + if (loadedCount == 0) + { + output.replyWarning("No tracks were loaded from playlist `" + playlist.getName() + "`!"); + return; + } + + StringBuilder msg = new StringBuilder("Queued **") + .append(loadedCount) + .append("** track(s) from playlist `") + .append(playlist.getName()) + .append("`."); + if (errorCount > 0) + { + msg.append(" Failed to load ").append(errorCount).append(" item(s)."); + } + output.replySuccess(msg.toString()); + if (finalHandler.getPlayer().getPlayingTrack() != null) + { + bot.getNowplayingHandler().requestReconcile(guild.getIdLong(), "playlist-loaded"); + } + }); + } + + /** + * Clears current playback queue and starts playing the selected saved playlist immediately. + * + * @param guild The guild + * @param member The member requesting playback + * @param playlistName The saved playlist name + * @param channel The text channel for request metadata + * @param output The output adapter + */ + public void playPlaylistNow(Guild guild, Member member, String playlistName, TextChannel channel, OutputAdapter output) + { + PlaylistLoader.PlaylistResult<Playlist> playlistResult = bot.getPlaylistLoader().getPlaylistResult(playlistName); + if (!playlistResult.isSuccess()) + { + output.replyError(mapPlaylistErrorToMessage(playlistResult.getError())); + return; + } + + AudioHandler handler = getHandler(guild); + if (handler == null) + { + handler = bot.getPlayerManager().setUpHandler(guild); + } + if (handler == null) + { + output.replyError("There is no player in this server!"); + return; + } + + handler.stopAndClearQueuePreserveHistory(); + queuePlaylist(guild, member, playlistName, channel, new OutputAdapter() + { + @Override + public void replySuccess(String content) + { + output.replySuccess("Now playing playlist `" + playlistName + "` (queue replaced). " + content); + } + + @Override + public void replyError(String content) + { + output.replyError(content); + } + + @Override + public void replyWarning(String content) + { + output.replyWarning(content); + } + + @Override + public void editMessage(String content) + { + } + + @Override + public void editMessage(String content, Consumer<Message> onSuccess) + { + } + + @Override + public void editNowPlaying(AudioHandler handler) + { + } + + @Override + public void editNoMusic(AudioHandler handler) + { + } + + @Override + public void onShowHelp() + { + } + }); + } + + /** + * Gets lightweight metadata for a saved playlist. + * + * @param playlistName The saved playlist name + * @return playlist details, or null if playlist does not exist + */ + public PlaylistDetailsInfo getPlaylistDetails(String playlistName) + { + PlaylistLoader.PlaylistResult<Playlist> playlistResult = bot.getPlaylistLoader().getPlaylistResult(playlistName); + if (!playlistResult.isSuccess()) + { + return null; + } + Playlist playlist = playlistResult.getValue(); + + List<String> items = playlist.getItems(); + int previewLimit = Math.min(5, items.size()); + List<String> preview = new ArrayList<>(items.subList(0, previewLimit)); + boolean hasMore = items.size() > previewLimit; + return new PlaylistDetailsInfo(playlist.getName(), items.size(), preview, hasMore); + } + + /** + * Returns true when the member is allowed to edit playlists in interactive details view. + * Policy: bot owner OR DJ permission. + */ + public boolean canEditPlaylistEntries(Guild guild, Member member) + { + return bot.getConfig().getOwnerId() == member.getIdLong() + || DJCommand.checkDJPermission(bot, guild, member); + } + + public PlaylistDraftContext buildPlaylistDraftContext(long guildId, long channelId, long messageId, + long userId, String playlistName) + { + return new PlaylistDraftContext(guildId, channelId, messageId, userId, playlistName); + } + + public PlaylistDraftMutationResult removePlaylistDraftItem(PlaylistDraftContext context, int position) + { + PlaylistDraftState state = getOrCreatePlaylistDraftState(context); + if (state == null) + { + return PlaylistDraftMutationResult.error("Playlist no longer exists."); + } + if (position < 1 || position > state.items.size()) + { + return PlaylistDraftMutationResult.error("Position must be between 1 and " + state.items.size() + "."); + } + state.items.remove(position - 1); + state.dirty = true; + state.revision++; + return PlaylistDraftMutationResult.success(state.items.size(), true, state.revision); + } + + public PlaylistDraftMutationResult movePlaylistDraftItem(PlaylistDraftContext context, int fromPosition, int toPosition) + { + PlaylistDraftState state = getOrCreatePlaylistDraftState(context); + if (state == null) + { + return PlaylistDraftMutationResult.error("Playlist no longer exists."); + } + int size = state.items.size(); + if (fromPosition < 1 || fromPosition > size || toPosition < 1 || toPosition > size) + { + return PlaylistDraftMutationResult.error("Positions must be between 1 and " + size + "."); + } + if (fromPosition == toPosition) + { + return PlaylistDraftMutationResult.success(size, state.dirty, state.revision); + } + String item = state.items.remove(fromPosition - 1); + state.items.add(toPosition - 1, item); + state.dirty = true; + state.revision++; + return PlaylistDraftMutationResult.success(state.items.size(), true, state.revision); + } + + public PlaylistDraftMutationResult savePlaylistDraft(PlaylistDraftContext context) + { + PlaylistDraftState state = playlistDrafts.get(toDraftKey(context)); + if (state == null) + { + return PlaylistDraftMutationResult.error("There are no unsaved changes."); + } + if (!state.dirty) + { + return PlaylistDraftMutationResult.error("There are no unsaved changes."); + } + String content = String.join(System.lineSeparator(), state.items); + PlaylistLoader.PlaylistResult<Void> writeResult = bot.getPlaylistLoader() + .writePlaylistResult(context.playlistName(), content); + if (!writeResult.isSuccess()) + { + return PlaylistDraftMutationResult.error(mapPlaylistErrorToMessage(writeResult.getError())); + } + state.dirty = false; + state.revision++; + return PlaylistDraftMutationResult.success(state.items.size(), false, state.revision); + } + + public void discardPlaylistDraft(PlaylistDraftContext context) + { + playlistDrafts.remove(toDraftKey(context)); + } + + public boolean isPlaylistDraftDirty(PlaylistDraftContext context) + { + PlaylistDraftState state = playlistDrafts.get(toDraftKey(context)); + return state != null && state.dirty; + } + + public int getPlaylistTrackCount(PlaylistDraftContext context) + { + PlaylistDraftState state = playlistDrafts.get(toDraftKey(context)); + if (state != null) + { + return state.items.size(); + } + PlaylistLoader.PlaylistResult<Playlist> playlistResult = bot.getPlaylistLoader().getPlaylistResult(context.playlistName()); + if (!playlistResult.isSuccess()) + { + return -1; + } + return playlistResult.getValue().getItems().size(); + } + + private PlaylistDraftKey toDraftKey(PlaylistDraftContext context) + { + return new PlaylistDraftKey( + context.guildId(), + context.channelId(), + context.messageId(), + context.userId(), + context.playlistName() + ); + } + + private PlaylistDraftState getOrCreatePlaylistDraftState(PlaylistDraftContext context) + { + PlaylistDraftKey key = toDraftKey(context); + PlaylistDraftState existing = playlistDrafts.get(key); + if (existing != null) + { + return existing; + } + + PlaylistLoader.PlaylistResult<Playlist> playlistResult = bot.getPlaylistLoader() + .getPlaylistResult(context.playlistName()); + if (!playlistResult.isSuccess()) + { + return null; + } + Playlist playlist = playlistResult.getValue(); + PlaylistDraftState created = new PlaylistDraftState(new ArrayList<>(playlist.getItems())); + PlaylistDraftState raced = playlistDrafts.putIfAbsent(key, created); + return raced != null ? raced : created; + } + + private static final String COULD_NOT_LOAD_LINE = "`[?:??]` **Could not load**"; + + /** + * Asynchronously loads the first N playlist URLs and invokes the callback with formatted track lines + * (same style as Queue/History: duration + linked title). Uses a unique load identifier per index so + * loads run in parallel. If the playlist does not exist, the callback is invoked with null. + * + * @param playlistName name of the saved playlist + * @param maxPreview maximum number of preview entries to load (e.g. 5) + * @param callback invoked with the result, or null if playlist not found + */ + public void loadPlaylistPreviewWithTracks(String playlistName, int maxPreview, + Consumer<PlaylistPreviewWithTracks> callback) + { + loadPlaylistLines(playlistName, 0, maxPreview, false, result -> + { + if (result == null) + { + callback.accept(null); + return; + } + callback.accept(new PlaylistPreviewWithTracks( + result.playlistName, + result.totalItems, + result.hasMore, + result.formattedLines + )); + }); + } + + /** + * Asynchronously loads one page of playlist URLs and resolves each to a formatted track line + * (same style as Queue/History: duration + linked title). + * + * @param playlistName playlist name + * @param page 1-based page number + * @param pageSize page size (e.g. 10) + * @param callback invoked with page result, or null when playlist does not exist + */ + public void loadPlaylistPageWithTracks(String playlistName, int page, int pageSize, + Consumer<PlaylistTracksPageInfo> callback) + { + int safePage = Math.max(1, page); + int safePageSize = Math.max(1, pageSize); + int startIndex = (safePage - 1) * safePageSize; + loadPlaylistLines(playlistName, startIndex, safePageSize, false, callback); + } + + /** + * Same as {@link #loadPlaylistPageWithTracks(String, int, int, Consumer)} but reads from the current + * interaction draft when one exists for the given context. + */ + public void loadPlaylistPageWithTracks(PlaylistDraftContext context, int page, int pageSize, + Consumer<PlaylistTracksPageInfo> callback) + { + int safePage = Math.max(1, page); + int safePageSize = Math.max(1, pageSize); + int startIndex = (safePage - 1) * safePageSize; + + PlaylistDraftState draft = playlistDrafts.get(toDraftKey(context)); + if (draft != null) + { + loadPlaylistLines(context.playlistName(), draft.items, startIndex, safePageSize, draft.dirty, callback); + return; + } + loadPlaylistLines(context.playlistName(), startIndex, safePageSize, false, callback); + } + + /** + * Returns the playlist item URL at a 1-based position, or null when missing. + */ + public String getPlaylistTrackUrlAtPosition(String playlistName, int position) + { + PlaylistLoader.PlaylistResult<Playlist> playlistResult = bot.getPlaylistLoader().getPlaylistResult(playlistName); + if (!playlistResult.isSuccess()) + { + return null; + } + List<String> items = playlistResult.getValue().getItems(); + if (position < 1 || position > items.size()) + { + return null; + } + return items.get(position - 1); + } + + /** + * Returns the draft-aware playlist item URL at a 1-based position, or null when missing. + */ + public String getPlaylistTrackUrlAtPosition(PlaylistDraftContext context, int position) + { + PlaylistDraftState draft = playlistDrafts.get(toDraftKey(context)); + if (draft != null) + { + if (position < 1 || position > draft.items.size()) + { + return null; + } + return draft.items.get(position - 1); + } + return getPlaylistTrackUrlAtPosition(context.playlistName(), position); + } + + /** + * Loads a URL and adds it to the front of queue, then starts it immediately. + */ + public void playNowFromUrl(Guild guild, Member member, String url, TextChannel channel, OutputAdapter output) + { + if (url == null || url.isBlank()) + { + output.replyError("That playlist entry could not be loaded."); + return; + } + bot.getPlayerManager().loadItemOrdered(guild, url, bot.getAudioLoadWrapper().wrap(url, new AudioLoadResultHandler() + { + private void loadSingle(AudioTrack track) + { + TrackAddResult result = addTrackToFront(guild, member, track, url, channel); + if (result == null) + { + output.replyError(formatTooLongError(track)); + return; + } + AudioHandler handler = getHandler(guild); + if (handler != null && handler.getPlayer().getPlayingTrack() != null) + { + handler.getPlayer().stopTrack(); + } + output.replySuccess("Now playing **" + FormatUtil.filter(FormatUtil.getTrackTitle(track)) + "**"); + } + + @Override + public void trackLoaded(AudioTrack track) + { + loadSingle(track); + } + + @Override + public void playlistLoaded(AudioPlaylist ap) + { + AudioTrack single; + if (ap.isSearchResult() && !ap.getTracks().isEmpty()) + { + single = ap.getTracks().get(0); + } + else if (ap.getSelectedTrack() != null) + { + single = ap.getSelectedTrack(); + } + else if (!ap.getTracks().isEmpty()) + { + single = ap.getTracks().get(0); + } + else + { + output.replyError("That playlist entry could not be loaded."); + return; + } + loadSingle(single); + } + + @Override + public void noMatches() + { + output.replyError("No matches found for that playlist entry."); + } + + @Override + public void loadFailed(FriendlyException exception) + { + output.replyError("Error loading playlist entry: " + exception.getMessage()); + } + })); + } + + private void loadPlaylistLines(String playlistName, int startIndex, int maxCount, + boolean dirty, Consumer<PlaylistTracksPageInfo> callback) + { + PlaylistLoader.PlaylistResult<Playlist> playlistResult = bot.getPlaylistLoader().getPlaylistResult(playlistName); + if (!playlistResult.isSuccess()) + { + callback.accept(null); + return; + } + Playlist playlist = playlistResult.getValue(); + loadPlaylistLines(playlist.getName(), playlist.getItems(), startIndex, maxCount, dirty, callback); + } + + private void loadPlaylistLines(String playlistName, List<String> sourceItems, int startIndex, int maxCount, + boolean dirty, Consumer<PlaylistTracksPageInfo> callback) + { + List<String> items = sourceItems == null ? List.of() : sourceItems; + int safeStartIndex = Math.max(0, startIndex); + if (safeStartIndex >= items.size()) + { + callback.accept(new PlaylistTracksPageInfo( + playlistName, items.size(), safeStartIndex, false, dirty, new ArrayList<>())); + return; + } + + int toLoad = Math.min(Math.max(0, maxCount), items.size() - safeStartIndex); + if (toLoad == 0) + { + callback.accept(new PlaylistTracksPageInfo( + playlistName, items.size(), safeStartIndex, items.size() > safeStartIndex, dirty, new ArrayList<>())); + return; + } + + String[] lines = new String[toLoad]; + AtomicInteger completed = new AtomicInteger(0); + Runnable maybeDone = () -> + { + if (completed.incrementAndGet() == toLoad) + { + callback.accept(new PlaylistTracksPageInfo( + playlistName, + items.size(), + safeStartIndex, + safeStartIndex + toLoad < items.size(), + dirty, + Arrays.asList(lines) + )); + } + }; + + for (int i = 0; i < toLoad; i++) + { + int index = i; + String url = items.get(safeStartIndex + i); + Object orderingId = "playlist-lines-" + playlistName + "-" + (safeStartIndex + i); + bot.getPlayerManager().loadItemOrdered(orderingId, url, new AudioLoadResultHandler() + { + @Override + public void trackLoaded(AudioTrack track) + { + lines[index] = FormatUtil.formatTrackLineForEmbed(track); + maybeDone.run(); + } + + @Override + public void playlistLoaded(AudioPlaylist ap) + { + AudioTrack single; + if (ap.isSearchResult() && !ap.getTracks().isEmpty()) + { + single = ap.getTracks().get(0); + } + else if (ap.getSelectedTrack() != null) + { + single = ap.getSelectedTrack(); + } + else if (!ap.getTracks().isEmpty()) + { + single = ap.getTracks().get(0); + } + else + { + lines[index] = COULD_NOT_LOAD_LINE; + maybeDone.run(); + return; + } + lines[index] = FormatUtil.formatTrackLineForEmbed(single); + maybeDone.run(); + } + + @Override + public void noMatches() + { + lines[index] = COULD_NOT_LOAD_LINE; + maybeDone.run(); + } + + @Override + public void loadFailed(FriendlyException exception) + { + lines[index] = COULD_NOT_LOAD_LINE; + maybeDone.run(); + } + }); + } + } + + /** + * Saves the current playback history as a playlist file. Rejects if a playlist with the name already exists. + * Requires DJ permission or bot owner. + * + * @param guild The guild + * @param member The member + * @param playlistName The name for the new playlist (sanitized) + * @param output The output adapter + */ + public void saveHistoryAsPlaylist(Guild guild, Member member, String playlistName, OutputAdapter output) + { + if (!canSaveHistoryAsPlaylist(guild, member)) + { + output.replyError("You need to be a DJ or the bot owner to save history as a playlist!"); + return; + } + + AudioHandler handler = getHandler(guild); + if (handler == null) + { + output.replyError("There is no player in this server!"); + return; + } + + var history = handler.getQueue().getHistory(); + if (!requireHistoryEnabled(history, output)) + { + return; + } + + List<QueuedTrack> previous = handler.getPreviousTracks(); + List<String> uris = extractHttpUrisFromHistory(previous); + if (uris.isEmpty()) + { + output.replyWarning("No valid track URLs in history to save (only http(s) sources are stored)."); + return; + } + + String sanitized = playlistName.replaceAll("\\s+", "_").replaceAll("[*?|\\/\":<>]", ""); + if (sanitized.isEmpty()) + { + output.replyError("Please provide a valid playlist name!"); + return; + } + + if (bot.getPlaylistLoader().getPlaylistResult(sanitized).isSuccess()) + { + output.replyError("Playlist `" + sanitized + "` already exists! Choose a different name."); + return; + } + + PlaylistLoader.PlaylistResult<Void> createResult = bot.getPlaylistLoader().createPlaylistResult(sanitized); + if (!createResult.isSuccess()) + { + output.replyError(mapPlaylistErrorToMessage(createResult.getError())); + return; + } + + String content = String.join("\r\n", uris); + PlaylistLoader.PlaylistResult<Void> writeResult = bot.getPlaylistLoader().writePlaylistResult(sanitized, content); + if (!writeResult.isSuccess()) + { + output.replyError(mapPlaylistErrorToMessage(writeResult.getError())); + return; + } + output.replySuccess("Saved " + uris.size() + " tracks from history to playlist `" + sanitized + "`!"); + } + + private boolean canSaveHistoryAsPlaylist(Guild guild, Member member) + { + return bot.getConfig().getOwnerId() == member.getIdLong() + || DJCommand.checkDJPermission(bot, guild, member); + } + + /** + * Extracts http(s) URIs from history tracks for playlist persistence. + */ + private static List<String> extractHttpUrisFromHistory(List<QueuedTrack> previous) + { + List<String> uris = new ArrayList<>(); + for (QueuedTrack qt : previous) + { + String uri = qt.getTrack().getInfo().uri; + if (uri != null && uri.startsWith("http")) + { + uris.add(uri); + } + } + return uris; + } + + private boolean isInvalidPosition(AbstractQueue<QueuedTrack> queue, int position) + { + return position < 1 || position > queue.size(); + } + + private boolean requireHistoryEnabled(PlaybackHistory<QueuedTrack> history, OutputAdapter output) + { + if (history.getMaxSize() == 0) + { + output.replyError(HISTORY_DISABLED_MESSAGE); + return false; + } + return true; + } + + /** + * Validates a queue position and sends an error message if invalid. + * + * @param handler The audio handler + * @param position The 1-based position to validate + * @param output The output adapter for error messages + * @return true if the position is valid, false otherwise + */ + private boolean validateQueuePosition(AudioHandler handler, int position, OutputAdapter output) + { + int size = handler.getQueue().size(); + if (position < 1 || position > size) + { + output.replyError("Position must be a valid integer between 1 and " + size + "!"); + return false; + } + return true; + } + + /** + * Checks if the queue is non-empty and sends an error message if empty. + * + * @param handler The audio handler + * @param output The output adapter for error messages + * @return true if the queue is non-empty, false otherwise + */ + private boolean requireNonEmptyQueue(AudioHandler handler, OutputAdapter output) + { + if (handler.getQueue().isEmpty()) + { + output.replyError("There is nothing in the queue!"); + return false; + } + return true; + } + + // ========== Inner Classes ========== + + /** + * Result of adding a track to the queue. + */ + public static class TrackAddResult + { + public final int position; + public final String formattedMessage; + public final String trackTitle; + + public TrackAddResult(int position, String formattedMessage, String trackTitle) + { + this.position = position; + this.formattedMessage = formattedMessage; + this.trackTitle = trackTitle; + } + } + + /** + * Data class containing queue information for display. + */ + public static class QueueInfo + { + public final String[] tracks; + public final long totalDuration; + public final String nowPlayingTitle; + public final String statusEmoji; + public final RepeatMode repeatMode; + public final QueueType queueType; + public final Object nowPlayingMessage; + public final Object noMusicMessage; + + public QueueInfo(String[] tracks, long totalDuration, String nowPlayingTitle, String statusEmoji, + RepeatMode repeatMode, QueueType queueType, Object nowPlayingMessage, Object noMusicMessage) + { + this.tracks = tracks; + this.totalDuration = totalDuration; + this.nowPlayingTitle = nowPlayingTitle; + this.statusEmoji = statusEmoji; + this.repeatMode = repeatMode; + this.queueType = queueType; + this.nowPlayingMessage = nowPlayingMessage; + this.noMusicMessage = noMusicMessage; + } + + public boolean isEmpty() + { + return tracks.length == 0; + } + } + + /** + * Data class containing playback history information for display. + */ + public static class HistoryInfo + { + public final String[] tracks; + public final long totalDuration; + public final int maxSize; + public final boolean disabled; + + public HistoryInfo(String[] tracks, long totalDuration, int maxSize) + { + this(tracks, totalDuration, maxSize, maxSize == 0); + } + + public HistoryInfo(String[] tracks, long totalDuration, int maxSize, boolean disabled) + { + this.tracks = tracks; + this.totalDuration = totalDuration; + this.maxSize = maxSize; + this.disabled = disabled; + } + + public boolean isEmpty() + { + return tracks.length == 0; + } + + public boolean isDisabled() + { + return disabled; + } + } + + /** + * Result for listing playlists where storage errors are explicit. + */ + public static class PlaylistNamesInfo + { + public final List<String> names; + public final String errorMessage; + + private PlaylistNamesInfo(List<String> names, String errorMessage) + { + this.names = names; + this.errorMessage = errorMessage; + } + + public static PlaylistNamesInfo success(List<String> names) + { + return new PlaylistNamesInfo(names, null); + } + + public static PlaylistNamesInfo error(String errorMessage) + { + return new PlaylistNamesInfo(List.of(), errorMessage); + } + + public boolean hasError() + { + return errorMessage != null; + } + } + + /** + * Metadata used for playlist details previews in interactions. + */ + public static class PlaylistDetailsInfo + { + public final String playlistName; + public final int totalItems; + public final List<String> previewItems; + public final boolean hasMore; + + public PlaylistDetailsInfo(String playlistName, int totalItems, List<String> previewItems, boolean hasMore) + { + this.playlistName = playlistName; + this.totalItems = totalItems; + this.previewItems = previewItems; + this.hasMore = hasMore; + } + } + + /** + * Result of loading playlist preview with resolved track lines (duration + title, same style as Queue/History). + */ + public static class PlaylistPreviewWithTracks + { + public final String playlistName; + public final int totalItems; + public final boolean hasMore; + public final List<String> formattedLines; + + public PlaylistPreviewWithTracks(String playlistName, int totalItems, boolean hasMore, List<String> formattedLines) + { + this.playlistName = playlistName; + this.totalItems = totalItems; + this.hasMore = hasMore; + this.formattedLines = formattedLines; + } + } + + /** + * Result for one page of playlist tracks resolved to formatted lines. + */ + public static class PlaylistTracksPageInfo + { + public final String playlistName; + public final int totalItems; + public final int startIndex; + public final boolean hasMore; + public final boolean dirty; + public final List<String> formattedLines; + + public PlaylistTracksPageInfo(String playlistName, int totalItems, int startIndex, + boolean hasMore, boolean dirty, List<String> formattedLines) + { + this.playlistName = playlistName; + this.totalItems = totalItems; + this.startIndex = startIndex; + this.hasMore = hasMore; + this.dirty = dirty; + this.formattedLines = formattedLines; + } + } + + /** + * Identifies a playlist details draft session for one interaction message and user. + */ + public record PlaylistDraftContext(long guildId, long channelId, long messageId, long userId, String playlistName) + { + } + + private record PlaylistDraftKey(long guildId, long channelId, long messageId, long userId, String playlistName) + { + } + + private static class PlaylistDraftState + { + private final List<String> items; + private boolean dirty; + private int revision; + + private PlaylistDraftState(List<String> items) + { + this.items = items; + this.dirty = false; + this.revision = 0; + } + } + + public static class PlaylistDraftMutationResult + { + public final boolean success; + public final String errorMessage; + public final int totalItems; + public final boolean dirty; + public final int revision; + + private PlaylistDraftMutationResult(boolean success, String errorMessage, int totalItems, + boolean dirty, int revision) + { + this.success = success; + this.errorMessage = errorMessage; + this.totalItems = totalItems; + this.dirty = dirty; + this.revision = revision; + } + + public static PlaylistDraftMutationResult success(int totalItems, boolean dirty, int revision) + { + return new PlaylistDraftMutationResult(true, null, totalItems, dirty, revision); + } + + public static PlaylistDraftMutationResult error(String errorMessage) + { + return new PlaylistDraftMutationResult(false, errorMessage, 0, false, 0); + } + } + + /** + * Adapter interface for abstracting output operations. + * <p> + * This interface allows services to be command-type agnostic - the same service + * methods work for text commands, slash commands, and button interactions. + * Each command type provides its own implementation. + * + * @see com.jagrosh.jmusicbot.commands.BaseOutputAdapter + * @see com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters + * @see com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters + */ + public interface OutputAdapter + { + void replySuccess(String content); + void replyError(String content); + void replyWarning(String content); + void editMessage(String content); + void editMessage(String content, Consumer<Message> onSuccess); + void editNowPlaying(AudioHandler handler); + void editNoMusic(AudioHandler handler); + void onShowHelp(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/service/SearchService.java b/src/main/java/com/jagrosh/jmusicbot/service/SearchService.java new file mode 100644 index 0000000..de68fd5 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/service/SearchService.java @@ -0,0 +1,207 @@ +/* + * 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.service; + +import com.jagrosh.jmusicbot.Bot; +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.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Service for search-related operations. + * Uses PlayerService for shared track operations (validation, queue addition). + */ +public class SearchService +{ + private static final Logger LOG = LoggerFactory.getLogger(SearchService.class); + + private final Bot bot; + + public SearchService(Bot bot) + { + this.bot = bot; + LOG.info("SearchService initialized"); + } + + /** + * Performs a search and handles the results. + * + * @param guild The guild + * @param member The member requesting search + * @param query The search query + * @param searchPrefix The search prefix (e.g., "ytsearch:" or "scsearch:") + * @param channel The text channel for request metadata + * @param callback The callback for handling results + */ + public void search(Guild guild, Member member, String query, String searchPrefix, + TextChannel channel, SearchCallback callback) + { + LOG.debug("Search requested: guild={}, user={}, query=\"{}\", prefix={}", + guild.getId(), member.getUser().getName(), query, searchPrefix); + + if (query == null || query.isEmpty()) + { + LOG.debug("Search rejected: empty query"); + callback.onError("Please include a query."); + return; + } + + LOG.info("Executing search: guild={}, user={}, query=\"{}\"", + guild.getId(), member.getUser().getName(), query); + + bot.getPlayerManager().loadItemOrdered(guild, searchPrefix + query, + bot.getAudioLoadWrapper().wrap(searchPrefix + query, new SearchResultHandler(guild, member, query, channel, callback))); + } + + /** + * Formats search result choices for display. + * + * @param tracks The tracks from search results + * @param limit Maximum number of choices to return + * @return Array of formatted choice strings + */ + public String[] formatSearchChoices(List<AudioTrack> tracks, int limit) + { + int count = Math.min(limit, tracks.size()); + String[] choices = new String[count]; + for (int i = 0; i < count; i++) + { + AudioTrack track = tracks.get(i); + String title = FormatUtil.getTrackTitle(track); + choices[i] = "`[" + TimeUtil.formatTime(track.getDuration()) + "]` [**" + + FormatUtil.filter(title == null ? "" : title) + "**](" + track.getInfo().uri + ")"; + } + return choices; + } + + /** + * Callback interface for search results. + */ + public interface SearchCallback + { + /** + * Called when a single track is loaded. + */ + void onTrackLoaded(AudioTrack track, int queuePosition, String formattedMessage); + + /** + * Called when search results (playlist) are loaded. + */ + void onSearchResults(AudioPlaylist playlist, String[] formattedChoices); + + /** + * Called when no matches are found. + */ + void onNoMatches(String query); + + /** + * Called when loading fails. + */ + void onLoadFailed(String errorMessage); + + /** + * Called for general errors. + */ + void onError(String message); + } + + private class SearchResultHandler implements AudioLoadResultHandler + { + private final Guild guild; + private final Member member; + private final String query; + private final TextChannel channel; + private final SearchCallback callback; + + private SearchResultHandler(Guild guild, Member member, String query, + TextChannel channel, SearchCallback callback) + { + this.guild = guild; + this.member = member; + this.query = query; + this.channel = channel; + this.callback = callback; + } + + @Override + public void trackLoaded(AudioTrack track) + { + LOG.debug("Search result - single track: guild={}, track=\"{}\"", + guild.getId(), track.getInfo().title); + + MusicService musicService = bot.getMusicService(); + + // Use shared track operations from MusicService + MusicService.TrackAddResult result = musicService.addTrackToQueue(guild, member, track, query, channel); + if (result == null) + { + LOG.warn("Search track rejected (too long): guild={}, track=\"{}\"", + guild.getId(), track.getInfo().title); + callback.onLoadFailed(musicService.formatTooLongError(track)); + return; + } + + LOG.info("Search result added to queue: guild={}, track=\"{}\", position={}", + guild.getId(), track.getInfo().title, result.position); + callback.onTrackLoaded(track, result.position, result.formattedMessage); + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) + { + LOG.debug("Search results loaded: guild={}, query=\"{}\", results={}", + guild.getId(), query, playlist.getTracks().size()); + + String[] choices = formatSearchChoices(playlist.getTracks(), 4); + callback.onSearchResults(playlist, choices); + } + + @Override + public void noMatches() + { + LOG.debug("Search - no matches: guild={}, query=\"{}\"", guild.getId(), query); + callback.onNoMatches(query); + } + + @Override + public void loadFailed(FriendlyException throwable) + { + if (throwable.severity == Severity.COMMON) + { + LOG.warn("Search load failed (common): guild={}, query=\"{}\", error={}", + guild.getId(), query, throwable.getMessage()); + callback.onLoadFailed("Error loading: " + throwable.getMessage()); + } + else + { + LOG.error("Search load failed (severe): guild={}, query=\"{}\"", + guild.getId(), query, throwable); + callback.onLoadFailed("Error loading track."); + } + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/NowPlayingButtonsMode.java b/src/main/java/com/jagrosh/jmusicbot/settings/NowPlayingButtonsMode.java new file mode 100644 index 0000000..b2670f8 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/settings/NowPlayingButtonsMode.java @@ -0,0 +1,49 @@ +package com.jagrosh.jmusicbot.settings; + +import com.jagrosh.jmusicbot.utils.EnumUtil; + +public enum NowPlayingButtonsMode +{ + INHERIT("inherit"), + ON("on"), + OFF("off"); + + private final String userInputValue; + + NowPlayingButtonsMode(String userInputValue) + { + this.userInputValue = userInputValue; + } + + public static NowPlayingButtonsMode valueOfOrDefault(String value, NowPlayingButtonsMode defaultValue) + { + return EnumUtil.valueOfOrDefault(NowPlayingButtonsMode.class, value, defaultValue); + } + + public static NowPlayingButtonsMode fromInput(String input) + { + if (input == null) + return null; + String normalized = input.trim().toLowerCase(); + for (NowPlayingButtonsMode mode : values()) + { + if (mode.userInputValue.equals(normalized)) + return mode; + } + return null; + } + + public boolean resolve(boolean globalShowButtons) + { + return switch (this) { + case ON -> true; + case OFF -> false; + case INHERIT -> globalShowButtons; + }; + } + + public String getUserInputValue() + { + return userInputValue; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/NowPlayingLayoutMode.java b/src/main/java/com/jagrosh/jmusicbot/settings/NowPlayingLayoutMode.java new file mode 100644 index 0000000..fcd1e15 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/settings/NowPlayingLayoutMode.java @@ -0,0 +1,49 @@ +package com.jagrosh.jmusicbot.settings; + +import com.jagrosh.jmusicbot.utils.EnumUtil; + +public enum NowPlayingLayoutMode +{ + INHERIT("inherit"), + FULL("full"), + MINIMAL("minimal"); + + private final String userInputValue; + + NowPlayingLayoutMode(String userInputValue) + { + this.userInputValue = userInputValue; + } + + public static NowPlayingLayoutMode valueOfOrDefault(String value, NowPlayingLayoutMode defaultValue) + { + return EnumUtil.valueOfOrDefault(NowPlayingLayoutMode.class, value, defaultValue); + } + + public static NowPlayingLayoutMode fromInput(String input) + { + if (input == null) + return null; + String normalized = input.trim().toLowerCase(); + for (NowPlayingLayoutMode mode : values()) + { + if (mode.userInputValue.equals(normalized)) + return mode; + } + return null; + } + + public boolean resolve(boolean globalMinimalMessage) + { + return switch (this) { + case MINIMAL -> true; + case FULL -> false; + case INHERIT -> globalMinimalMessage; + }; + } + + public String getUserInputValue() + { + return userInputValue; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/QueueType.java b/src/main/java/com/jagrosh/jmusicbot/settings/QueueType.java new file mode 100644 index 0000000..ac3f354 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/settings/QueueType.java @@ -0,0 +1,88 @@ +/* + * Copyright 2022 John Grosh <john.a.grosh@gmail.com>. + * + * 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.settings; + +import com.jagrosh.jmusicbot.queue.*; +import com.jagrosh.jmusicbot.utils.EnumUtil; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * + * @author Wolfgang Schwendtbauer + */ +public enum QueueType +{ + LINEAR("\u23E9", "Linear", LinearQueue::create), // ⏩ + FAIR("\uD83D\uDD22", "Fair", FairQueue::create); // 🔢 + + private final String userFriendlyName; + private final String emoji; + private final QueueSupplier supplier; + + QueueType(final String emoji, final String userFriendlyName, QueueSupplier supplier) + { + this.userFriendlyName = userFriendlyName; + this.emoji = emoji; + this.supplier = supplier; + } + + /** + * Parses a QueueType from a string, returning the default value if parsing fails. + * This handles invalid values gracefully (e.g., from manual editing or data corruption) + * instead of throwing IllegalArgumentException. + * + * @param value the string value to parse + * @param defaultValue the default value to return if parsing fails + * @return the parsed QueueType, or defaultValue if parsing fails + */ + public static QueueType valueOfOrDefault(String value, QueueType defaultValue) + { + return EnumUtil.valueOfOrDefault(QueueType.class, value, defaultValue); + } + + public static List<String> getNames() + { + return Arrays.stream(QueueType.values()) + .map(type -> type.name().toLowerCase()) + .collect(Collectors.toList()); + } + + /** + * Creates a new queue instance. + * + * @param previous The previous queue to copy state from, or null for initial creation + * @param maxHistorySize The maximum history size (only used when previous is null) + * @param <T> The type of items in the queue + * @return A new queue instance + */ + public <T extends Queueable> AbstractQueue<T> createInstance(AbstractQueue<T> previous, int maxHistorySize) + { + return supplier.apply(previous, maxHistorySize); + } + + public String getUserFriendlyName() + { + return userFriendlyName; + } + + public String getEmoji() + { + return emoji; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/RepeatMode.java b/src/main/java/com/jagrosh/jmusicbot/settings/RepeatMode.java new file mode 100644 index 0000000..414859a --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/settings/RepeatMode.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020 John Grosh <john.a.grosh@gmail.com>. + * + * 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.settings; + +import com.jagrosh.jmusicbot.utils.EnumUtil; + +/** + * + * @author Michaili K + */ +public enum RepeatMode +{ + OFF(null, "Off"), + ALL("\uD83D\uDD01", "All"), // 🔁 + SINGLE("\uD83D\uDD02", "Single"); // 🔂 + + private final String emoji; + private final String userFriendlyName; + + RepeatMode(String emoji, String userFriendlyName) + { + this.emoji = emoji; + this.userFriendlyName = userFriendlyName; + } + + /** + * Parses a RepeatMode from a string, returning the default value if parsing fails. + * This handles invalid values gracefully (e.g., from manual editing or data corruption) + * instead of throwing IllegalArgumentException. + * + * @param value the string value to parse + * @param defaultValue the default value to return if parsing fails + * @return the parsed RepeatMode, or defaultValue if parsing fails + */ + public static RepeatMode valueOfOrDefault(String value, RepeatMode defaultValue) + { + return EnumUtil.valueOfOrDefault(RepeatMode.class, value, defaultValue); + } + + public String getEmoji() + { + return emoji; + } + + public String getUserFriendlyName() + { + return userFriendlyName; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/Settings.java b/src/main/java/com/jagrosh/jmusicbot/settings/Settings.java new file mode 100644 index 0000000..4bf6d75 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/settings/Settings.java @@ -0,0 +1,238 @@ +/* + * Copyright 2016 John Grosh <john.a.grosh@gmail.com>. + * + * 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.settings; + +import com.jagrosh.jdautilities.command.GuildSettingsProvider; +import com.jagrosh.jmusicbot.BotConfig; +import net.dv8tion.jda.api.entities.Guild; +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 java.util.Collection; +import java.util.Collections; + +/** + * + * @author John Grosh <john.a.grosh@gmail.com> + */ +public class Settings implements GuildSettingsProvider +{ + private final SettingsManager manager; + protected long textId; + protected long voiceId; + protected long roleId; + private int volume; + private String defaultPlaylist; + private RepeatMode repeatMode; + private QueueType queueType; + private String prefix; + private double skipRatio; + private NowPlayingLayoutMode nowPlayingLayoutMode; + private NowPlayingButtonsMode nowPlayingButtonsMode; + + public Settings(SettingsManager manager, String textId, String voiceId, String roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType, NowPlayingLayoutMode nowPlayingLayoutMode, NowPlayingButtonsMode nowPlayingButtonsMode) + { + this.manager = manager; + try + { + this.textId = Long.parseLong(textId); + } + catch(NumberFormatException e) + { + this.textId = 0; + } + try + { + this.voiceId = Long.parseLong(voiceId); + } + catch(NumberFormatException e) + { + this.voiceId = 0; + } + try + { + this.roleId = Long.parseLong(roleId); + } + catch(NumberFormatException e) + { + this.roleId = 0; + } + this.volume = volume; + this.defaultPlaylist = defaultPlaylist; + this.repeatMode = repeatMode; + this.prefix = prefix; + this.skipRatio = skipRatio; + this.queueType = queueType; + this.nowPlayingLayoutMode = nowPlayingLayoutMode; + this.nowPlayingButtonsMode = nowPlayingButtonsMode; + } + + public Settings(SettingsManager manager, long textId, long voiceId, long roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType, NowPlayingLayoutMode nowPlayingLayoutMode, NowPlayingButtonsMode nowPlayingButtonsMode) + { + this.manager = manager; + this.textId = textId; + this.voiceId = voiceId; + this.roleId = roleId; + this.volume = volume; + this.defaultPlaylist = defaultPlaylist; + this.repeatMode = repeatMode; + this.prefix = prefix; + this.skipRatio = skipRatio; + this.queueType = queueType; + this.nowPlayingLayoutMode = nowPlayingLayoutMode; + this.nowPlayingButtonsMode = nowPlayingButtonsMode; + } + + // Getters + public TextChannel getTextChannel(Guild guild) + { + return guild == null ? null : guild.getTextChannelById(textId); + } + + public VoiceChannel getVoiceChannel(Guild guild) + { + return guild == null ? null : guild.getVoiceChannelById(voiceId); + } + + public Role getRole(Guild guild) + { + return guild == null ? null : guild.getRoleById(roleId); + } + + public int getVolume() + { + return volume; + } + + public String getDefaultPlaylist() + { + return defaultPlaylist; + } + + public RepeatMode getRepeatMode() + { + return repeatMode; + } + + public String getPrefix() + { + return prefix; + } + + public double getSkipRatio() + { + return skipRatio; + } + + public QueueType getQueueType() + { + return queueType; + } + + public NowPlayingLayoutMode getNowPlayingLayoutMode() + { + return nowPlayingLayoutMode; + } + + public NowPlayingButtonsMode getNowPlayingButtonsMode() + { + return nowPlayingButtonsMode; + } + + public boolean useMinimalNowPlayingMessage(BotConfig config) + { + return nowPlayingLayoutMode.resolve(config.useMinimalNowPlayingMessage()); + } + + public boolean showNowPlayingButtons(BotConfig config) + { + return nowPlayingButtonsMode.resolve(config.showNowPlayingButtons()); + } + + @Override + public Collection<String> getPrefixes() + { + return prefix == null ? Collections.emptySet() : Collections.singleton(prefix); + } + + // Setters + public void setTextChannel(TextChannel tc) + { + this.textId = tc == null ? 0 : tc.getIdLong(); + this.manager.writeSettings(); + } + + public void setVoiceChannel(VoiceChannel vc) + { + this.voiceId = vc == null ? 0 : vc.getIdLong(); + this.manager.writeSettings(); + } + + public void setDJRole(Role role) + { + this.roleId = role == null ? 0 : role.getIdLong(); + this.manager.writeSettings(); + } + + public void setVolume(int volume) + { + this.volume = volume; + this.manager.writeSettings(); + } + + public void setDefaultPlaylist(String defaultPlaylist) + { + this.defaultPlaylist = defaultPlaylist; + this.manager.writeSettings(); + } + + public void setRepeatMode(RepeatMode mode) + { + this.repeatMode = mode; + this.manager.writeSettings(); + } + + public void setPrefix(String prefix) + { + this.prefix = prefix; + this.manager.writeSettings(); + } + + public void setSkipRatio(double skipRatio) + { + this.skipRatio = skipRatio; + this.manager.writeSettings(); + } + + public void setQueueType(QueueType queueType) + { + this.queueType = queueType; + this.manager.writeSettings(); + } + + public void setNowPlayingLayoutMode(NowPlayingLayoutMode nowPlayingLayoutMode) + { + this.nowPlayingLayoutMode = nowPlayingLayoutMode; + this.manager.writeSettings(); + } + + public void setNowPlayingButtonsMode(NowPlayingButtonsMode nowPlayingButtonsMode) + { + this.nowPlayingButtonsMode = nowPlayingButtonsMode; + this.manager.writeSettings(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/SettingsManager.java b/src/main/java/com/jagrosh/jmusicbot/settings/SettingsManager.java new file mode 100644 index 0000000..53f4fb3 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/settings/SettingsManager.java @@ -0,0 +1,158 @@ +/* + * Copyright 2018 John Grosh <john.a.grosh@gmail.com>. + * + * 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.settings; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.jagrosh.jdautilities.command.GuildSettingsManager; +import com.jagrosh.jmusicbot.utils.OtherUtil; +import net.dv8tion.jda.api.entities.Guild; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.util.HashMap; +import java.util.Map; + +/** + * + * @author John Grosh (john.a.grosh@gmail.com) + */ +public class SettingsManager implements GuildSettingsManager<Settings> +{ + private final static Logger LOG = LoggerFactory.getLogger(SettingsManager.class); + private final static String SETTINGS_FILE = "serversettings.json"; + private final HashMap<Long,Settings> settings; + private final static ObjectMapper objectMapper = new ObjectMapper(); + + public SettingsManager() + { + this.settings = new HashMap<>(); + + try { + String jsonContent = new String(Files.readAllBytes(OtherUtil.getPath(SETTINGS_FILE))); + JsonNode loadedSettings = objectMapper.readTree(jsonContent); + + if (loadedSettings != null && loadedSettings.isObject()) { + for (Map.Entry<String, JsonNode> entry : loadedSettings.properties()) { + String id = entry.getKey(); + JsonNode o = entry.getValue(); + + // Legacy version support: On versions 0.3.3 and older, the repeat mode was represented as a boolean. + if (!o.has("repeat_mode") && o.has("repeat") && o.get("repeat").asBoolean()) { + ((ObjectNode) o).put("repeat_mode", RepeatMode.ALL.name()); + } + + settings.put(Long.parseLong(id), new Settings(this, + o.has("text_channel_id") ? o.get("text_channel_id").asText() : null, + o.has("voice_channel_id")? o.get("voice_channel_id").asText() : null, + o.has("dj_role_id") ? o.get("dj_role_id").asText() : null, + o.has("volume") ? o.get("volume").asInt() : 100, + o.has("default_playlist")? o.get("default_playlist").asText() : null, + o.has("repeat_mode") ? RepeatMode.valueOfOrDefault(o.get("repeat_mode").asText(), RepeatMode.OFF) : RepeatMode.OFF, + o.has("prefix") ? o.get("prefix").asText() : null, + o.has("skip_ratio") ? o.get("skip_ratio").asDouble() : -1, + o.has("queue_type") ? QueueType.valueOfOrDefault(o.get("queue_type").asText(), QueueType.FAIR) : QueueType.FAIR, + o.has("now_playing_layout_mode") + ? NowPlayingLayoutMode.valueOfOrDefault(o.get("now_playing_layout_mode").asText(), NowPlayingLayoutMode.INHERIT) + : NowPlayingLayoutMode.INHERIT, + o.has("now_playing_buttons_mode") + ? NowPlayingButtonsMode.valueOfOrDefault(o.get("now_playing_buttons_mode").asText(), NowPlayingButtonsMode.INHERIT) + : NowPlayingButtonsMode.INHERIT)); + } + } + } catch (NoSuchFileException e) { + // create an empty json file + try { + LOG.info("serversettings.json will be created in " + OtherUtil.getPath("serversettings.json").toAbsolutePath()); + ObjectNode emptyJson = JsonNodeFactory.instance.objectNode(); + Files.write(OtherUtil.getPath("serversettings.json"), objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(emptyJson)); + } catch(IOException ex) { + LOG.warn("Failed to create new settings file: "+ex); + } + return; + } catch(IOException e) { + LOG.warn("Failed to load server settings: "+e); + } + + LOG.info("serversettings.json loaded from " + OtherUtil.getPath("serversettings.json").toAbsolutePath()); + } + + /** + * Gets non-null settings for a Guild + * + * @param guild the guild to get settings for + * @return the existing settings, or new settings for that guild + */ + @Override + public Settings getSettings(Guild guild) + { + return getSettings(guild.getIdLong()); + } + + public Settings getSettings(long guildId) + { + return settings.computeIfAbsent(guildId, id -> createDefaultSettings()); + } + + private Settings createDefaultSettings() + { + return new Settings(this, 0, 0, 0, 100, null, RepeatMode.OFF, null, -1, QueueType.FAIR, + NowPlayingLayoutMode.INHERIT, NowPlayingButtonsMode.INHERIT); + } + + protected void writeSettings() + { + ObjectNode obj = JsonNodeFactory.instance.objectNode(); + settings.keySet().stream().forEach(key -> { + ObjectNode o = JsonNodeFactory.instance.objectNode(); + Settings s = settings.get(key); + if(s.textId!=0) + o.put("text_channel_id", Long.toString(s.textId)); + if(s.voiceId!=0) + o.put("voice_channel_id", Long.toString(s.voiceId)); + if(s.roleId!=0) + o.put("dj_role_id", Long.toString(s.roleId)); + if(s.getVolume()!=100) + o.put("volume", s.getVolume()); + if(s.getDefaultPlaylist() != null) + o.put("default_playlist", s.getDefaultPlaylist()); + if(s.getRepeatMode()!=RepeatMode.OFF) + o.put("repeat_mode", s.getRepeatMode().name()); + if(s.getPrefix() != null) + o.put("prefix", s.getPrefix()); + if(s.getSkipRatio() != -1) + o.put("skip_ratio", s.getSkipRatio()); + if(s.getQueueType() != QueueType.FAIR) + o.put("queue_type", s.getQueueType().name()); + if(s.getNowPlayingLayoutMode() != NowPlayingLayoutMode.INHERIT) + o.put("now_playing_layout_mode", s.getNowPlayingLayoutMode().name()); + if(s.getNowPlayingButtonsMode() != NowPlayingButtonsMode.INHERIT) + o.put("now_playing_buttons_mode", s.getNowPlayingButtonsMode().name()); + obj.set(Long.toString(key), o); + }); + try { + // Use Jackson with pretty printing (indentation) + Files.write(OtherUtil.getPath(SETTINGS_FILE), objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(obj)); + } catch(IOException ex){ + LOG.warn("Failed to write to file: "+ex); + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/ChannelClearHelper.java b/src/main/java/com/jagrosh/jmusicbot/utils/ChannelClearHelper.java new file mode 100644 index 0000000..23352ed --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/ChannelClearHelper.java @@ -0,0 +1,326 @@ +/* + * 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.utils; + +import com.jagrosh.jmusicbot.settings.Settings; +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.exceptions.RateLimitedException; +import net.dv8tion.jda.api.Permission; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Shared logic for clearing all messages in the guild's configured text channel. + * Used by both the slash and prefix clearchannel commands. + */ +public final class ChannelClearHelper +{ + private static final int HISTORY_BATCH_SIZE = 100; + private static final long DISCORD_BULK_DELETE_MAX_AGE_DAYS = 14; + + private ChannelClearHelper() {} + + /** + * Callbacks for clear-channel operations. Implement to send replies (slash or prefix). + */ + public interface ClearChannelCallback + { + void onNoChannelConfigured(); + + void onNoPermission(); + + void onClearingStarted(); + + void onCleared(int count); + + void onError(Throwable t); + } + + /** + * Callback for purge-only operations (after channel is already resolved). + */ + public interface PurgeCallback + { + void onCleared(int count); + + void onError(Throwable t); + } + + public record ClearPolicy(int deleteLimit, long ageDays) + { + public static ClearPolicy of(int deleteLimit, long ageDays) + { + return new ClearPolicy(Math.max(0, deleteLimit), Math.max(0L, ageDays)); + } + } + + private record ClearSweepState(Instant startedAt, String beforeMessageId) {} + + /** + * Runs the purge on the given channel. Use this after deferring a slash reply. + * + * @param channel the channel to clear + * @param callback onCleared(count) or onError(t) when done + * @param policy delete policy for max count and age window + */ + public static void purgeChannel(TextChannel channel, PurgeCallback callback, ClearPolicy policy) + { + AtomicInteger totalDeleted = new AtomicInteger(0); + ClearSweepState sweepState = new ClearSweepState(Instant.now(), null); + takeAndProcessBatch(channel, totalDeleted, policy, sweepState, new ClearChannelCallback() + { + @Override + public void onNoChannelConfigured() { /* not used */ } + + @Override + public void onNoPermission() { /* not used */ } + + @Override + public void onClearingStarted() { /* not used */ } + + @Override + public void onCleared(int count) + { + callback.onCleared(count); + } + + @Override + public void onError(Throwable t) + { + callback.onError(t); + } + }); + } + + /** + * Resolves the target channel from settings, checks permissions, then runs the purge asynchronously. + * Callbacks are invoked on the calling thread for validation errors; onClearingStarted is called + * before starting the purge, then onCleared(count) or onError(t) when done. + * + * @param guild the guild + * @param settings the guild settings (e.g. from event.getClient().getSettingsFor(guild)) + * @param policy delete policy for max count and age window + * @param callback callbacks for results and progress + */ + public static void clearConfiguredTextChannel(Guild guild, Settings settings, ClearPolicy policy, ClearChannelCallback callback) + { + TextChannel channel = settings.getTextChannel(guild); + if (channel == null) + { + callback.onNoChannelConfigured(); + return; + } + + if (!guild.getSelfMember().hasPermission(channel, Permission.MESSAGE_MANAGE)) + { + callback.onNoPermission(); + return; + } + + callback.onClearingStarted(); + + purgeChannel(channel, new PurgeCallback() + { + @Override + public void onCleared(int count) + { + callback.onCleared(count); + } + + @Override + public void onError(Throwable t) + { + callback.onError(t); + } + }, policy); + } + + private static void takeAndProcessBatch(TextChannel channel, AtomicInteger totalDeleted, ClearPolicy policy, + ClearSweepState sweepState, + ClearChannelCallback callback) + { + int remaining = policy.deleteLimit() > 0 + ? policy.deleteLimit() - totalDeleted.get() + : HISTORY_BATCH_SIZE; + if (policy.deleteLimit() > 0 && remaining <= 0) + { + callback.onCleared(totalDeleted.get()); + return; + } + int requestSize = Math.min(HISTORY_BATCH_SIZE, Math.max(1, remaining)); + + loadBatch(channel, sweepState.beforeMessageId(), requestSize, (batch, throwable) -> + { + if (throwable != null) + { + if (isRateLimit(throwable)) + callback.onCleared(totalDeleted.get()); + else + callback.onError(throwable); + return; + } + if (batch == null || batch.isEmpty()) + { + callback.onCleared(totalDeleted.get()); + return; + } + + String nextBeforeMessageId = batch.get(batch.size() - 1).getId(); + ClearSweepState nextSweepState = new ClearSweepState(sweepState.startedAt(), nextBeforeMessageId); + + Instant policyCutoff = policy.ageDays() > 0 + ? Instant.now().minus(policy.ageDays(), ChronoUnit.DAYS) + : null; + final Instant bulkCutoff = Instant.now().minus(DISCORD_BULK_DELETE_MAX_AGE_DAYS, ChronoUnit.DAYS); + List<Message> eligible = new ArrayList<>(); + for (Message m : batch) + { + Instant createdAt = m.getTimeCreated().toInstant(); + // Only delete messages that existed when the command started. + if (createdAt.isAfter(sweepState.startedAt())) + continue; + if (policyCutoff == null || createdAt.isAfter(policyCutoff)) + eligible.add(m); + } + + if (eligible.isEmpty()) + { + if (batch.size() >= requestSize) + takeAndProcessBatch(channel, totalDeleted, policy, nextSweepState, callback); + else + callback.onCleared(totalDeleted.get()); + return; + } + + List<Message> recent = new ArrayList<>(); + List<Message> old = new ArrayList<>(); + for (Message m : eligible) + { + if (m.getTimeCreated().toInstant().isAfter(bulkCutoff)) + recent.add(m); + else + old.add(m); + } + + Runnable onBatchDone = () -> + { + int total = totalDeleted.get(); + boolean hasLimit = policy.deleteLimit() > 0; + if (hasLimit && total >= policy.deleteLimit()) + { + callback.onCleared(total); + return; + } + if (batch.size() >= requestSize) + takeAndProcessBatch(channel, totalDeleted, policy, nextSweepState, callback); + else + callback.onCleared(total); + }; + + if (recent.isEmpty() && old.isEmpty()) + { + onBatchDone.run(); + return; + } + + if (!recent.isEmpty()) + { + List<CompletableFuture<Void>> purgeFutures = channel.purgeMessages(recent); + CompletableFuture.allOf(purgeFutures.toArray(new CompletableFuture[0])) + .thenRun(() -> + { + totalDeleted.addAndGet(recent.size()); + deleteOldOneByOne(old, 0, totalDeleted, callback, onBatchDone); + }) + .exceptionally(t -> + { + if (isRateLimit(t)) + callback.onCleared(totalDeleted.get()); + else + callback.onError(t); + return null; + }); + } + else + deleteOldOneByOne(old, 0, totalDeleted, callback, onBatchDone); + }); + } + + private static void loadBatch(TextChannel channel, String beforeMessageId, int requestSize, + java.util.function.BiConsumer<List<Message>, Throwable> callback) + { + if (beforeMessageId == null) + { + channel.getHistory().retrievePast(requestSize).queue( + messages -> callback.accept(messages, null), + t -> callback.accept(null, t) + ); + return; + } + + channel.getHistoryBefore(beforeMessageId, requestSize).queue( + history -> callback.accept(history.getRetrievedHistory(), null), + t -> callback.accept(null, t) + ); + } + + private static void deleteOldOneByOne(List<Message> old, int index, AtomicInteger totalDeleted, + ClearChannelCallback callback, Runnable whenDone) + { + if (index >= old.size()) + { + whenDone.run(); + return; + } + Message msg = old.get(index); + msg.delete().queue( + v -> + { + totalDeleted.incrementAndGet(); + deleteOldOneByOne(old, index + 1, totalDeleted, callback, whenDone); + }, + t -> + { + if (isRateLimit(t)) + callback.onCleared(totalDeleted.get()); + else + callback.onError(t); + } + ); + } + + private static boolean isRateLimit(Throwable throwable) + { + Throwable current = throwable; + while (current != null) + { + if (current instanceof RateLimitedException) + return true; + if (current instanceof ErrorResponseException ex && ex.getErrorResponse().name().contains("RATE")) + return true; + current = current.getCause(); + } + return false; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/ConsoleUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/ConsoleUtil.java new file mode 100644 index 0000000..3d5806a --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/ConsoleUtil.java @@ -0,0 +1,87 @@ +/* + * 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.utils; + +import java.io.PrintStream; +import javax.swing.*; + +import com.jagrosh.jmusicbot.gui.TextAreaOutputStream; + + +/** + * Utility class for redirecting System.out and System.err to a GUI text area. + * This allows early redirection during startup so that logs appear in the GUI console. + * + * <p>Supports buffer-and-replay: early startup output can be captured in a buffer + * (via {@link TeeOutputStream}) and replayed into the GUI console when the GUI + * is initialized.</p> + * + * @author Arif Banai (arif-banai) + */ +public class ConsoleUtil { + private static PrintStream consoleStream; + private static JTextArea sharedTextArea; + + /** + * Redirects System.out and System.err to a GUI text area. + * This can be called early in startup (before GUI is fully initialized) + * to capture logs that occur during configuration loading. + * + * @return The JTextArea that will receive the console output + */ + public static JTextArea redirectSystemStreams() { + return redirectSystemStreamsWithReplay(null); + } + + /** + * Redirects System.out and System.err to a GUI text area, first replaying + * any buffered early output into the text area. + * + * <p>Use this when early startup logs were captured via {@link TeeOutputStream} + * before the GUI was initialized. The buffered content is appended to the + * text area before redirection begins, so all logs appear in order.</p> + * + * @param earlyOutput Buffered output from early startup (may be null or empty) + * @return The JTextArea that will receive the console output + */ + public static JTextArea redirectSystemStreamsWithReplay(String earlyOutput) { + if (sharedTextArea == null) { + sharedTextArea = new JTextArea(); + sharedTextArea.setLineWrap(true); + sharedTextArea.setWrapStyleWord(true); + sharedTextArea.setEditable(false); + + // Replay buffered early output before redirecting streams + if (earlyOutput != null && !earlyOutput.isEmpty()) { + sharedTextArea.append(earlyOutput); + } + + consoleStream = new PrintStream(new TextAreaOutputStream(sharedTextArea)); + System.setOut(consoleStream); + System.setErr(consoleStream); + } + return sharedTextArea; + } + + /** + * Gets the shared text area if redirection has been set up, or null otherwise. + * + * @return The shared JTextArea, or null if redirection hasn't been initialized + */ + public static JTextArea getSharedTextArea() { + return sharedTextArea; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/EnumUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/EnumUtil.java new file mode 100644 index 0000000..4ca2239 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/EnumUtil.java @@ -0,0 +1,60 @@ +/* + * Copyright 2018 John Grosh <john.a.grosh@gmail.com>. + * + * 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.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for enum operations. + */ +public final class EnumUtil +{ + private static final Logger LOG = LoggerFactory.getLogger(EnumUtil.class); + + private EnumUtil() + { + // Prevent instantiation + } + + /** + * Parses an enum value from a string, returning the default value if parsing fails. + * This handles invalid values gracefully (e.g., from manual editing or data corruption) + * instead of throwing IllegalArgumentException. + * + * @param enumClass the enum class to parse into + * @param value the string value to parse + * @param defaultValue the default value to return if parsing fails + * @param <T> the enum type + * @return the parsed enum value, or defaultValue if parsing fails + */ + public static <T extends Enum<T>> T valueOfOrDefault(Class<T> enumClass, String value, T defaultValue) + { + if (value == null || value.isEmpty()) + { + return defaultValue; + } + try + { + return Enum.valueOf(enumClass, value); + } + catch (IllegalArgumentException e) + { + LOG.warn("Invalid {} value '{}', using default: {}", enumClass.getSimpleName(), value, defaultValue); + return defaultValue; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java new file mode 100644 index 0000000..b55fa8c --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java @@ -0,0 +1,162 @@ +/* + * Copyright 2016 John Grosh <john.a.grosh@gmail.com>. + * + * 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.utils; + +import com.jagrosh.jmusicbot.audio.RequestMetadata.UserInfo; +import com.sedmelluq.discord.lavaplayer.source.local.LocalAudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; + +import java.util.List; + +/** + * + * @author John Grosh <john.a.grosh@gmail.com> + */ +public class FormatUtil { + + public static String formatUsername(String username, String discrim) + { + if(discrim == null || discrim.equals("0000")) + { + return username; + } + else + { + return username + "#" + discrim; + } + } + + public static String formatUsername(UserInfo userinfo) + { + return formatUsername(userinfo.username, userinfo.discrim); + } + + public static String formatUsername(User user) + { + return formatUsername(user.getName(), user.getDiscriminator()); + } + + public static String volumeIcon(int volume) + { + if(volume == 0) + return "\uD83D\uDD07"; // 🔇 + if(volume < 30) + return "\uD83D\uDD08"; // 🔈 + if(volume < 70) + return "\uD83D\uDD09"; // 🔉 + return "\uD83D\uDD0A"; // 🔊 + } + + /** + * Generates a 12-segment progress bar for track playback. + * + * @param percent The progress as a value between 0.0 and 1.0. + * Use negative values (e.g., -1) for "no music" state (all segments empty). + * @return A string representing the progress bar with 🔘 at the current position and ▬ for other segments. + */ + public static String progressBar(double percent) + { + StringBuilder str = new StringBuilder(); + for(int i = 0; i < 12; i++) + { + if(i == (int)(percent * 12)) + str.append("\uD83D\uDD18"); // 🔘 + else + str.append("▬"); + } + return str.toString(); + } + + public static String listOfTChannels(List<TextChannel> list, String query) + { + String out = " Multiple text channels found matching \""+query+"\":"; + for(int i=0; i<6 && i<list.size(); i++) + out+="\n - "+list.get(i).getName()+" (<#"+list.get(i).getId()+">)"; + if(list.size()>6) + out+="\n**And "+(list.size()-6)+" more...**"; + return out; + } + + public static String listOfVChannels(List<VoiceChannel> list, String query) + { + String out = " Multiple voice channels found matching \""+query+"\":"; + for(int i=0; i<6 && i<list.size(); i++) + out+="\n - "+list.get(i).getAsMention()+" (ID:"+list.get(i).getId()+")"; + if(list.size()>6) + out+="\n**And "+(list.size()-6)+" more...**"; + return out; + } + + public static String listOfRoles(List<Role> list, String query) + { + String out = " Multiple roles found matching \""+query+"\":"; + for(int i=0; i<6 && i<list.size(); i++) + out+="\n - "+list.get(i).getName()+" (ID:"+list.get(i).getId()+")"; + if(list.size()>6) + out+="\n**And "+(list.size()-6)+" more...**"; + return out; + } + + public static String filter(String input) + { + return input.replace("\u202E","") + .replace("@everyone", "@\u0435veryone") // cyrillic letter e + .replace("@here", "@h\u0435re") // cyrillic letter e + .trim(); + } + + public static String getTrackTitle(AudioTrack track) { + String title = track.getInfo().title; + if (track instanceof LocalAudioTrack && (title == null || title.equals("Unknown title"))) { + String identifier = track.getIdentifier(); + int lastSeparator = Math.max(identifier.lastIndexOf('/'), identifier.lastIndexOf('\\')); + return (lastSeparator != -1) ? identifier.substring(lastSeparator + 1) : identifier; + } + + // Truncate if the title is too long for Discord displays + if (title != null && title.length() > 100) { + title = title.substring(0, 97) + "..."; + } + + return title; + } + + /** + * Formats a single track line for use in embeds (e.g. playlist details preview), in the same style as + * Queue/History: duration plus linked title, without the " - @user" part. + * + * @param track the loaded track + * @return string like {@code `[MM:SS]` [**Title**](url)} or {@code `[MM:SS]` **Title**} for non-http URIs + */ + public static String formatTrackLineForEmbed(AudioTrack track) + { + if (track == null) + { + return "`[?:??]` **Could not load**"; + } + String entry = "`[" + TimeUtil.formatTime(track.getDuration()) + "]` "; + var trackInfo = track.getInfo(); + String title = getTrackTitle(track); + String safeTitle = filter(title == null ? "" : title); + return entry + (trackInfo.uri != null && trackInfo.uri.startsWith("http") + ? "[**" + safeTitle + "**](" + trackInfo.uri + ")" + : "**" + safeTitle + "**"); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/InstanceLock.java b/src/main/java/com/jagrosh/jmusicbot/utils/InstanceLock.java new file mode 100644 index 0000000..17de897 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/InstanceLock.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 John Grosh <john.a.grosh@gmail.com>. + * + * 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.utils; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** + * Utility class to prevent multiple instances of the bot from running + * in the same directory. Uses FileChannel.tryLock() which is automatically + * released by the OS when the JVM exits (even on crash). + * + * @author John Grosh (john.a.grosh@gmail.com) + */ +public class InstanceLock { + private static final String LOCK_FILE = ".jmusicbot.lock"; + private static FileChannel channel; + private static FileLock lock; + + /** + * Attempts to acquire an exclusive lock to prevent duplicate instances. + * The lock is held for the lifetime of the JVM and automatically released + * when the process exits. + * + * @return true if the lock was acquired (no other instance running), + * false if another instance already holds the lock + */ + public static boolean tryAcquire() { + try { + Path lockPath = OtherUtil.getPath(LOCK_FILE); + channel = FileChannel.open(lockPath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE); + lock = channel.tryLock(); + return lock != null; + } catch (IOException | OverlappingFileLockException e) { + return false; + } + } + + /** + * Releases the instance lock and closes the file channel. + * Safe to call even if lock was never acquired or already released. + */ + public static void release() { + try { + if (lock != null && lock.isValid()) { + lock.release(); + } + } catch (IOException ignored) { + } finally { + lock = null; + } + try { + if (channel != null && channel.isOpen()) { + channel.close(); + } + } catch (IOException ignored) { + } finally { + channel = null; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java b/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java new file mode 100644 index 0000000..ad55e86 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java @@ -0,0 +1,269 @@ +package com.jagrosh.jmusicbot.utils; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.audio.NowPlayingInfo; +import com.jagrosh.jmusicbot.audio.RequestMetadata; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.settings.Settings; +import com.sedmelluq.discord.lavaplayer.source.local.LocalAudioTrack; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; + +public class MessageFormatter { + private static final String NP_PREFIX = "np_"; + private static final int MIN_VOLUME = 0; + private static final int MAX_VOLUME = 150; + + public static MessageCreateData buildNowPlayingMessage(Bot bot, NowPlayingInfo info) { + if (info.track == null) + return buildNoMusicPlayingMessage(bot, info); + + Settings settings = bot.getSettingsManager().getSettings(info.guild); + boolean minimalMessage = settings.useMinimalNowPlayingMessage(bot.getConfig()); + boolean showButtons = settings.showNowPlayingButtons(bot.getConfig()); + + return minimalMessage + ? buildMinimalNowPlayingMessage(bot, info, showButtons) + : buildFullNowPlayingMessage(bot, info, showButtons); + } + + private static MessageCreateData buildFullNowPlayingMessage(Bot bot, NowPlayingInfo info, boolean showButtons) { + MessageCreateBuilder mb = new MessageCreateBuilder(); + EmbedBuilder eb = new EmbedBuilder(); + eb.setColor(info.guild.getSelfMember().getColors().getPrimary()); + eb.setAuthor(info.guild.getName(), null, info.guild.getIconUrl()); + + String title = FormatUtil.filter(FormatUtil.getTrackTitle(info.track)); + try { + eb.setTitle(title, info.track.getInfo().uri); + } catch (Exception ignored) { + eb.setTitle(title); + } + + RepeatMode repeatMode = bot.getSettingsManager().getSettings(info.guild).getRepeatMode(); + eb.setDescription(buildPlaybackStatusDescription(bot, info, repeatMode, false)); + + String rawAuthor = info.track.getInfo().author; + String author = rawAuthor == null ? null : FormatUtil.filter(rawAuthor); + if (author != null && (!author.isEmpty() && !author.equalsIgnoreCase("unknown artist"))) { + eb.addField("Author", author, false); + } + + eb.addField("Duration", TimeUtil.formatTime(info.duration), true); + eb.addField("Queue", String.valueOf(info.queueSize), true); + eb.addField("Volume", info.volume + "%", true); + + if (repeatMode != RepeatMode.OFF) { + eb.addField("Repeat", repeatMode.getEmoji() + " " + repeatMode.getUserFriendlyName(), true); + } + + String requesterDetails = buildRequesterDetails(info); + if (requesterDetails != null) { + eb.addField("Requester", requesterDetails, false); + } + + String artworkUrl = resolveArtworkUrl(bot, info); + if (artworkUrl != null) { + eb.setThumbnail(artworkUrl); + } + if (info.footerInfo != null && !info.footerInfo.isEmpty()) { + eb.setFooter(info.footerInfo); + } + + mb.setEmbeds(eb.build()); + if (showButtons) { + applyNowPlayingButtons(mb, info, repeatMode); + } + return mb.build(); + } + + private static MessageCreateData buildMinimalNowPlayingMessage(Bot bot, NowPlayingInfo info, boolean showButtons) { + MessageCreateBuilder mb = new MessageCreateBuilder(); + mb.setContent(FormatUtil.filter(bot.getConfig().getSuccess() + " **Now Playing in** " + getNowPlayingLocationName(info))); + + EmbedBuilder eb = new EmbedBuilder(); + eb.setColor(info.guild.getSelfMember().getColors().getPrimary()); + String title = FormatUtil.filter(FormatUtil.getTrackTitle(info.track)); + try { + eb.setTitle(title, info.track.getInfo().uri); + } catch (Exception ignored) { + eb.setTitle(title); + } + RepeatMode repeatMode = bot.getSettingsManager().getSettings(info.guild).getRepeatMode(); + eb.setDescription(buildPlaybackStatusDescription(bot, info, repeatMode, true)); + + if (showButtons) { + applyNowPlayingButtons(mb, info, repeatMode); + } + + mb.setEmbeds(eb.build()); + return mb.build(); + } + + public static MessageCreateData buildNoMusicPlayingMessage(Bot bot, NowPlayingInfo info) { + Settings settings = bot.getSettingsManager().getSettings(info.guild); + boolean minimalMessage = settings.useMinimalNowPlayingMessage(bot.getConfig()); + + if (minimalMessage) { + return buildNoMusicPlayingMessageMinimal(bot, info); + } + + String descriptionText = bot.getConfig().showNpProgressBar() + ? AudioHandler.STOP_EMOJI + " " + FormatUtil.progressBar(-1) + " " + FormatUtil.volumeIcon(info.volume) + : AudioHandler.STOP_EMOJI + " " + FormatUtil.volumeIcon(info.volume); + + return new MessageCreateBuilder() + .setContent(FormatUtil.filter(bot.getConfig().getSuccess() + " **Now Playing...**")) + .setEmbeds(new EmbedBuilder() + .setTitle("No music playing") + .setDescription(descriptionText) + .setColor(info.guild.getSelfMember().getColors().getPrimary()) + .build()) + .build(); + } + + private static MessageCreateData buildNoMusicPlayingMessageMinimal(Bot bot, NowPlayingInfo info) { + String descriptionText = bot.getConfig().showNpProgressBar() + ? AudioHandler.STOP_EMOJI + " " + FormatUtil.progressBar(-1) + " " + FormatUtil.volumeIcon(info.volume) + : AudioHandler.STOP_EMOJI + " " + FormatUtil.volumeIcon(info.volume); + + return new MessageCreateBuilder() + .setContent(FormatUtil.filter(bot.getConfig().getSuccess() + " **Now Playing in** " + getNowPlayingLocationName(info))) + .setEmbeds(new EmbedBuilder() + .setTitle("No music playing") + .setDescription(descriptionText) + .setColor(info.guild.getSelfMember().getColors().getPrimary()) + .build()) + .build(); + } + + private static String buildPlaybackStatusLine(Bot bot, NowPlayingInfo info) { + String statusEmoji = info.isPaused ? AudioHandler.PAUSE_EMOJI : AudioHandler.PLAY_EMOJI; + String timeDisplay = "`[" + TimeUtil.formatTime(info.position) + "/" + TimeUtil.formatTime(info.duration) + "]`"; + if (bot.getConfig().showNpProgressBar()) { + double progress = info.duration > 0 ? (double) info.position / info.duration : 0; + return statusEmoji + " " + FormatUtil.progressBar(progress) + " " + timeDisplay + " " + FormatUtil.volumeIcon(info.volume); + } + return statusEmoji + " " + timeDisplay + " " + FormatUtil.volumeIcon(info.volume); + } + + private static String buildMetadataSummaryLine(NowPlayingInfo info, RepeatMode repeatMode, boolean minimalLayout) { + StringBuilder summary = new StringBuilder("**Source:** ").append(sourceNameForTrack(info)); + if (!minimalLayout) { + return summary.toString(); + } + + String queuedLabel = formatQueuedLabel(info.queueSize); + if (queuedLabel != null) { + summary.append(" • ").append(queuedLabel); + } + summary.append("\n**Volume:** ").append(info.volume).append("%"); + if (repeatMode == RepeatMode.ALL || repeatMode == RepeatMode.SINGLE) { + summary.append(" • **Repeat:** ").append(repeatMode.getUserFriendlyName()); + } + return summary.toString(); + } + + private static String buildPlaybackStatusDescription(Bot bot, NowPlayingInfo info, RepeatMode repeatMode, boolean minimalLayout) { + return buildPlaybackStatusLine(bot, info) + "\n" + buildMetadataSummaryLine(info, repeatMode, minimalLayout); + } + + private static String formatQueuedLabel(int queueSize) { + if (queueSize <= 0) { + return null; + } + return queueSize == 1 ? "1 song queued" : queueSize + " songs queued"; + } + + private static String getNowPlayingLocationName(NowPlayingInfo info) { + if (info.guild.getSelfMember() != null + && info.guild.getSelfMember().getVoiceState() != null + && info.guild.getSelfMember().getVoiceState().getChannel() != null) { + return info.guild.getSelfMember().getVoiceState().getChannel().getName(); + } + return info.guild.getName(); + } + + private static String sourceNameForTrack(NowPlayingInfo info) + { + return info.track.getSourceManager() != null + ? info.track.getSourceManager().getSourceName() + : "Unknown"; + } + + private static String resolveArtworkUrl(Bot bot, NowPlayingInfo info) + { + if (info.track instanceof LocalAudioTrack || !bot.getConfig().useNPImages()) + return null; + var artworkUrl = info.track.getInfo().artworkUrl; + if (artworkUrl == null || artworkUrl.isEmpty()) + artworkUrl = "https://img.youtube.com/vi/" + info.track.getIdentifier() + "/mqdefault.jpg"; + return artworkUrl; + } + + private static String buildRequesterDetails(NowPlayingInfo info) + { + RequestMetadata rm = info.track.getUserData(RequestMetadata.class); + if (rm != null && rm.getOwner() != 0L) + { + User u = info.guild.getJDA().getUserById(rm.user.id); + return (u == null) ? FormatUtil.formatUsername(rm.user) : u.getAsMention(); + } + return null; + } + + private static void applyNowPlayingButtons(MessageCreateBuilder mb, NowPlayingInfo info, RepeatMode repeatMode) { + boolean canGoPrevious = info.position > 5000 || info.previousTrackCount > 0; + boolean canShuffle = info.queueSize > 1; + boolean canVolDown = info.volume > MIN_VOLUME; + boolean canVolUp = info.volume < MAX_VOLUME; + + Button repeatButton = switch (repeatMode) { + case ALL -> Button.primary(nowPlayingButtonId("repeat"), "Repeat All").withEmoji(Emoji.fromUnicode("\uD83D\uDD01")); // 🔁 + case SINGLE -> Button.primary(nowPlayingButtonId("repeat"), "Repeat One").withEmoji(Emoji.fromUnicode("\uD83D\uDD02")); // 🔂 + default -> Button.secondary(nowPlayingButtonId("repeat"), "Repeat").withEmoji(Emoji.fromUnicode("\uD83D\uDD01")); // 🔁 + }; + + Button previousButton = Button.secondary(nowPlayingButtonId("previous"), "Prev").withEmoji(Emoji.fromUnicode("\u23EE")) // ⏮ + .withDisabled(!canGoPrevious); + Button pauseButton = info.isPaused + ? Button.primary(nowPlayingButtonId("pause"), "Resume").withEmoji(Emoji.fromUnicode("\u25B6")) // ▶ + : Button.primary(nowPlayingButtonId("pause"), "Pause").withEmoji(Emoji.fromUnicode("\u23F8")); // ⏸ + Button shuffleButton = Button.secondary(nowPlayingButtonId("shuffle"), "Shuffle").withEmoji(Emoji.fromUnicode("\uD83D\uDD00")) // 🔀 + .withDisabled(!canShuffle); + Button volumeDownButton = Button.secondary(nowPlayingButtonId("voldown"), "Vol -").withEmoji(Emoji.fromUnicode("\uD83D\uDD09")) // 🔉 + .withDisabled(!canVolDown); + Button volumeUpButton = Button.secondary(nowPlayingButtonId("volup"), "Vol +").withEmoji(Emoji.fromUnicode("\uD83D\uDD0A")) // 🔊 + .withDisabled(!canVolUp); + Button favoriteButton = (info.isCurrentTrackFavorited + ? Button.success(nowPlayingButtonId("favorite"), "Favorite") + : Button.secondary(nowPlayingButtonId("favorite"), "Favorite")) + .withEmoji(Emoji.fromUnicode("\u2B50")); // ⭐ + + mb.setComponents( + ActionRow.of( + previousButton, + pauseButton, + Button.secondary(nowPlayingButtonId("skip"), "Skip").withEmoji(Emoji.fromUnicode("\u23ED")), // ⏭ + Button.danger(nowPlayingButtonId("stop"), "Stop").withEmoji(Emoji.fromUnicode("\u23F9")) // ⏹ + ), + ActionRow.of( + shuffleButton, + repeatButton, + favoriteButton, + volumeDownButton, + volumeUpButton + ) + ); + } + + private static String nowPlayingButtonId(String action) { + return NP_PREFIX + action; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java new file mode 100644 index 0000000..c172629 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java @@ -0,0 +1,372 @@ +/* + * Copyright 2018 John Grosh <john.a.grosh@gmail.com>. + * + * 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.utils; + +import com.jagrosh.jmusicbot.JMusicBot; +import com.jagrosh.jmusicbot.entities.UserInteraction; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.OnlineStatus; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.entities.ApplicationInfo; +import net.dv8tion.jda.api.entities.User; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.*; +import java.net.Proxy; +import java.util.concurrent.TimeUnit; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; + +import com.jagrosh.jmusicbot.BotConfig; + +/** + * + * @author John Grosh <john.a.grosh@gmail.com> + */ +public class OtherUtil +{ + public final static String NEW_VERSION_AVAILABLE = "There is a new version of JMusicBot available!\n" + + "Current version: %s\n" + + "New Version: %s\n\n" + + "Please visit https://github.com/arif-banai/MusicBot/releases/latest to get the latest release."; + private final static String WINDOWS_INVALID_PATH = "c:\\windows\\system32\\"; + + /** + * gets a Path from a String + * also fixes the windows tendency to try to start in system32 + * any time the bot tries to access this path, it will instead start in the location of the jar file + * + * @param path the string path + * @return the Path object + */ + public static Path getPath(String path) + { + Path result = Paths.get(path); + // special logic to prevent trying to access system32 + if(result.toAbsolutePath().toString().toLowerCase().startsWith(WINDOWS_INVALID_PATH)) + { + try + { + result = Paths.get(new File(JMusicBot.class.getProtectionDomain().getCodeSource().getLocation().toURI()).getParentFile().getPath() + File.separator + path); + } + catch(URISyntaxException ignored) {} + } + return result; + } + + /** + * Loads a resource from the jar as a string + * + * @param clazz class to use for loading the resource + * @param name name of resource + * @return string containing the contents of the resource + */ + public static String loadResource(Class<?> clazz, String name) + { + try(BufferedReader reader = new BufferedReader(new InputStreamReader(clazz.getResourceAsStream(name), StandardCharsets.UTF_8))) + { + StringBuilder sb = new StringBuilder(); + reader.lines().forEach(line -> sb.append("\r\n").append(line)); + return sb.toString().trim(); + } + catch(IOException ignored) + { + return null; + } + } + + /** + * Loads image data from a URL + * + * @param url url of image + * @return inputstream of url + */ + public static InputStream imageFromUrl(String url) + { + if(url==null) + return null; + try + { + URL u = new URL(url); + URLConnection urlConnection = u.openConnection(); + urlConnection.setRequestProperty("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36"); + return urlConnection.getInputStream(); + } + catch(IOException | IllegalArgumentException ignore) {} + return null; + } + + /** + * Parses an activity from a string + * + * @param game the game, including the action such as 'playing' or 'watching' + * @return the parsed activity + */ + public static Activity parseGame(String game) + { + if(game==null || game.trim().isEmpty() || game.trim().equalsIgnoreCase("default")) + return null; + String lower = game.toLowerCase(); + if(lower.startsWith("playing")) + return Activity.playing(makeNonEmpty(game.substring(7).trim())); + if(lower.startsWith("listening to")) + return Activity.listening(makeNonEmpty(game.substring(12).trim())); + if(lower.startsWith("listening")) + return Activity.listening(makeNonEmpty(game.substring(9).trim())); + if(lower.startsWith("watching")) + return Activity.watching(makeNonEmpty(game.substring(8).trim())); + if(lower.startsWith("streaming")) + { + String[] parts = game.substring(9).trim().split("\\s+", 2); + if(parts.length == 2) + { + return Activity.streaming(makeNonEmpty(parts[1]), "https://twitch.tv/"+parts[0]); + } + } + return Activity.playing(game); + } + + public static String makeNonEmpty(String str) + { + return str == null || str.isEmpty() ? "\u200B" : str; + } + + public static OnlineStatus parseStatus(String status) + { + if(status==null || status.trim().isEmpty()) + return OnlineStatus.ONLINE; + OnlineStatus st = OnlineStatus.fromKey(status); + return st == null ? OnlineStatus.ONLINE : st; + } + + public static void checkJavaVersion(UserInteraction userInteraction) + { + if(!System.getProperty("java.vm.name").contains("64")) + userInteraction.alert(Level.WARNING, "Java Version", + "It appears that you may not be using a supported Java version. Please use 64-bit java."); + } + + public static String getCurrentVersion() + { + if(JMusicBot.class.getPackage()!=null && JMusicBot.class.getPackage().getImplementationVersion()!=null) + return JMusicBot.class.getPackage().getImplementationVersion(); + else + return "UNKNOWN"; + } + + public static String getLatestVersion() + { + return getLatestVersion("https://api.github.com/repos/arif-banai/MusicBot", null); + } + + /** + * Gets the latest non-prerelease version from GitHub releases API with optional proxy support. + * + * @param config the bot configuration (may be null) + * @return the latest non-prerelease version tag (without 'v' prefix), or null if not found + */ + public static String getLatestVersion(BotConfig config) + { + Proxy proxy = null; + if (config != null && config.proxyGithub() && config.hasProxy()) { + proxy = ProxyUtil.createProxy(config); + } + return getLatestVersion("https://api.github.com/repos/arif-banai/MusicBot", proxy); + } + + /** + * Gets the latest non-prerelease version from GitHub releases API. + * This method is public to allow testing with mock servers. + * + * @param baseUrl the base URL for the GitHub API (e.g., "https://api.github.com/repos/arif-banai/MusicBot") + * @return the latest non-prerelease version tag (without 'v' prefix), or null if not found + */ + public static String getLatestVersion(String baseUrl) + { + return getLatestVersion(baseUrl, null); + } + + /** + * Gets the latest non-prerelease version from GitHub releases API with optional proxy support. + * This method is public to allow testing with mock servers. + * + * @param baseUrl the base URL for the GitHub API (e.g., "https://api.github.com/repos/arif-banai/MusicBot") + * @param proxy the proxy to use for HTTP requests (may be null) + * @return the latest non-prerelease version tag (without 'v' prefix), or null if not found + */ + public static String getLatestVersion(String baseUrl, Proxy proxy) + { + try + { + OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS); + + if (proxy != null) { + clientBuilder.proxy(proxy); + } + + OkHttpClient client = clientBuilder.build(); + // First, try to get the latest release + Response response = client.newCall(new Request.Builder().get() + .url(baseUrl + "/releases/latest").build()) + .execute(); + ResponseBody body = response.body(); + if(body != null) + { + try(Reader reader = body.charStream()) + { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode obj = objectMapper.readTree(reader); + if(obj != null && obj.has("tag_name")) + { + // Check if this is a pre-release + boolean isPrerelease = obj.has("prerelease") && obj.get("prerelease").asBoolean(); + + if(!isPrerelease) + { + // Not a pre-release, use it + String tag = obj.get("tag_name").asText(); + if(tag.startsWith("v")) + tag = tag.substring(1); + return tag; + } + } + } + finally + { + response.close(); + } + } + + // If the latest release was a pre-release, fetch recent releases and find the latest non-prerelease + // Limit to first 10 releases (sorted by date, newest first) - should be more than enough + Response allReleasesResponse = client.newCall(new Request.Builder().get() + .url(baseUrl + "/releases?per_page=10").build()) + .execute(); + ResponseBody allReleasesBody = allReleasesResponse.body(); + if(allReleasesBody != null) + { + try(Reader reader = allReleasesBody.charStream()) + { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode releases = objectMapper.readTree(reader); + if(releases != null && releases.isArray()) + { + // Find the first non-prerelease release + for(JsonNode release : releases) + { + if(release.has("prerelease") && release.get("prerelease").asBoolean()) + continue; // Skip pre-releases + + if(release.has("tag_name")) + { + String tag = release.get("tag_name").asText(); + if(tag.startsWith("v")) + tag = tag.substring(1); + return tag; + } + } + } + } + finally + { + allReleasesResponse.close(); + } + } + return null; + } + catch(IOException | NullPointerException ex) + { + return null; + } + } + + public static void checkVersion(UserInteraction userInteraction) + { + checkVersion(userInteraction, null); + } + + /** + * Checks for a new version of JMusicBot with optional proxy support. + * + * @param userInteraction the user interaction handler for alerts + * @param config the bot configuration for proxy settings (may be null) + */ + public static void checkVersion(UserInteraction userInteraction, BotConfig config) + { + // Get current version number + String version = getCurrentVersion(); + + // Check for new version + String latestVersion = getLatestVersion(config); + + if(latestVersion != null && isNewerVersion(version, latestVersion)) + { + userInteraction.alert(Level.WARNING, "JMusicBot Version", String.format(NEW_VERSION_AVAILABLE, version, latestVersion)); + } + } + + public static boolean isNewerVersion(String current, String latest) + { + if (current.equalsIgnoreCase("UNKNOWN")) + return true; + + String[] currentParts = current.split("\\."); + String[] latestParts = latest.split("\\."); + int length = Math.max(currentParts.length, latestParts.length); + + for (int i = 0; i < length; i++) + { + int curr = i < currentParts.length ? Integer.parseInt(currentParts[i].replaceAll("\\D", "")) : 0; + int late = i < latestParts.length ? Integer.parseInt(latestParts[i].replaceAll("\\D", "")) : 0; + + if (late > curr) return true; + if (late < curr) return false; + } + return false; + } + + /** + * Checks if the bot JMusicBot is being run on is supported & returns the reason if it is not. + * @return A string with the reason, or null if it is supported. + */ + public static String getUnsupportedBotReason(JDA jda) + { + if (jda.getSelfUser().getFlags().contains(User.UserFlag.VERIFIED_BOT)) + return "The bot is verified. Using JMusicBot in a verified bot is not supported."; + + ApplicationInfo info = jda.retrieveApplicationInfo().complete(); + if (info.isBotPublic()) + return "\"Public Bot\" is enabled. Using JMusicBot as a public bot is not supported. Please disable it in the " + + "Developer Dashboard at https://discord.com/developers/applications/" + jda.getSelfUser().getId() + "/bot ." + + "You may also need to disable all Installation Contexts at https://discord.com/developers/applications/" + + jda.getSelfUser().getId() + "/installation ."; + + return null; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/PaginatedListEmbedUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/PaginatedListEmbedUtil.java new file mode 100644 index 0000000..68d1dc9 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/PaginatedListEmbedUtil.java @@ -0,0 +1,78 @@ +/* + * 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.utils; + +import net.dv8tion.jda.api.EmbedBuilder; + +import java.awt.Color; +import java.time.Instant; +import java.util.List; + +/** + * Shared utilities for building paginated list embeds (Queue, History, Playlists slash commands). + */ +public final class PaginatedListEmbedUtil +{ + private PaginatedListEmbedUtil() + { + } + + /** + * Builds the "section header + numbered bullet lines" string used by Queue, History, and Playlists embeds. + * + * @param sectionHeader e.g. "**Up Next** *(select a track below)*" + * @param lineContents pre-formatted line strings for the current page (e.g. track strings, or "`name`", or "[**label**](url)") + * @param selectedIndex 1-based index of the selected line, or 0 if none + * @param firstDisplayNum 1-based number of the first line (for pagination, e.g. page 2 starts at 11) + * @return sectionHeader + "\n\n" + numbered lines; when selectedIndex > 0 the matching line uses ▶️ and bold + */ + public static String buildNumberedListSection(String sectionHeader, List<String> lineContents, + int selectedIndex, int firstDisplayNum) + { + StringBuilder sb = new StringBuilder(); + sb.append(sectionHeader).append("\n\n"); + if (lineContents != null) + { + for (int i = 0; i < lineContents.size(); i++) + { + int displayNum = firstDisplayNum + i; + String content = lineContents.get(i); + if (selectedIndex > 0 && displayNum == selectedIndex) + { + sb.append("▶️ **`").append(displayNum).append(".`** ").append(content).append("\n"); + } + else + { + sb.append("⬛ `").append(displayNum).append(".` ").append(content).append("\n"); + } + } + } + return sb.toString(); + } + + /** + * Applies standard embed options (timestamp, color, optional footer) used by all list embeds. + */ + public static EmbedBuilder applyStandardEmbedOptions(EmbedBuilder embed, String footer, Color memberColor) + { + embed.setTimestamp(Instant.now()).setColor(memberColor); + if (footer != null) + { + embed.setFooter(footer); + } + return embed; + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/ProxyUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/ProxyUtil.java new file mode 100644 index 0000000..e2d251c --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/ProxyUtil.java @@ -0,0 +1,60 @@ +/* + * 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.utils; + +import com.jagrosh.jmusicbot.BotConfig; +import org.apache.http.HttpHost; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +/** + * Utility class for creating proxy objects from configuration. + * Supports both Java's native Proxy (for OkHttp/JDA) and Apache's HttpHost (for Lavaplayer). + * + * @author Arif Banai (arif-banai) + */ +public class ProxyUtil { + + /** + * Creates a Java Proxy object from config settings. + * Used for OkHttp-based clients (JDA, GitHub checks). + * + * @param config The bot configuration + * @return A Proxy object, or null if proxy is not configured + */ + public static Proxy createProxy(BotConfig config) { + if (!config.hasProxy()) { + return null; + } + return new Proxy(Proxy.Type.HTTP, + new InetSocketAddress(config.getProxyHost(), config.getProxyPort())); + } + + /** + * Creates an Apache HttpHost for Lavaplayer's HTTP client. + * Used with DefaultAudioPlayerManager.setHttpBuilderConfigurator(). + * + * @param config The bot configuration + * @return An HttpHost object, or null if proxy is not configured + */ + public static HttpHost createApacheProxy(BotConfig config) { + if (!config.hasProxy()) { + return null; + } + return new HttpHost(config.getProxyHost(), config.getProxyPort()); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/TeeOutputStream.java b/src/main/java/com/jagrosh/jmusicbot/utils/TeeOutputStream.java new file mode 100644 index 0000000..c2991c3 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/TeeOutputStream.java @@ -0,0 +1,96 @@ +/* + * 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.utils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +/** + * An OutputStream that writes to both a primary stream (e.g., the real stdout/stderr) + * and an in-memory buffer. Used to capture early startup logs before we know if + * the GUI is enabled, so they can be replayed into the GUI console later. + * + * <p>Thread-safety: writes are synchronized to prevent interleaving when multiple + * threads write concurrently during startup.</p> + * + * @author Arif Banai (arif-banai) + */ +public class TeeOutputStream extends OutputStream { + + private final OutputStream primary; + private final ByteArrayOutputStream buffer; + + /** + * Creates a TeeOutputStream that writes to the given primary stream + * and also captures output in an internal buffer. + * + * @param primary The primary output stream (e.g., original System.out) + */ + public TeeOutputStream(OutputStream primary) { + this.primary = primary; + this.buffer = new ByteArrayOutputStream(4096); + } + + @Override + public synchronized void write(int b) throws IOException { + primary.write(b); + buffer.write(b); + } + + @Override + public synchronized void write(byte[] b) throws IOException { + primary.write(b); + buffer.write(b); + } + + @Override + public synchronized void write(byte[] b, int off, int len) throws IOException { + primary.write(b, off, len); + buffer.write(b, off, len); + } + + @Override + public synchronized void flush() throws IOException { + primary.flush(); + // ByteArrayOutputStream.flush() is a no-op, but call for consistency + buffer.flush(); + } + + @Override + public synchronized void close() throws IOException { + // Don't close the primary stream (we don't own it) + // ByteArrayOutputStream.close() is a no-op + buffer.close(); + } + + /** + * Returns the buffered content as a UTF-8 string. + * + * @return The buffered output as a string + */ + public synchronized String getBufferedContent() { + return buffer.toString(StandardCharsets.UTF_8); + } + + /** + * Clears the buffer. Call after replaying to free memory. + */ + public synchronized void clearBuffer() { + buffer.reset(); + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/TimeUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/TimeUtil.java new file mode 100644 index 0000000..0811187 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/TimeUtil.java @@ -0,0 +1,138 @@ +/* + * Copyright 2020 John Grosh <john.a.grosh@gmail.com>. + * + * 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.utils; + +public class TimeUtil +{ + + public static String formatTime(long duration) + { + if(duration == Long.MAX_VALUE) + return "LIVE"; + long seconds = Math.round(duration/1000.0); + long hours = seconds/(60*60); + seconds %= 60*60; + long minutes = seconds/60; + seconds %= 60; + return (hours>0 ? hours+":" : "") + (minutes<10 ? "0"+minutes : minutes) + ":" + (seconds<10 ? "0"+seconds : seconds); + } + + /** + * Parses a seek time string into milliseconds and determines if it's relative. + * Supports "colon time" (HH:MM:SS) or "unit time" (1h20m) + * @param args time string + * @return SeekTime object, or null if the string could not be parsed + */ + public static SeekTime parseTime(String args) + { + if (args.length() == 0) return null; + String timestamp = args; + boolean relative = false; // seek forward or backward + boolean isSeekingBackwards = false; + char first = timestamp.charAt(0); + if (first == '+' || first == '-') + { + relative = true; + isSeekingBackwards = first == '-'; + timestamp = timestamp.substring(1); + } + + long milliseconds = parseColonTime(timestamp); + if(milliseconds == -1) milliseconds = parseUnitTime(timestamp); + if(milliseconds == -1) return null; + + milliseconds *= isSeekingBackwards ? -1 : 1; + + return new SeekTime(milliseconds, relative); + } + + /** + * @param timestamp timestamp formatted as: [+ | -] <HH:MM:SS | MM:SS | SS> + * @return Time in milliseconds + */ + public static long parseColonTime(String timestamp) + { + String[] timestampSplitArray = timestamp.split(":+"); + if(timestampSplitArray.length > 3 ) + return -1; + double[] timeUnitArray = new double[3]; // hours, minutes, seconds + for(int index = 0; index < timestampSplitArray.length; index++) + { + String unit = timestampSplitArray[index]; + if (unit.startsWith("+") || unit.startsWith("-")) return -1; + unit = unit.replace(",", "."); + try + { + timeUnitArray[index + 3 - timestampSplitArray.length] = Double.parseDouble(unit); + } + catch (NumberFormatException e) + { + return -1; + } + } + return Math.round(timeUnitArray[0] * 3600000 + timeUnitArray[1] * 60000 + timeUnitArray[2] * 1000); + } + + /** + * + * @param timestr time string formatted as a unit time, e.g. 20m10, 1d5h20m14s or 1h and 20m + * @return Time in milliseconds + */ + public static long parseUnitTime(String timestr) + { + timestr = timestr.replaceAll("(?i)(\\s|,|and)","") + .replaceAll("(?is)(-?\\d+|[a-z]+)", "$1 ") + .trim(); + String[] vals = timestr.split("\\s+"); + int time = 0; + try + { + for(int j=0; j<vals.length; j+=2) + { + int num = Integer.parseInt(vals[j]); + + if(vals.length > j+1) + { + if(vals[j+1].toLowerCase().startsWith("m")) + num*=60; + else if(vals[j+1].toLowerCase().startsWith("h")) + num*=60*60; + else if(vals[j+1].toLowerCase().startsWith("d")) + num*=60*60*24; + } + + time+=num*1000; + } + } + catch(Exception ex) + { + return -1; + } + return time; + } + + public static class SeekTime + { + public final long milliseconds; + public final boolean relative; + + private SeekTime(long milliseconds, boolean relative) + { + this.milliseconds = milliseconds; + this.relative = relative; + } + } +} diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/YoutubeOauth2TokenHandler.java b/src/main/java/com/jagrosh/jmusicbot/utils/YoutubeOauth2TokenHandler.java new file mode 100644 index 0000000..3e10717 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/utils/YoutubeOauth2TokenHandler.java @@ -0,0 +1,91 @@ +package com.jagrosh.jmusicbot.utils; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.spi.FilterReply; +import org.slf4j.LoggerFactory; +import org.slf4j.Marker; + +import java.nio.file.Files; + +/** + * A logback turbo filter, used retrieve the YouTube OAuth2 refresh token that gets logged once authorized with YouTube. + * + * @author Michaili K. <git@michaili.dev> + */ +public class YoutubeOauth2TokenHandler extends TurboFilter { + public final static org.slf4j.Logger LOGGER = LoggerFactory.getLogger(YoutubeOauth2TokenHandler.class); + private Data data; + + + public void init() + { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + context.addTurboFilter(this); + } + + public Data getData() + { + return data; + } + + @Override + public FilterReply decide(Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) + { + if (!logger.getName().equals("dev.lavalink.youtube.http.YoutubeOauth2Handler")) + return FilterReply.NEUTRAL; + + if (format.equals("OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {}")) + { + this.data = new Data((String) params[0], (String) params[1]); + return FilterReply.NEUTRAL; + } + if (format.equals("OAUTH INTEGRATION: Token retrieved successfully. Store your refresh token as this can be reused. ({})")) + { + LOGGER.info( + "Authorization successful & retrieved token! Storing the token in {}", + OtherUtil.getPath("youtubetoken.txt").toAbsolutePath() + ); + + try + { + Files.write(OtherUtil.getPath("youtubetoken.txt"), params[0].toString().getBytes()); + } + catch (Exception e) + { + LOGGER.error( + "Failed to write the YouTube OAuth2 refresh token to storage! You will need to authorize again on the next reboot", + e + ); + } + return FilterReply.DENY; + } + + return FilterReply.NEUTRAL; + } + + public static class Data + { + private final String authorisationUrl; + private final String code; + + private Data(String authorisationUrl, String code) + { + this.authorisationUrl = authorisationUrl; + this.code = code; + } + + public String getCode() + { + return code; + } + + public String getAuthorisationUrl() + { + return authorisationUrl; + } + } + +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..4a7033f --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,17 @@ +<!-- author: Kaidan Gustave --> +<configuration> + + <appender name="Simple" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <!-- Pattern --> + <pattern> + %nopex[%d{HH:mm:ss}] [%level] [%logger{0}]: %msg%n%ex + </pattern> + </encoder> + </appender> + + <root level="INFO"> + <appender-ref ref="Simple"/> + </root> + +</configuration> diff --git a/src/main/resources/natives/linux-aarch32/libconnector.so b/src/main/resources/natives/linux-aarch32/libconnector.so new file mode 100644 index 0000000..85d41f0 Binary files /dev/null and b/src/main/resources/natives/linux-aarch32/libconnector.so differ diff --git a/src/main/resources/natives/linux-aarch64/libconnector.so b/src/main/resources/natives/linux-aarch64/libconnector.so new file mode 100644 index 0000000..abf337a Binary files /dev/null and b/src/main/resources/natives/linux-aarch64/libconnector.so differ diff --git a/src/main/resources/natives/linux-arm/libconnector.so b/src/main/resources/natives/linux-arm/libconnector.so new file mode 100644 index 0000000..0668792 Binary files /dev/null and b/src/main/resources/natives/linux-arm/libconnector.so differ diff --git a/src/main/resources/natives/linux-armhf/libconnector.so b/src/main/resources/natives/linux-armhf/libconnector.so new file mode 100644 index 0000000..0668792 Binary files /dev/null and b/src/main/resources/natives/linux-armhf/libconnector.so differ diff --git a/src/main/resources/natives/linux-x86/libconnector.so b/src/main/resources/natives/linux-x86/libconnector.so new file mode 100644 index 0000000..41b0012 Binary files /dev/null and b/src/main/resources/natives/linux-x86/libconnector.so differ diff --git a/src/main/resources/old_configs/reference-legacy.conf b/src/main/resources/old_configs/reference-legacy.conf new file mode 100644 index 0000000..109e920 --- /dev/null +++ b/src/main/resources/old_configs/reference-legacy.conf @@ -0,0 +1,270 @@ +/// START OF JMUSICBOT CONFIG /// +///////////////////////////////////////////////////////// +// Config for the JMusicBot // +///////////////////////////////////////////////////////// +// Any line starting with // is ignored // +// You MUST set the token and owner // +// All other items have defaults if you don't set them // +// Open in Notepad++ for best results // +///////////////////////////////////////////////////////// + + +// This sets the token for the bot to log in with +// This MUST be a bot token (user tokens will not work) +// If you don't know how to get a bot token, please see the guide here: +// https://github.com/jagrosh/MusicBot/wiki/Getting-a-Bot-Token + +token = BOT_TOKEN_HERE + + +// This sets the owner of the bot +// This needs to be the owner's ID (a 17-18 digit number) +// https://github.com/jagrosh/MusicBot/wiki/Finding-Your-User-ID + +owner = 0 // OWNER ID + + +// This sets the prefix for the bot +// The prefix is used to control the commands +// If you use !!, the play command will be !!play +// If you do not set this, the prefix will be a mention of the bot (@Botname play) + +prefix = "@mention" + + +// If you set this, it modifies the default game of the bot +// Set this to NONE to have no game +// Set this to DEFAULT to use the default game +// You can make the game "Playing X", "Listening to X", or "Watching X" +// where X is the title. If you don't include an action, it will use the +// default of "Playing" + +game = "DEFAULT" + + +// If you set this, it will modify the default status of bot +// Valid values: ONLINE IDLE DND INVISIBLE + +status = ONLINE + + +// If you set this to true, the bot will list the title of the song it is currently playing in its +// "Playing" status. Note that this will ONLY work if the bot is playing music on ONE guild; +// if the bot is playing on multiple guilds, this will not work. + +songinstatus=false + + +// If you set this, the bot will also use this prefix in addition to +// the one provided above + +altprefix = "NONE" + + +// If you set these, it will change the various emojis + +success = "🎶" +warning = "💡" +error = "🚫" +loading = "⌚" +searching = "🔎" + + +// If you set this, you change the word used to view the help. +// For example, if you set the prefix to !! and the help to cmds, you would type +// !!cmds to see the help text + +help = help + + +// If you set this, the "nowplaying" command will show youtube thumbnails +// Note: If you set this to true, the nowplaying boxes will NOT refresh +// This is because refreshing the boxes causes the image to be reloaded +// every time it refreshes. + +npimages = false + + +// If you set this, the bot will not leave a voice channel after it finishes a queue. +// Keep in mind that being connected to a voice channel uses additional bandwith, +// so this option is not recommended if bandwidth is a concern. + +stayinchannel = false + + +// This sets the maximum amount of seconds any track loaded can be. If not set or set +// to any number less than or equal to zero, there is no maximum time length. This time +// restriction applies to songs loaded from any source. + +maxtime = 0 + +// This sets the maximum number of pages of songs that can be loaded from a YouTube +// playlist. Each page can contain up to 100 tracks. Playing a playlist with more +// pages than the maximum will stop loading after the provided number of pages. +// For example, if the max was set to 15 and a playlist contained 1850 tracks, +// only the first 1500 tracks (15 pages) would be loaded. By default, this is +// set to 10 pages (1000 tracks). + +maxytplaylistpages = 10 + + +// If you set this to true, the bot will use YouTube's OAuth2 flow for YouTube playback. +// This is useful if you are experiencing "Video unavailable" or "Sign in to confirm you're not a bot" errors. +// When enabled, the bot will print a code to the console that you must enter at https://www.google.com/device +// This only needs to be done once, and the bot will remember the login until you change the refresh token. + +useyoutubeoauth = false + + +// This sets the ratio of users that must vote to skip the currently playing song. +// Guild owners can define their own skip ratios, but this will be used if a guild +// has not defined their own skip ratio. + +skipratio = 0.55 + + +// This sets the amount of seconds the bot will stay alone on a voice channel until it +// automatically leaves the voice channel and clears the queue. If not set or set +// to any number less than or equal to zero, the bot won't leave when alone. + +alonetimeuntilstop = 0 + + +// This sets an alternative folder to be used as the Playlists folder +// This can be a relative or absolute path + +playlistsfolder = "Playlists" + + +// By default, the bot will DM the owner if the bot is running and a new version of the bot +// becomes available. Set this to false to disable this feature. + +updatealerts=true + + +// Changing this changes the lyrics provider +// Currently available providers: "A-Z Lyrics", "Genius", "MusicMatch", "LyricsFreak" +// At the time of writing, I would recommend sticking with A-Z Lyrics or MusicMatch, +// as Genius tends to have a lot of non-song results and you might get something +// completely unrelated to what you want. +// If you are interested in contributing a provider, please see +// https://github.com/jagrosh/JLyrics + +lyrics.default = "A-Z Lyrics" + + +// These settings allow you to configure custom aliases for all commands. +// Multiple aliases may be given, separated by commas. +// +// Example 1: Giving command "play" the alias "p": +// play = [ p ] +// +// Example 2: Giving command "search" the aliases "yts" and "find": +// search = [ yts, find ] + +aliases { + // General commands + settings = [ status ] + + // Music commands + lyrics = [] + nowplaying = [ np, current ] + play = [] + playlists = [ pls ] + queue = [ list ] + remove = [ delete ] + scsearch = [] + search = [ ytsearch ] + shuffle = [] + skip = [ voteskip ] + + // Admin commands + prefix = [ setprefix ] + setdj = [] + setskip = [ setskippercent, skippercent, setskipratio ] + settc = [] + setvc = [] + + // DJ Commands + forceremove = [ forcedelete, modremove, moddelete, modelete ] + forceskip = [ modskip ] + movetrack = [ move ] + pause = [] + playnext = [] + queuetype = [] + repeat = [] + skipto = [ jumpto ] + stop = [ leave ] + volume = [ vol ] +} + + +// This sets the logging verbosity. +// Available levels: off, error, warn, info, debug, trace, all +// +// It is recommended to leave this at info. Debug log levels might help with troubleshooting, +// but can contain sensitive data. + +loglevel = info + + +// This allows you to enable specific audio sources. +// By default, all sources are enabled. Only list the sources you want enabled. +// If this setting is not specified, all sources will be enabled. +// Available sources: +// youtube - YouTube videos and playlists +// soundcloud - SoundCloud tracks +// bandcamp - Bandcamp albums and tracks +// vimeo - Vimeo videos +// twitch - Twitch streams +// beam - Beam.pro streams +// getyarn - Getyarn.io clips +// nico - NicoNico videos +// http - Direct HTTP audio links +// local - Local file playback +// +// Example: To enable only Local: +// audiosources = [ +// local +// ] + +audiosources = [ + youtube, + soundcloud, + bandcamp, + vimeo, + twitch, + beam, + getyarn, + nico, + http, + local +] + + +// Transforms are used to modify specific play inputs and convert them to different kinds of inputs +// These are quite complicated to use, and have limited use-cases, but in theory allow for rough +// whitelists or blacklists, roundabout loading from some sources, and customization of how things are +// requested. +// +// These are NOT EASY to set up, so if you want to use these, you'll need to look through the code +// for how they work and what fields are needed. Also, it's possible this feature might get entirely +// removed in the future if I find a better way to do this. +transforms = {} + + +// If you set this to true, it will enable the eval command for the bot owner. This command +// allows the bot owner to run arbitrary code from the bot's account. +// +// WARNING: +// This command can be extremely dangerous. If you don't know what you're doing, you could +// cause horrific problems on your Discord server or on whatever computer this bot is running +// on. Never run this command unless you are completely positive what you are running. +// +// DO NOT ENABLE THIS IF YOU DON'T KNOW WHAT THIS DOES OR HOW TO USE IT +// IF SOMEONE ASKS YOU TO ENABLE THIS, THERE IS AN 11/10 CHANCE THEY ARE TRYING TO SCAM YOU + +eval=false +evalengine="Nashorn" + +/// END OF JMUSICBOT CONFIG /// diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf new file mode 100644 index 0000000..4d30857 --- /dev/null +++ b/src/main/resources/reference.conf @@ -0,0 +1,301 @@ +# START OF JMUSICBOT CONFIG +# +# Config for the JMusicBot +# Any line starting with // or # is ignored +# You MUST set the token and owner +# +# All other items have defaults if you don't set them +# +# Open in Notepad++ / VS Code for best results + +meta { + # Used for config migration / forward compatibility. + # DO NOT CHANGE THIS VALUE. It is managed automatically by the bot. + configVersion = 2 +} + +discord { + # This sets the token for the bot to log in with + # This MUST be a bot token (user tokens will not work) + # Guide: https://github.com/jagrosh/MusicBot/wiki/Getting-a-Bot-Token + token = "BOT_TOKEN_HERE" + + # This sets the owner of the bot + # This needs to be the owner's ID (a 17-18 digit number) + # Guide: https://github.com/jagrosh/MusicBot/wiki/Finding-Your-User-ID + owner = 0 # OWNER ID +} + +playback { + # Maximum track length in seconds. + # 0 (or less) disables the limit. + maxTrackSeconds = 0 + + # Maximum number of YouTube playlist pages to load (100 tracks per page). + # Example: 10 pages = up to 1000 tracks. + maxYouTubePlaylistPages = 10 + + # Maximum number of tracks to keep in playback history. + # Used for: + # - the previous/rewind functionality. + # - the history command and its subcommands. + # 0 disables playback history entirely. + maxHistorySize = 40 + + # Ratio of users that must vote to skip the currently playing song. + # Some guilds may override this, this is the default. + skipRatio = 0.55 + + youtube { + # If true, uses YouTube OAuth device flow for playback. + # Helpful for “Sign in to confirm you’re not a bot” / “Video unavailable” issues. + # When enabled, a code is printed in console/DM'd to the owner for https://www.google.com/device + # Only needs to be done once (refresh token persists unless changed). + useOAuth = false + + # Remote cipher server used to turn YouTube's obfuscated stream signatures + # into playable URLs. + # + # YouTube rotates its player script every few weeks. Each rotation breaks the + # signature extractor bundled inside youtube-source, which shows up as + # playback failing with: + # "Must find sig function from script: /s/player/<id>/.../base.js" + # A remote cipher server is updated independently of this bot, so playback + # keeps working without waiting for a new bot release. + # + # This is unrelated to useOAuth above - it is applied either way. + # Set url to "" to disable it and use the bundled local extractor instead. + # You can self-host one: https://github.com/kikkia/yt-cipher + remoteCipher { + url = "https://cipher.kikkia.dev/" + + # Password for the cipher server, if it requires one. + # The public server above does not, so leave this empty. + password = "" + } + } + + # Audio sources toggles. + # If you want to disable a source, set the value to false + audioSources { + youtube = true + soundcloud = true + bandcamp = true + vimeo = true + twitch = true + beam = true + getyarn = true + nico = true + http = true + local = true + } + + # Transforms can rewrite play inputs into other inputs. + # Advanced feature, can be used for whitelists/blacklists or custom routing. + # Not easy to configure, see code for supported fields. + transforms = {} +} + +commands { + # This sets the prefix for the bot + # If you use !!, the play command will be !!play + # If you do not set this, the prefix can be a mention of the bot (@Botname play) + prefix = "@mention" + + # If you set this, the bot will also use this prefix in addition to the one above + # "NONE" disables + altPrefix = "NONE" + + # Word used to view the help + help = "help" + + clearChannel { + # Maximum number of messages to delete per clear operation. + # 0 (or less) deletes all eligible messages. + deleteLimit = 50 + + # Maximum message age in days for clear operation eligibility. + # 0 (or less) disables age filtering. + ageDays = 14 + } + + # Configure custom aliases for commands. + # Multiple aliases are comma-separated inside the list. + # Example: + # play = [ "p" ] + # search = [ "yts", "find" ] + aliases { + # General commands + settings = [ "status" ] + + # Music commands + lyrics = [] + nowplaying = [ "np", "current" ] + play = [] + playlists = [ "pls" ] + queue = [ "list" ] + remove = [ "delete" ] + scsearch = [] + search = [ "ytsearch" ] + shuffle = [] + skip = [ "voteskip" ] + + # Admin commands + prefix = [ "setprefix" ] + setdj = [] + setskip = [ "setskippercent", "skippercent", "setskipratio" ] + settc = [] + setvc = [] + + # DJ commands + forceremove = [ "forcedelete", "modremove", "moddelete", "modelete" ] + forceskip = [ "modskip" ] + movetrack = [ "move" ] + pause = [] + playnext = [] + queuetype = [] + repeat = [] + skipto = [ "jumpto" ] + stop = [ "leave" ] + volume = [ "vol" ] + } +} + +presence { + # Modifies the default game shown on the bot. + # Set to "NONE" for no game, "DEFAULT" for default behavior, + # or set a custom string (Discord chooses the verb/presence style). + game = "DEFAULT" + + # Default status: ONLINE | IDLE | DND | INVISIBLE + status = "ONLINE" + + # If true, shows the currently playing song title in presence. + # Works reliably only if the bot plays music in ONE guild. + songInStatus = false +} + +ui { + # Customize emojis shown in bot responses. + emojis { + success = "🎶" + warning = "💡" + error = "🚫" + loading = "⌚" + searching = "🔎" + } +} + +gui { + # If true, the GUI will be enabled. + # Default is true. + enabled = true + + # Theme for the GUI window: light, dark, darcula, intellij + # Default is "dark" for a modern look + theme = "dark" + + # Base font size for GUI components (8-24) + # Higher values make text larger and more readable + fontSize = 12 +} + +nowPlaying { + # If true, the nowplaying command will show YouTube thumbnails. + # Only works if minimalMessage is false + images = false + + # If true, render now-playing messages in a compact/minimal layout. + # If false, use the full now-playing layout. + minimalMessage = false + + # If true, include interactive playback buttons in now-playing messages. + # If false, render now-playing messages without action rows/buttons. + showButtons = true + + # If true, show a progress bar in now-playing messages. + # Also enables the 10-second now-playing refresh thread. + showProgressBar = false +} + +voice { + # If true, the bot will not leave a voice channel after finishing a queue. + # Staying connected uses additional bandwidth; not recommended if bandwidth is a concern. + stayInChannel = false + + # Seconds the bot stays alone in a voice channel before leaving and clearing queue. + # 0 (or less) disables. + aloneTimeUntilStopSeconds = 10 +} + +paths { + # Folder used for playlists (relative or absolute). + playlistsFolder = "Playlists" +} + +updates { + # If true, the bot will DM the owner when a new version is available. + alerts = true +} + +logging { + # Logging verbosity: off, error, warn, info, debug, trace, all + # Recommended: info + level = "info" +} + +performance { + # Audio buffer settings to reduce stuttering from GC pauses. + # Higher values provide more protection but add latency. + + # NAS (Native Audio Send) buffer duration in milliseconds. + # This buffer protects against JVM pauses up to this duration. + # Default: 800 (can tolerate GC pauses up to 800ms) + nasBufferMs = 800 + + # Lavaplayer frame buffer duration in milliseconds. + # Amount of decoded audio to keep buffered ahead of playback. + # Default: 2000 (2 seconds of audio pre-buffered) + frameBufferMs = 2000 +} + +proxy { + # HTTP proxy configuration for routing traffic through a proxy server. + # Leave host empty to disable proxy entirely. + # This setting is EXPERIMENTAL/DEVELOPMENT level, may not work as expected + # You can also provide proxy host/port using java args + # -Dhttp.proxyHost=<proxy-hostname> + # -Dhttp.proxyPort=<proxy-port> + # -Dhttps.proxyHost=<proxy-hostname> + # -Dhttps.proxyPort=<proxy-port> + + # Proxy server hostname (e.g., "127.0.0.1" or "proxy.example.com") + host = "" + + # Proxy server port (e.g., 8080, 18080) + port = 0 + + # Which components should route through the proxy. + # Practical use case: enable only lavaplayer to proxy audio streams + # while letting Discord API and other traffic go through normally. + + # Route Lavaplayer audio source requests through proxy (YouTube, SoundCloud, etc.) + lavaplayer = false + + # Route JDA Discord API traffic through proxy + jda = false + + # Route GitHub version check requests through proxy + github = false +} + +dangerous { + # Enables the eval command for the bot owner (arbitrary code execution). + # + # WARNING: Extremely dangerous. Only enable if you 100% understand the risks. + # If someone asks you to enable this, there is a high chance they are scamming you. + eval = false + evalEngine = "Nashorn" +} + +# END OF JMUSICBOT CONFIG diff --git a/src/test/java/com/jagrosh/jmusicbot/BaseConfigTest.java b/src/test/java/com/jagrosh/jmusicbot/BaseConfigTest.java new file mode 100644 index 0000000..25073eb --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/BaseConfigTest.java @@ -0,0 +1,165 @@ +/* + * 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; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Base class for config-related tests. + * Provides common setup/teardown and utility methods. + */ +public abstract class BaseConfigTest { + + @TempDir + protected Path tempDir; + + protected List<Path> tempFiles; + protected MockUserInteraction mockUserInteraction; + + // System property backup for config.file and config + private String originalConfigFile; + private String originalConfig; + + @BeforeEach + protected void setUpBase() { + tempFiles = new ArrayList<>(); + mockUserInteraction = new MockUserInteraction(); + saveSystemProperties(); + } + + @AfterEach + protected void tearDownBase() throws IOException { + restoreSystemProperties(); + cleanupTempFiles(); + } + + /** + * Saves the current system properties for restoration after test. + */ + protected void saveSystemProperties() { + originalConfigFile = System.getProperty("config.file"); + originalConfig = System.getProperty("config"); + } + + /** + * Restores system properties to their original values. + */ + protected void restoreSystemProperties() { + if (originalConfigFile != null) { + System.setProperty("config.file", originalConfigFile); + } else { + System.clearProperty("config.file"); + } + if (originalConfig != null) { + System.setProperty("config", originalConfig); + } else { + System.clearProperty("config"); + } + } + + /** + * Cleans up temporary files created during the test. + */ + private void cleanupTempFiles() { + for (Path tempFile : tempFiles) { + try { + if (Files.exists(tempFile)) { + Files.delete(tempFile); + } + } catch (IOException e) { + // Ignore cleanup errors + } + } + tempFiles.clear(); + } + + /** + * Sets the config.file system property to point to the given file. + */ + protected void setConfigFileProperty(Path configFile) { + System.setProperty("config.file", configFile.toString()); + } + + /** + * Creates a temporary config file in the test's temp directory. + */ + protected Path createTempConfigFile(String content) throws IOException { + Path configFile = tempDir.resolve("config-" + System.currentTimeMillis() + ".conf"); + Files.writeString(configFile, content, java.nio.charset.StandardCharsets.UTF_8); + tempFiles.add(configFile); + return configFile; + } + + /** + * Creates a temporary config file with minimal valid config. + */ + protected Path createMinimalTempConfigFile() throws IOException { + return createTempConfigFile("token = test_token_12345\nowner = 123456789"); + } + + /** + * Reads the content of a file. + */ + protected String readFileContent(Path file) throws IOException { + return Files.readString(file); + } + + /** + * Writes content to a file. + */ + protected void writeFileContent(Path file, String content) throws IOException { + Files.writeString(file, content, java.nio.charset.StandardCharsets.UTF_8); + } + + /** + * Asserts that a file exists. + */ + protected void assertFileExists(Path file) { + org.junit.jupiter.api.Assertions.assertTrue( + Files.exists(file), + "Expected file to exist: " + file + ); + } + + /** + * Asserts that a file does not exist. + */ + protected void assertFileNotExists(Path file) { + org.junit.jupiter.api.Assertions.assertFalse( + Files.exists(file), + "Expected file to not exist: " + file + ); + } + + /** + * Asserts that file content contains the given string. + */ + protected void assertFileContains(Path file, String expectedContent) throws IOException { + String content = readFileContent(file); + org.junit.jupiter.api.Assertions.assertTrue( + content.contains(expectedContent), + String.format("Expected file to contain '%s', but content was: %s", expectedContent, content) + ); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/MockUserInteraction.java b/src/test/java/com/jagrosh/jmusicbot/MockUserInteraction.java new file mode 100644 index 0000000..7cadf5f --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/MockUserInteraction.java @@ -0,0 +1,170 @@ +/* + * 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; + +import com.jagrosh.jmusicbot.entities.UserInteraction; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * Mock implementation of UserInteraction for testing. + * Allows configuring responses for prompt() calls and captures alert() calls. + */ +public class MockUserInteraction implements UserInteraction { + private final List<AlertCall> alertCalls = new ArrayList<>(); + private final List<String> promptCalls = new ArrayList<>(); + + private Function<String, String> promptResponseProvider = null; + private final List<String> promptResponseQueue = new ArrayList<>(); + private int promptResponseIndex = 0; + private boolean nogui = false; + + /** + * Sets a function that provides responses to prompt() calls. + * The function receives the prompt message and returns the response. + */ + public void setPromptResponse(Function<String, String> provider) { + this.promptResponseProvider = provider; + } + + /** + * Sets a simple string response for all prompt() calls. + */ + public void setPromptResponse(String response) { + this.promptResponseProvider = msg -> response; + this.promptResponseQueue.clear(); + this.promptResponseIndex = 0; + } + + /** + * Adds a response to the queue for sequential prompt() calls. + * Each call to prompt() will return the next response in the queue. + */ + public void addPromptResponse(String response) { + this.promptResponseQueue.add(response); + this.promptResponseProvider = msg -> { + if (promptResponseIndex < promptResponseQueue.size()) { + return promptResponseQueue.get(promptResponseIndex++); + } + return null; + }; + } + + /** + * Sets prompt to return null (simulating cancellation/no input). + */ + public void setPromptCancelled() { + this.promptResponseProvider = msg -> null; + } + + /** + * Sets whether running in no-GUI mode. + */ + public void setNoGUI(boolean nogui) { + this.nogui = nogui; + } + + @Override + public String prompt(String message) { + promptCalls.add(message); + if (promptResponseProvider != null) { + return promptResponseProvider.apply(message); + } + return null; + } + + @Override + public void alert(Level level, String context, String message) { + alertCalls.add(new AlertCall(level, context, message)); + } + + @Override + public boolean isNoGUI() { + return nogui; + } + + /** + * Gets all alert calls made to this mock. + */ + public List<AlertCall> getAlertCalls() { + return new ArrayList<>(alertCalls); + } + + /** + * Gets all prompt calls made to this mock. + */ + public List<String> getPromptCalls() { + return new ArrayList<>(promptCalls); + } + + /** + * Clears all recorded calls. + */ + public void clear() { + alertCalls.clear(); + promptCalls.clear(); + promptResponseQueue.clear(); + promptResponseIndex = 0; + } + + /** + * Gets the last alert call, if any. + */ + public AlertCall getLastAlert() { + return alertCalls.isEmpty() ? null : alertCalls.get(alertCalls.size() - 1); + } + + /** + * Gets the last prompt call, if any. + */ + public String getLastPrompt() { + return promptCalls.isEmpty() ? null : promptCalls.get(promptCalls.size() - 1); + } + + /** + * Represents an alert call. + */ + public static class AlertCall { + private final Level level; + private final String context; + private final String message; + + public AlertCall(Level level, String context, String message) { + this.level = level; + this.context = context; + this.message = message; + } + + public Level getLevel() { + return level; + } + + public String getContext() { + return context; + } + + public String getMessage() { + return message; + } + + @Override + public String toString() { + return String.format("AlertCall[level=%s, context=%s, message=%s]", level, context, message); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/TestBase.java b/src/test/java/com/jagrosh/jmusicbot/TestBase.java new file mode 100644 index 0000000..a3dfe60 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/TestBase.java @@ -0,0 +1,97 @@ +package com.jagrosh.jmusicbot; + +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.audio.AudioLoadWrapper; +import com.jagrosh.jmusicbot.audio.PlayerManager; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.settings.SettingsManager; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.managers.AudioManager; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.concurrent.ScheduledExecutorService; + +import static org.mockito.Mockito.when; + +public abstract class TestBase { + + @Mock + protected Bot bot; + @Mock + protected BotConfig config; + @Mock + protected PlayerManager playerManager; + @Mock + protected SettingsManager settingsManager; + @Mock + protected Settings settings; + @Mock + protected Guild guild; + @Mock + protected Member member; + @Mock + protected User user; + @Mock + protected TextChannel textChannel; + @Mock + protected AudioManager audioManager; + @Mock + protected AudioHandler audioHandler; + @Mock + protected AudioPlayer audioPlayer; + @Mock + protected AudioTrack audioTrack; + @Mock + protected JDA jda; + @Mock + protected AudioChannelUnion audioChannel; + @Mock + protected ScheduledExecutorService threadpool; + + protected final long GUILD_ID = 123456789L; + protected final long OWNER_ID = 123L; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + + // Basic Bot relationships + when(bot.getConfig()).thenReturn(config); + when(bot.getPlayerManager()).thenReturn(playerManager); + when(bot.getSettingsManager()).thenReturn(settingsManager); + when(bot.getThreadpool()).thenReturn(threadpool); + when(bot.getJDA()).thenReturn(jda); + when(bot.getAudioLoadWrapper()).thenReturn(AudioLoadWrapper.NO_OP); + + // PlayerManager relationships + when(playerManager.getBot()).thenReturn(bot); + + // Guild and Audio relationships + when(guild.getIdLong()).thenReturn(GUILD_ID); + when(guild.getAudioManager()).thenReturn(audioManager); + when(audioManager.getSendingHandler()).thenReturn(audioHandler); + when(audioHandler.getPlayer()).thenReturn(audioPlayer); + + // Member and User relationships + when(member.getUser()).thenReturn(user); + when(member.getGuild()).thenReturn(guild); + when(user.getId()).thenReturn(String.valueOf(OWNER_ID)); + when(user.getIdLong()).thenReturn(OWNER_ID); + + // Config defaults + when(config.getOwnerId()).thenReturn(OWNER_ID); + when(config.getMaxHistorySize()).thenReturn(10); + + // Settings defaults + when(settingsManager.getSettings(GUILD_ID)).thenReturn(settings); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigIntegrationTest.java new file mode 100644 index 0000000..b31a810 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigIntegrationTest.java @@ -0,0 +1,313 @@ +/* + * 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.integration; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.audio.AudioSource; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.parser.ConfigDocument; +import com.typesafe.config.parser.ConfigDocumentFactory; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("BotConfig Integration Tests") +class BotConfigIntegrationTest extends BaseConfigTest { + // Uses BaseConfigTest's system property management + + @Nested + @DisplayName("Full Load Flow") + class FullLoadFlowTests { + + @Test + @DisplayName("Loads config with all optional fields") + void loadsConfigWithAllOptionalFields() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = integration_test_token + discord.owner = 987654321 + commands.prefix = "!!" + commands.altPrefix = "??" + commands.help = commands + ui.emojis.success = ✅ + ui.emojis.warning = ⚠️ + ui.emojis.error = ❌ + ui.emojis.loading = ⏳ + ui.emojis.searching = 🔍 + presence.game = Playing music + presence.status = ONLINE + presence.songInStatus = true + nowPlaying.images = true + voice.stayInChannel = true + playback.maxTrackSeconds = 3600 + playback.maxYouTubePlaylistPages = 20 + playback.youtube.useOAuth = true + voice.aloneTimeUntilStopSeconds = 300 + paths.playlistsFolder = CustomPlaylists + updates.alerts = false + logging.level = debug + dangerous.eval = false + dangerous.evalEngine = Nashorn + playback.skipRatio = 0.75 + commands.aliases { + play = [ p, playmusic ] + skip = [ voteskip, vs ] + } + playback.transforms = {} + playback.audioSources { + youtube = true + soundcloud = true + bandcamp = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + assertEquals("integration_test_token", config.getToken()); + assertEquals(987654321L, config.getOwnerId()); + assertEquals("!!", config.getPrefix()); + assertEquals("??", config.getAltPrefix()); + assertTrue(config.getSongInStatus()); + assertEquals(20, config.getMaxYTPlaylistPages()); + } + + @Test + @DisplayName("Loads config with missing required fields and prompts") + void loadsConfigWithMissingRequiredFieldsAndPrompts() throws IOException { + // Note: We use legacy keys here to test migration + prompting + Path configFile = createTempConfigFile("token = BOT_TOKEN_HERE\nowner = 0"); + setConfigFileProperty(configFile); + + mockUserInteraction.addPromptResponse("prompted_token"); + mockUserInteraction.addPromptResponse("123456789"); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + assertEquals("prompted_token", config.getToken()); + assertEquals(123456789L, config.getOwnerId()); + assertEquals(2, mockUserInteraction.getPromptCalls().size()); + } + + @Test + @DisplayName("Fails to load when user cancels validation") + void failsToLoadWhenUserCancelsValidation() throws IOException { + Path configFile = createTempConfigFile("token = BOT_TOKEN_HERE\nowner = 123456789"); + setConfigFileProperty(configFile); + + mockUserInteraction.setPromptCancelled(); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertFalse(config.isValid()); + assertEquals(1, mockUserInteraction.getAlertCalls().size()); + } + + @Test + @DisplayName("Writes config file when validation prompts for input") + void writesConfigFileWhenValidationPromptsForInput() throws IOException { + Path configFile = createTempConfigFile("token = BOT_TOKEN_HERE\nowner = 0"); + setConfigFileProperty(configFile); + + mockUserInteraction.addPromptResponse("new_token"); + mockUserInteraction.addPromptResponse("987654321"); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + // Config file should have been written with new values + // Note: writeToFile() replaces the entire file content with default template + String fileContent = readFileContent(configFile); + // After write, file should contain the config structure (new format uses discord.token) + // The file will be written with the default template, so check for config structure + assertTrue(fileContent.contains("token") || fileContent.contains("discord")); + } + } + + @Nested + @DisplayName("Config Updates Integration") + class ConfigUpdatesIntegrationTests { + + @Test + @DisplayName("Updates config with missing values after load") + void updatesConfigWithMissingValuesAfterLoad() throws IOException { + // Minimal config (using nested keys) + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + // Since we didn't provide all optional fields, the original config should have been backed up and updated + Path backupConfig = configFile.resolveSibling(configFile.getFileName().toString() + ".bak"); + assertTrue(java.nio.file.Files.exists(backupConfig), "Backup config file should have been generated"); + // The original config file should have been updated with migrated content + assertTrue(java.nio.file.Files.exists(configFile), "Original config file should still exist"); + String updatedContent = java.nio.file.Files.readString(configFile); + + // Verify it can be parsed as ConfigDocument (primary method used by application) + ConfigDocument doc = ConfigDocumentFactory.parseString(updatedContent); + assertNotNull(doc, "Updated config should be parseable as ConfigDocument"); + + // Verify it contains migrated content (should have meta.configVersion = 1 or other migrated keys) + assertTrue(updatedContent.contains("meta") || updatedContent.contains("configVersion"), + "Updated config should contain migrated content"); + + // Also verify it's parseable as Config (fallback) + Config parsed = ConfigFactory.parseString(updatedContent); + assertTrue(parsed.hasPath("meta.configVersion")); + } + + @Test + @DisplayName("Correctly detects explicitly set audio sources after config update") + void correctlyDetectsExplicitlySetAudioSourcesAfterConfigUpdate() throws IOException { + // Create a v1 config with partial audioSources (some true, some false, missing local) + // This simulates a scenario where user has explicitly set some sources + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = true + soundcloud = true + bandcamp = false + vimeo = false + # local key is intentionally missing + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + + // Verify that the config file was updated (local key should be added) + String updatedContent = readFileContent(configFile); + Config parsed = ConfigFactory.parseString(updatedContent); + assertTrue(parsed.hasPath("playback.audioSources.local"), + "Missing local key should be added to config file"); + + // After the fix, the bot should reload the config after update + // and correctly detect that youtube and soundcloud were explicitly set to true + var enabledSources = config.getEnabledAudioSources(); + assertNotNull(enabledSources); + + // The key fix: after reloading migratedUserConfig, it should see the explicitly set keys + // After the config update, all missing keys are added with template defaults (true) + // So all keys that exist in the updated config file are considered "explicitly set" + assertTrue(enabledSources.contains(AudioSource.YOUTUBE), + "Youtube should be enabled (was explicitly set to true)"); + assertTrue(enabledSources.contains(AudioSource.SOUNDCLOUD), + "Soundcloud should be enabled (was explicitly set to true)"); + assertFalse(enabledSources.contains(AudioSource.BANDCAMP), + "Bandcamp should not be enabled (was explicitly set to false)"); + assertFalse(enabledSources.contains(AudioSource.VIMEO), + "Vimeo should not be enabled (was explicitly set to false)"); + + // The newly added local key IS enabled because after config update and reload, + // it exists in the config file with template default (true), so it's considered "explicitly set" + assertTrue(enabledSources.contains(AudioSource.LOCAL), + "Local should be enabled (was added to config file with template default true)"); + + // After config update, all missing audio source keys are added with template defaults (true) + // So sources that were missing (like twitch, beam, getyarn, nico, http, bandcamp, vimeo) + // are added with true, making them enabled + // Original config had: youtube=true, soundcloud=true, bandcamp=false, vimeo=false, local missing + // After update: all missing sources are added with true, so enabled = youtube, soundcloud, local, + // and all other missing sources (twitch, beam, getyarn, nico, http) = 8 total + assertTrue(enabledSources.size() >= 3, + "Should have at least 3 enabled sources (youtube, soundcloud, and local)"); + // Verify that sources explicitly set to false remain disabled + assertFalse(enabledSources.contains(AudioSource.BANDCAMP), + "Bandcamp should remain disabled (was explicitly set to false)"); + assertFalse(enabledSources.contains(AudioSource.VIMEO), + "Vimeo should remain disabled (was explicitly set to false)"); + } + } + + @Nested + @DisplayName("Error Handling Integration") + class ErrorHandlingIntegrationTests { + + @Test + @DisplayName("Handles ConfigException and shows alert") + void handlesConfigExceptionAndShowsAlert() throws IOException { + // Malformed config + String configContent = """ + token = test_token + owner = 123456789 + invalid syntax { + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertFalse(config.isValid()); + // Should have shown error alert + assertEquals(1, mockUserInteraction.getAlertCalls().size()); + var alert = mockUserInteraction.getLastAlert(); + assertNotNull(alert); + assertEquals("Config", alert.getContext()); + } + + @Test + @DisplayName("Shows config location in error messages") + void showsConfigLocationInErrorMessages() throws IOException { + Path configFile = createTempConfigFile("invalid syntax"); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + var alert = mockUserInteraction.getLastAlert(); + if (alert != null) { + assertTrue(alert.getMessage().contains("Config Location") || + alert.getMessage().contains(configFile.toString())); + } + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigMigrationIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigMigrationIntegrationTest.java new file mode 100644 index 0000000..4d22a89 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigMigrationIntegrationTest.java @@ -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.integration; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.MockUserInteraction; +import com.jagrosh.jmusicbot.audio.AudioSource; +import com.jagrosh.jmusicbot.config.io.ConfigIO; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigValueFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("BotConfig Migration Integration Tests") +class BotConfigMigrationIntegrationTest extends BaseConfigTest { + // Uses BaseConfigTest's system property management + + @Nested + @DisplayName("BotConfig Loads Migrated Config") + class BotConfigLoadTests { + + @Test + @DisplayName("BotConfig.load() works with migrated legacy config") + void testBotConfigLoadsMigratedConfig() throws IOException { + String legacyConfig = """ + token = integration_test_token + owner = 987654321 + prefix = "!!" + altprefix = "??" + help = commands + game = DEFAULT + status = ONLINE + """; + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + assertEquals("integration_test_token", config.getToken()); + assertEquals(987654321L, config.getOwnerId()); + assertEquals("!!", config.getPrefix()); + assertEquals("??", config.getAltPrefix()); + assertEquals("commands", config.getHelp()); + } + + @Test + @DisplayName("BotConfig getters return correct migrated values") + void testBotConfigValuesCorrectAfterMigration() throws IOException { + String legacyConfig = """ + token = test_token + owner = 123456789 + prefix = "!!" + altprefix = "NONE" + help = help + success = ✅ + warning = ⚠️ + error = ❌ + loading = ⏳ + searching = 🔍 + game = Playing music + status = IDLE + songinstatus = true + npimages = true + stayinchannel = true + maxtime = 3600 + maxytplaylistpages = 20 + useyoutubeoauth = true + alonetimeuntilstop = 300 + playlistsfolder = CustomPlaylists + updatealerts = false + loglevel = debug + eval = false + evalengine = Nashorn + skipratio = 0.75 + """; + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + assertEquals("test_token", config.getToken()); + assertEquals(123456789L, config.getOwnerId()); + assertEquals("!!", config.getPrefix()); + assertNull(config.getAltPrefix()); // "NONE" converted to null + assertEquals("help", config.getHelp()); + assertEquals("✅", config.getSuccess()); + assertEquals("⚠️", config.getWarning()); + assertEquals("❌", config.getError()); + assertEquals("⏳", config.getLoading()); + assertEquals("🔍", config.getSearching()); + assertTrue(config.getSongInStatus()); + assertTrue(config.useNPImages()); + assertTrue(config.getStay()); + assertEquals(3600L, config.getMaxSeconds()); + assertEquals(20, config.getMaxYTPlaylistPages()); + assertTrue(config.useYouTubeOauth()); + assertEquals(300L, config.getAloneTimeUntilStop()); + assertEquals("CustomPlaylists", config.getPlaylistsFolder()); + assertFalse(config.useUpdateAlerts()); + assertEquals("debug", config.getLogLevel()); + assertFalse(config.useEval()); + assertEquals("Nashorn", config.getEvalEngine()); + assertEquals(0.75, config.getSkipRatio()); + } + + @Test + @DisplayName("BotConfig audio sources work correctly after migration") + void testAudioSourcesAfterMigration() throws IOException { + String legacyConfig = """ + token = test_token + owner = 123456789 + audiosources = [ youtube, soundcloud, local ] + """; + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + Set<AudioSource> enabled = config.getEnabledAudioSources(); + + assertTrue(enabled.contains(AudioSource.YOUTUBE)); + assertTrue(enabled.contains(AudioSource.SOUNDCLOUD)); + assertTrue(enabled.contains(AudioSource.LOCAL)); + assertFalse(enabled.contains(AudioSource.BANDCAMP)); + + assertTrue(config.isAudioSourceEnabled(AudioSource.YOUTUBE)); + assertTrue(config.isAudioSourceEnabled(AudioSource.SOUNDCLOUD)); + assertTrue(config.isAudioSourceEnabled(AudioSource.LOCAL)); + assertFalse(config.isAudioSourceEnabled(AudioSource.BANDCAMP)); + } + + @Test + @DisplayName("BotConfig audio sources default to all enabled when missing") + void testAudioSourcesDefaultAfterMigration() throws IOException { + String legacyConfig = """ + token = test_token + owner = 123456789 + """; + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + Set<AudioSource> enabled = config.getEnabledAudioSources(); + + // All sources should be enabled by default + assertTrue(enabled.contains(AudioSource.YOUTUBE)); + assertTrue(enabled.contains(AudioSource.SOUNDCLOUD)); + assertTrue(enabled.contains(AudioSource.LOCAL)); + assertTrue(enabled.contains(AudioSource.BANDCAMP)); + } + } + + @Nested + @DisplayName("BotConfig Migration Failure Tests") + class BotConfigMigrationFailureTests { + + @Test + @DisplayName("BotConfig.load() shows error alert when migration fails") + void testBotConfigShowsErrorOnMigrationFailure() throws IOException { + // Create a legacy config (version 0) + String legacyConfig = """ + token = test_token + owner = 123456789 + """; + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + // Create defaults that claim a future version with no migration path (2->3 missing) + Config fakeDefaults = ConfigIO.loadDefaults().withValue( + "meta.configVersion", + ConfigValueFactory.fromAnyRef(3)); + + // Mock ConfigIO.loadDefaults() to return our fake defaults + try (MockedStatic<ConfigIO> mockedConfigIO = Mockito.mockStatic(ConfigIO.class)) { + mockedConfigIO.when(ConfigIO::loadDefaults).thenReturn(fakeDefaults); + // Let getConfigPath() work normally + mockedConfigIO.when(ConfigIO::getConfigPath).thenCallRealMethod(); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + // Config should be invalid due to migration failure + assertFalse(config.isValid()); + + // User should have been alerted about the migration failure + MockUserInteraction.AlertCall lastAlert = mockUserInteraction.getLastAlert(); + assertNotNull(lastAlert, "Expected an alert to be shown"); + assertEquals(Level.ERROR, lastAlert.getLevel()); + assertEquals("Config Migration", lastAlert.getContext()); + assertTrue(lastAlert.getMessage().contains("migration"), + "Alert message should mention migration: " + lastAlert.getMessage()); + } + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/config/RemoteCipherConfigIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/config/RemoteCipherConfigIntegrationTest.java new file mode 100644 index 0000000..5cacab4 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/integration/config/RemoteCipherConfigIntegrationTest.java @@ -0,0 +1,111 @@ +/* + * 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.integration.config; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.config.loader.ConfigLoader; +import com.jagrosh.jmusicbot.config.model.ConfigOption; +import com.typesafe.config.Config; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the remote cipher settings travel the real config chain: + * reference.conf defaults -> user overrides -> {@link ConfigOption} keys. + * + * <p>The unit tests around {@code buildYoutubeOptions} mock {@code BotConfig}, so a typo in + * a config path would not show up there. These tests exercise the untyped key strings. + */ +@DisplayName("Remote Cipher Config Integration Tests") +class RemoteCipherConfigIntegrationTest extends BaseConfigTest { + + // meta.configVersion must match reference.conf. Without it the file is detected as a + // legacy v0 config and migrated, and the legacy mapping drops keys it does not know. + private static final String MINIMAL = + "meta { configVersion = 2 }\ndiscord.token = test_token\ndiscord.owner = 123456789\n"; + + @Test + @DisplayName("A default install gets a working remote cipher without touching its config") + void defaultsProvideRemoteCipher() throws IOException { + Config merged = ConfigLoader.loadMergedConfig(createTempConfigFile(MINIMAL)); + + assertTrue(merged.hasPath(ConfigOption.YOUTUBE_REMOTE_CIPHER_URL.getKey()), + "reference.conf must define " + ConfigOption.YOUTUBE_REMOTE_CIPHER_URL.getKey()); + + String url = ConfigOption.YOUTUBE_REMOTE_CIPHER_URL.getString(merged); + assertFalse(url.isBlank(), + "The shipped default must be a real cipher server - a blank default would leave " + + "every install on the local extractor, which is the bug this setting fixes"); + assertTrue(url.startsWith("https://"), "The default cipher server should use HTTPS, got: " + url); + } + + @Test + @DisplayName("Password defaults to empty rather than missing") + void passwordDefaultsToEmpty() throws IOException { + Config merged = ConfigLoader.loadMergedConfig(createTempConfigFile(MINIMAL)); + + assertTrue(merged.hasPath(ConfigOption.YOUTUBE_REMOTE_CIPHER_PASSWORD.getKey()), + "reference.conf must define " + ConfigOption.YOUTUBE_REMOTE_CIPHER_PASSWORD.getKey()); + assertEquals("", ConfigOption.YOUTUBE_REMOTE_CIPHER_PASSWORD.getString(merged), + "The public cipher server needs no password, so the default should be empty"); + } + + @Test + @DisplayName("Users can point at a self-hosted cipher server") + void userCanOverrideCipherServer() throws IOException { + Path configFile = createTempConfigFile(MINIMAL + + "playback.youtube.remoteCipher.url = \"https://cipher.example.internal/\"\n" + + "playback.youtube.remoteCipher.password = \"s3cret\"\n"); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertEquals("https://cipher.example.internal/", + ConfigOption.YOUTUBE_REMOTE_CIPHER_URL.getString(merged)); + assertEquals("s3cret", + ConfigOption.YOUTUBE_REMOTE_CIPHER_PASSWORD.getString(merged)); + } + + @Test + @DisplayName("Users can opt out of the remote cipher entirely") + void userCanDisableRemoteCipher() throws IOException { + Path configFile = createTempConfigFile(MINIMAL + + "playback.youtube.remoteCipher.url = \"\"\n"); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertEquals("", ConfigOption.YOUTUBE_REMOTE_CIPHER_URL.getString(merged), + "An empty URL should survive the merge so the local extractor can be chosen"); + } + + @Test + @DisplayName("The remote cipher default is independent of the OAuth setting") + void cipherIsIndependentOfOauth() throws IOException { + // The original bug was exactly this coupling. + Config oauthOff = ConfigLoader.loadMergedConfig( + createTempConfigFile(MINIMAL + "playback.youtube.useOAuth = false\n")); + Config oauthOn = ConfigLoader.loadMergedConfig( + createTempConfigFile(MINIMAL + "playback.youtube.useOAuth = true\n")); + + assertEquals(ConfigOption.YOUTUBE_REMOTE_CIPHER_URL.getString(oauthOn), + ConfigOption.YOUTUBE_REMOTE_CIPHER_URL.getString(oauthOff), + "The cipher server must not depend on whether OAuth is enabled"); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/config/loader/ConfigLoaderIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/config/loader/ConfigLoaderIntegrationTest.java new file mode 100644 index 0000000..4a5c899 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/integration/config/loader/ConfigLoaderIntegrationTest.java @@ -0,0 +1,198 @@ +/* + * 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.integration.config.loader; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.config.loader.ConfigLoader; +import com.typesafe.config.Config; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigLoader Integration Tests") +class ConfigLoaderIntegrationTest extends BaseConfigTest { + + @Nested + @DisplayName("Real Config File Loading") + class RealConfigFileLoadingTests { + + @Test + @DisplayName("Loads real config file with all options") + void loadsRealConfigFileWithAllOptions() throws IOException { + String configContent = """ + token = integration_test_token + owner = 987654321 + prefix = "!!" + altprefix = "??" + help = commands + success = ✅ + warning = ⚠️ + error = ❌ + loading = ⏳ + searching = 🔍 + game = Playing music + status = ONLINE + songinstatus = true + npimages = true + stayinchannel = true + maxtime = 3600 + maxytplaylistpages = 20 + useyoutubeoauth = true + alonetimeuntilstop = 300 + playlistsfolder = CustomPlaylists + updatealerts = false + loglevel = debug + eval = false + evalengine = Nashorn + skipratio = 0.75 + aliases { + play = [ p, playmusic ] + skip = [ voteskip, vs ] + } + transforms = {} + audiosources = [ youtube, soundcloud, bandcamp ] + """; + Path configFile = createTempConfigFile(configContent); + + Config userConfig = ConfigLoader.loadRawUserConfig(configFile); + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertNotNull(userConfig); + assertNotNull(merged); + assertEquals("integration_test_token", merged.getString("discord.token")); + assertEquals(987654321L, merged.getLong("discord.owner")); + assertEquals("!!", merged.getString("commands.prefix")); + assertTrue(merged.getBoolean("presence.songInStatus")); + assertEquals(20, merged.getInt("playback.maxYouTubePlaylistPages")); + } + + @Test + @DisplayName("Merges user config with reference.conf defaults") + void mergesUserConfigWithReferenceDefaults() throws IOException { + // Minimal user config + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + """; + Path configFile = createTempConfigFile(configContent); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertNotNull(merged); + // Should have access to default values from reference.conf + // User values should override defaults + assertEquals("test_token", merged.getString("discord.token")); + assertEquals(123456789L, merged.getLong("discord.owner")); + } + + @Test + @DisplayName("Handles malformed config file gracefully") + void handlesMalformedConfigFileGracefully() throws IOException { + // Config with syntax errors + String configContent = """ + discord.token = test_token + discord.owner = 123456789 + commands.prefix = "!!" + invalid syntax here { + """; + Path configFile = createTempConfigFile(configContent); + + // Should throw ConfigException + assertThrows(Exception.class, () -> { + ConfigLoader.loadRawUserConfig(configFile); + }); + } + + @Test + @DisplayName("Handles nested config structures") + void handlesNestedConfigStructures() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + commands.aliases { + play = [ p, playmusic, pm ] + skip = [ voteskip, vs, s ] + queue = [ list, q ] + } + playback.transforms { + youtube { + enabled = true + } + } + """; + Path configFile = createTempConfigFile(configContent); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertNotNull(merged); + assertTrue(merged.hasPath("commands.aliases.play")); + assertEquals(3, merged.getStringList("commands.aliases.play").size()); + assertTrue(merged.hasPath("playback.transforms.youtube")); + } + } + + @Nested + @DisplayName("Config Merging Behavior") + class ConfigMergingBehaviorTests { + + @Test + @DisplayName("User config values override defaults") + void userConfigValuesOverrideDefaults() throws IOException { + // Use new format (already nested) - no migration needed + String configContent = """ + meta { + configVersion = 1 + } + discord.token = user_token + discord.owner = 999999999 + commands.prefix = user_prefix + """; + Path configFile = createTempConfigFile(configContent); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertEquals("user_token", merged.getString("discord.token")); + assertEquals(999999999L, merged.getLong("discord.owner")); + assertEquals("user_prefix", merged.getString("commands.prefix")); + } + + @Test + @DisplayName("Defaults are used when user config is missing values") + void defaultsAreUsedWhenUserConfigMissingValues() throws IOException { + // User config with only required fields + String configContent = "discord.token = test_token\ndiscord.owner = 123456789"; + Path configFile = createTempConfigFile(configContent); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + // Should still be able to access optional fields from defaults + assertNotNull(merged); + assertTrue(merged.hasPath("commands.help")); + assertEquals("help", merged.getString("commands.help")); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/config/migration/ConfigMigrationIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/config/migration/ConfigMigrationIntegrationTest.java new file mode 100644 index 0000000..3dd87b8 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/integration/config/migration/ConfigMigrationIntegrationTest.java @@ -0,0 +1,283 @@ +/* + * 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.integration.config.migration; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.config.diagnostics.ConfigDiagnostics; +import com.jagrosh.jmusicbot.config.loader.ConfigLoader; +import com.jagrosh.jmusicbot.config.model.ConfigUpdateType; +import com.jagrosh.jmusicbot.config.update.ConfigUpdater; +import com.jagrosh.jmusicbot.config.migration.ConfigMigration; +import com.jagrosh.jmusicbot.testutil.config.LegacyConfigBuilder; +import com.jagrosh.jmusicbot.testutil.config.V1ConfigBuilder; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.parser.ConfigDocument; +import com.typesafe.config.parser.ConfigDocumentFactory; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Config Migration Integration Tests") +class ConfigMigrationIntegrationTest extends BaseConfigTest { + // Uses BaseConfigTest's system property management + + @Nested + @DisplayName("Startup with Legacy Config") + class LegacyConfigTests { + + @Test + @DisplayName("loadMergedConfig migrates legacy config successfully") + void testStartupWithLegacyConfig() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("integration_test_token") + .withOwner(987654321L) + .withPrefix("!!") + .withAltPrefix("NONE") + .withHelp("commands") + .withGame("DEFAULT") + .withStatus("ONLINE") + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + // Verify migration occurred + assertTrue(merged.hasPath("meta.configVersion")); + assertEquals(2, merged.getInt("meta.configVersion")); + + // Verify values migrated correctly + assertEquals("integration_test_token", merged.getString("discord.token")); + assertEquals(987654321L, merged.getLong("discord.owner")); + assertEquals("!!", merged.getString("commands.prefix")); + assertEquals("NONE", merged.getString("commands.altPrefix")); + assertEquals("commands", merged.getString("commands.help")); + assertEquals("DEFAULT", merged.getString("presence.game")); + assertEquals("ONLINE", merged.getString("presence.status")); + } + + @Test + @DisplayName("loadMergedConfig preserves all user values after migration") + void testMigrationPreservesAllValues() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .withPrefix("!!") + .withAltPrefix("??") + .withHelp("help") + .withSuccess("✅") + .withWarning("⚠️") + .withError("❌") + .withLoading("⏳") + .withSearching("🔍") + .withGame("Playing music") + .withStatus("IDLE") + .withSongInStatus(true) + .withNPImages(true) + .withStayInChannel(true) + .withMaxTime(3600L) + .withMaxYTPlaylistPages(20) + .withUseYouTubeOAuth(true) + .withAloneTimeUntilStop(300L) + .withPlaylistsFolder("CustomPlaylists") + .withUpdateAlerts(false) + .withLogLevel("debug") + .withEval(false) + .withEvalEngine("Nashorn") + .withSkipRatio(0.75) + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + // Verify all values preserved + assertEquals("test_token", merged.getString("discord.token")); + assertEquals(123456789L, merged.getLong("discord.owner")); + assertEquals("!!", merged.getString("commands.prefix")); + assertEquals("??", merged.getString("commands.altPrefix")); + assertEquals("help", merged.getString("commands.help")); + assertEquals("✅", merged.getString("ui.emojis.success")); + assertEquals("⚠️", merged.getString("ui.emojis.warning")); + assertEquals("❌", merged.getString("ui.emojis.error")); + assertEquals("⏳", merged.getString("ui.emojis.loading")); + assertEquals("🔍", merged.getString("ui.emojis.searching")); + assertEquals("Playing music", merged.getString("presence.game")); + assertEquals("IDLE", merged.getString("presence.status")); + assertTrue(merged.getBoolean("presence.songInStatus")); + assertTrue(merged.getBoolean("nowPlaying.images")); + assertTrue(merged.getBoolean("voice.stayInChannel")); + assertEquals(3600L, merged.getLong("playback.maxTrackSeconds")); + assertEquals(20, merged.getInt("playback.maxYouTubePlaylistPages")); + assertTrue(merged.getBoolean("playback.youtube.useOAuth")); + assertEquals(300L, merged.getLong("voice.aloneTimeUntilStopSeconds")); + assertEquals("CustomPlaylists", merged.getString("paths.playlistsFolder")); + assertFalse(merged.getBoolean("updates.alerts")); + assertEquals("debug", merged.getString("logging.level")); + assertFalse(merged.getBoolean("dangerous.eval")); + assertEquals("Nashorn", merged.getString("dangerous.evalEngine")); + assertEquals(0.75, merged.getDouble("playback.skipRatio")); + } + } + + @Nested + @DisplayName("Startup with New Format Config") + class NewFormatConfigTests { + + @Test + @DisplayName("loadMergedConfig handles new-format config without migration") + void testStartupWithNewFormatConfig() throws IOException { + String newConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("integration_test_token") + .withDiscordOwner(987654321L) + .withCommandsPrefix("!!") + .withCommandsAltPrefix("NONE") + .withCommandsHelp("help") + .buildAsString(); + + Path configFile = createTempConfigFile(newConfig); + setConfigFileProperty(configFile); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + // Verify no migration occurred (already version 1) + assertEquals(1, ConfigMigration.detectVersion(ConfigLoader.loadRawUserConfig(configFile))); + + // Verify values preserved + assertEquals("integration_test_token", merged.getString("discord.token")); + assertEquals(987654321L, merged.getLong("discord.owner")); + assertEquals("!!", merged.getString("commands.prefix")); + } + } + + @Nested + @DisplayName("Custom Config Path") + class CustomConfigPathTests { + + @Test + @DisplayName("loadMergedConfig works with -Dconfig.file custom path") + void testCustomConfigPath() throws IOException { + String config = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .buildAsString(); + Path configFile = createTempConfigFile(config); + setConfigFileProperty(configFile); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertTrue(merged.hasPath("discord.token")); + assertEquals("test_token", merged.getString("discord.token")); + } + } + + @Nested + @DisplayName("Generated Config File") + class GeneratedConfigFileTests { + + @Test + @DisplayName("generateUpdatedConfig creates parseable config.updated.conf") + void testGeneratedConfigIsParseable() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .withPrefix("!!") + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.MIGRATION); + + assertNotNull(updatedPath); + assertFileExists(updatedPath); + + // Verify the generated file is parseable as ConfigDocument (primary method) + String content = readFileContent(updatedPath); + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + + // Also verify it's parseable as Config (fallback) + Config generated = ConfigFactory.parseFile(updatedPath.toFile()); + assertNotNull(generated); + assertTrue(generated.hasPath("meta.configVersion")); + } + } + + @Nested + @DisplayName("Audio Sources Migration") + class AudioSourcesMigrationTests { + + @Test + @DisplayName("migrates audiosources list to nested booleans") + void testAudioSourcesMigration() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .withAudioSources("youtube", "soundcloud", "local") + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertTrue(merged.hasPath("playback.audioSources")); + Config audioSources = merged.getConfig("playback.audioSources"); + assertTrue(audioSources.getBoolean("youtube")); + assertTrue(audioSources.getBoolean("soundcloud")); + assertTrue(audioSources.getBoolean("local")); + assertFalse(audioSources.getBoolean("bandcamp")); + } + + @Test + @DisplayName("defaults to all enabled when audiosources missing") + void testAudioSourcesDefault() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + setConfigFileProperty(configFile); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertTrue(merged.hasPath("playback.audioSources")); + Config audioSources = merged.getConfig("playback.audioSources"); + // All should be enabled by default + assertTrue(audioSources.getBoolean("youtube")); + assertTrue(audioSources.getBoolean("soundcloud")); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/config/update/ConfigUpdaterIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/config/update/ConfigUpdaterIntegrationTest.java new file mode 100644 index 0000000..e2cae3d --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/integration/config/update/ConfigUpdaterIntegrationTest.java @@ -0,0 +1,634 @@ +/* + * 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.integration.config.update; + +import com.jagrosh.jmusicbot.BaseConfigTest; +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.migration.ConfigMigration; +import com.jagrosh.jmusicbot.config.model.ConfigUpdateType; +import com.jagrosh.jmusicbot.config.update.ConfigUpdater; +import com.jagrosh.jmusicbot.testutil.config.LegacyConfigBuilder; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.parser.ConfigDocument; +import com.typesafe.config.parser.ConfigDocumentFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigUpdater Integration Tests") +class ConfigUpdaterIntegrationTest extends BaseConfigTest { + + @Nested + @DisplayName("ConfigDocument Usage") + class ConfigDocumentUsageTests { + + @Test + @DisplayName("generateUpdatedConfig creates file using ConfigDocument") + void generateUpdatedConfigUsesConfigDocument() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .withPrefix("!!") + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.MIGRATION); + + assertNotNull(updatedPath); + assertFileExists(updatedPath); + + // Verify the generated file can be parsed as ConfigDocument + String content = readFileContent(updatedPath); + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + + // Verify it's also parseable as Config + Config generated = ConfigFactory.parseString(content); + assertNotNull(generated); + assertTrue(generated.hasPath("meta.configVersion")); + } + + @Test + @DisplayName("generateUpdatedConfig preserves structure from reference.conf") + void generateUpdatedConfigPreservesStructure() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.MIGRATION); + + String content = readFileContent(updatedPath); + + // Should have nested structure matching reference.conf + assertTrue(content.contains("meta {") || content.contains("meta")); + assertTrue(content.contains("discord {") || content.contains("discord")); + assertTrue(content.contains("commands {") || content.contains("commands")); + } + + @Test + @DisplayName("generateUpdatedConfig creates backup file") + void generateUpdatedConfigCreatesBackup() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + + // Ensure file exists before calling generateUpdatedConfig + assertTrue(Files.exists(configFile), "Config file should exist before update"); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.MIGRATION); + + assertNotNull(updatedPath); + + // Verify backup was created using the helper method (use configFile, which gets normalized internally) + assertTrue(ConfigUpdater.backupExists(configFile), + "Backup file should exist. Config file: " + configFile); + + // Verify backup contains original content (calculate path using same logic as ConfigUpdater) + Path normalizedPath = configFile.toAbsolutePath().normalize(); + Path backupPath = normalizedPath.resolveSibling(normalizedPath.getFileName().toString() + ".bak"); + assertTrue(Files.exists(backupPath), "Backup file should exist at: " + backupPath); + + String backupContent = readFileContent(backupPath); + // Backup should contain the original legacy config content + assertTrue(backupContent.contains("test_token"), + "Backup should contain original token. Backup content: " + backupContent.substring(0, Math.min(200, backupContent.length()))); + // Also verify it's the legacy format (not migrated) + assertTrue(backupContent.contains("token") || backupContent.contains("owner"), + "Backup should contain original config keys"); + } + + @Test + @DisplayName("generateUpdatedConfig does not overwrite existing backup files") + void generateUpdatedConfigDoesNotOverwriteExistingBackups() throws IOException { + String legacyConfig1 = LegacyConfigBuilder.create() + .withToken("first_token") + .withOwner(111111111L) + .buildAsString(); + + String legacyConfig2 = LegacyConfigBuilder.create() + .withToken("second_token") + .withOwner(222222222L) + .buildAsString(); + + String legacyConfig3 = LegacyConfigBuilder.create() + .withToken("third_token") + .withOwner(333333333L) + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig1); + Path normalizedPath = configFile.toAbsolutePath().normalize(); + Path backupPath1 = normalizedPath.resolveSibling(normalizedPath.getFileName().toString() + ".bak"); + Path backupPath2 = normalizedPath.resolveSibling(normalizedPath.getFileName().toString() + ".bak1"); + + // First update - should create .bak + Config merged1 = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser1 = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + ConfigDiagnostics.Report diagnostics1 = ConfigDiagnostics.analyze(migratedUser1, merged1, defaults); + ConfigUpdater.generateUpdatedConfig(configFile, migratedUser1, diagnostics1, ConfigUpdateType.MIGRATION); + + assertTrue(Files.exists(backupPath1), "First backup (.bak) should exist"); + String backup1Content = readFileContent(backupPath1); + assertTrue(backup1Content.contains("first_token"), "First backup should contain first_token"); + + // Write second config to the config file + writeFileContent(configFile, legacyConfig2); + + // Second update - should create .bak.1 (not overwrite .bak) + Config merged2 = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser2 = ConfigLoader.loadMigratedUserConfig(configFile); + ConfigDiagnostics.Report diagnostics2 = ConfigDiagnostics.analyze(migratedUser2, merged2, defaults); + ConfigUpdater.generateUpdatedConfig(configFile, migratedUser2, diagnostics2, ConfigUpdateType.MIGRATION); + + assertTrue(Files.exists(backupPath1), "First backup (.bak) should still exist"); + assertTrue(Files.exists(backupPath2), "Second backup (.bak1) should exist"); + + // Verify first backup was NOT overwritten + String backup1ContentAfter = readFileContent(backupPath1); + assertTrue(backup1ContentAfter.contains("first_token"), + "First backup should still contain first_token after second update"); + + // Verify second backup contains second config + String backup2Content = readFileContent(backupPath2); + assertTrue(backup2Content.contains("second_token"), + "Second backup should contain second_token"); + + // Write third config to the config file + writeFileContent(configFile, legacyConfig3); + + // Third update - should create .bak.2 + Config merged3 = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser3 = ConfigLoader.loadMigratedUserConfig(configFile); + ConfigDiagnostics.Report diagnostics3 = ConfigDiagnostics.analyze(migratedUser3, merged3, defaults); + ConfigUpdater.generateUpdatedConfig(configFile, migratedUser3, diagnostics3, ConfigUpdateType.MIGRATION); + + Path backupPath3 = normalizedPath.resolveSibling(normalizedPath.getFileName().toString() + ".bak2"); + assertTrue(Files.exists(backupPath3), "Third backup (.bak2) should exist"); + + // All backups should be preserved + assertTrue(readFileContent(backupPath1).contains("first_token"), "First backup preserved"); + assertTrue(readFileContent(backupPath2).contains("second_token"), "Second backup preserved"); + assertTrue(readFileContent(backupPath3).contains("third_token"), "Third backup preserved"); + } + + @Test + @DisplayName("generateUpdatedConfig preserves comments from reference.conf via ConfigDocument") + void generateUpdatedConfigPreservesComments() throws IOException { + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.MIGRATION); + + String content = readFileContent(updatedPath); + + // Parse as ConfigDocument to verify it's using ConfigDocument (not fallback) + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + + // Verify the content is valid and has correct values + Config generated = ConfigFactory.parseString(content); + assertEquals("test_token", generated.getString("discord.token")); + assertEquals(123456789L, generated.getLong("discord.owner")); + + // ConfigDocument should preserve structure from reference.conf + // The exact formatting depends on reference.conf, but structure should be preserved + assertTrue(content.contains("meta") || content.contains("discord")); + } + + @Test + @DisplayName("Full update flow uses ConfigDocument to preserve formatting") + void fullUpdateFlowUsesConfigDocument() throws IOException { + // Create a legacy config file + String legacyConfig = LegacyConfigBuilder.create() + .withToken("original_token") + .withOwner(123456789L) + .withPrefix("!!") + .withGame("Playing music") + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + + // Load and migrate + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + // Generate updated config (should use ConfigDocument) + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.MIGRATION); + + assertNotNull(updatedPath); + String content = readFileContent(updatedPath); + + // Verify it's parseable as ConfigDocument (primary method) + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + + // Verify values are correct + Config parsed = ConfigFactory.parseString(content); + assertEquals("original_token", parsed.getString("discord.token")); + assertEquals(123456789L, parsed.getLong("discord.owner")); + assertEquals("!!", parsed.getString("commands.prefix")); + assertEquals("Playing music", parsed.getString("presence.game")); + + // Verify structure is preserved (nested sections) + assertTrue(parsed.hasPath("meta.configVersion")); + assertTrue(parsed.hasPath("discord.token")); + assertTrue(parsed.hasPath("commands.prefix")); + assertTrue(parsed.hasPath("presence.game")); + } + } + + @Nested + @DisplayName("Nested Config Preservation") + class NestedConfigPreservationTests { + + @Test + @DisplayName("generateUpdatedConfig preserves existing nested config values when adding missing keys") + void generateUpdatedConfigPreservesExistingNestedConfigValues() throws IOException { + // Create a v1 config with partial audioSources (missing local key) + // This simulates the user's scenario where they had some keys set to false + // but deleted the local key + String v1Config = """ + meta { + configVersion = 1 + } + discord { + token = test_token + owner = 123456789 + } + playback { + audioSources { + youtube = false + soundcloud = false + bandcamp = false + vimeo = false + twitch = false + beam = false + getyarn = false + nico = false + http = false + # local key is intentionally missing + } + } + """; + + Path configFile = createTempConfigFile(v1Config); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + // Verify that local is detected as missing + assertTrue(diagnostics.getMissingOptional().contains("playback.audioSources.local"), + "local key should be detected as missing"); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.REPAIR); + + assertNotNull(updatedPath); + String content = readFileContent(updatedPath); + + // Parse the updated config + Config updated = ConfigFactory.parseString(content); + assertTrue(updated.hasPath("playback.audioSources")); + Config audioSources = updated.getConfig("playback.audioSources"); + + // Verify that existing values are preserved (should still be false) + assertFalse(audioSources.getBoolean("youtube"), + "Existing youtube=false should be preserved"); + assertFalse(audioSources.getBoolean("soundcloud"), + "Existing soundcloud=false should be preserved"); + assertFalse(audioSources.getBoolean("bandcamp"), + "Existing bandcamp=false should be preserved"); + assertFalse(audioSources.getBoolean("vimeo"), + "Existing vimeo=false should be preserved"); + + // Verify that missing key is added with template default (true) + assertTrue(audioSources.getBoolean("local"), + "Missing local key should be added with template default (true)"); + } + + @Test + @DisplayName("generateUpdatedConfig preserves template defaults for missing nested keys") + void generateUpdatedConfigPreservesTemplateDefaultsForMissingNestedKeys() throws IOException { + // Create a v1 config with only one audioSources key set + String v1Config = """ + meta { + configVersion = 1 + } + discord { + token = test_token + owner = 123456789 + } + playback { + audioSources { + youtube = false + # All other keys are missing + } + } + """; + + Path configFile = createTempConfigFile(v1Config); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + + ConfigDiagnostics.Report diagnostics = + ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, ConfigUpdateType.REPAIR); + + assertNotNull(updatedPath); + String content = readFileContent(updatedPath); + + // Parse the updated config + Config updated = ConfigFactory.parseString(content); + Config audioSources = updated.getConfig("playback.audioSources"); + + // Verify user's value is preserved + assertFalse(audioSources.getBoolean("youtube"), + "User's youtube=false should be preserved"); + + // Verify all missing keys get template defaults (true) + assertTrue(audioSources.getBoolean("soundcloud"), + "Missing soundcloud should get template default (true)"); + assertTrue(audioSources.getBoolean("local"), + "Missing local should get template default (true)"); + assertTrue(audioSources.getBoolean("bandcamp"), + "Missing bandcamp should get template default (true)"); + assertTrue(audioSources.getBoolean("vimeo"), + "Missing vimeo should get template default (true)"); + } + } + + @Nested + @DisplayName("Migration vs Repair Wording") + class MigrationVsRepairWordingTests { + + /** + * Helper method that simulates the full update flow as done in BotConfig. + * Determines the correct ConfigUpdateType based on version and diagnostics. + */ + private ConfigUpdateType determineUpdateType(Path configFile) { + Config rawUserConfig = ConfigLoader.loadRawUserConfig(configFile); + Config defaults = ConfigIO.loadDefaults(); + + int originalVersion = ConfigMigration.detectVersion(rawUserConfig); + int latestVersion = ConfigMigration.getLatestVersion(defaults); + + Config migratedUserConfig = ConfigLoader.loadMigratedUserConfig(rawUserConfig, defaults); + Config mergedConfig = ConfigLoader.mergeWithDefaults(migratedUserConfig, defaults); + + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze( + migratedUserConfig, mergedConfig, defaults); + + boolean hasMissingKeys = !diagnostics.getMissingRequired().isEmpty() + || !diagnostics.getMissingOptional().isEmpty(); + + return ConfigUpdateType.determine(originalVersion, latestVersion, hasMissingKeys); + } + + @Test + @DisplayName("Legacy config (version 0) produces 'migrated' wording in generated file") + void legacyConfigProducesMigratedWording() throws IOException { + // Start with a legacy (version 0) config - no meta.configVersion + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .withPrefix("!!") + .buildAsString(); + + Path configFile = createTempConfigFile(legacyConfig); + + // Verify this is detected as version 0 + Config rawConfig = ConfigLoader.loadRawUserConfig(configFile); + assertEquals(0, ConfigMigration.detectVersion(rawConfig), + "Legacy config should be detected as version 0"); + + // Determine update type using the same logic as BotConfig + ConfigUpdateType updateType = determineUpdateType(configFile); + assertEquals(ConfigUpdateType.MIGRATION, updateType, + "Legacy config should result in MIGRATION update type"); + + // Generate the updated config file + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, updateType); + + // Read the generated file and verify the header comment + String content = readFileContent(updatedPath); + assertTrue(content.contains("# This file was automatically migrated on"), + "Generated file should contain 'migrated' wording for legacy config. Content starts with: " + + content.substring(0, Math.min(200, content.length()))); + assertFalse(content.contains("repaired"), + "Generated file should NOT contain 'repaired' for legacy config"); + } + + @Test + @DisplayName("V1 config with missing keys produces 'migrated' wording in generated file") + void v1ConfigWithMissingKeysProducesRepairedWording() throws IOException { + // Start with a v1 config that has missing keys + String v1ConfigWithMissingKeys = """ + meta { + configVersion = 1 + } + discord { + token = test_token + owner = 123456789 + } + # Many keys are missing - commands.prefix, presence.game, etc. + """; + + Path configFile = createTempConfigFile(v1ConfigWithMissingKeys); + + // Verify this is detected as version 1 + Config rawConfig = ConfigLoader.loadRawUserConfig(configFile); + assertEquals(1, ConfigMigration.detectVersion(rawConfig), + "V1 config should be detected as version 1"); + + // Determine update type using the same logic as BotConfig + ConfigUpdateType updateType = determineUpdateType(configFile); + assertEquals(ConfigUpdateType.MIGRATION, updateType, + "V1 config with missing keys should result in MIGRATION update type"); + + // Generate the updated config file + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + // Verify there are indeed missing keys + assertFalse(diagnostics.getMissingOptional().isEmpty(), + "V1 config should have missing optional keys detected"); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, updateType); + + // Read the generated file and verify the header comment + String content = readFileContent(updatedPath); + assertTrue(content.contains("# This file was automatically migrated on"), + "Generated file should contain 'migrated' wording for V1 config with missing keys. Content starts with: " + + content.substring(0, Math.min(200, content.length()))); + assertFalse(content.contains("repaired"), + "Generated file should NOT contain 'repaired' for V1 config migration"); + } + + @Test + @DisplayName("V1 config with specific missing key produces 'migrated' wording") + void v1ConfigMissingSpecificKeyProducesRepairedWording() throws IOException { + // Start with a v1 config where only playback.audioSources.local is missing + String v1ConfigMissingOneKey = """ + meta { + configVersion = 1 + } + discord { + token = test_token + owner = 123456789 + } + playback { + audioSources { + youtube = false + soundcloud = false + bandcamp = false + vimeo = false + twitch = false + beam = false + getyarn = false + nico = false + http = false + # local key is intentionally missing + } + } + """; + + Path configFile = createTempConfigFile(v1ConfigMissingOneKey); + + // Verify this is detected as version 1 + Config rawConfig = ConfigLoader.loadRawUserConfig(configFile); + assertEquals(1, ConfigMigration.detectVersion(rawConfig), + "V1 config should be detected as version 1"); + + // Determine update type + ConfigUpdateType updateType = determineUpdateType(configFile); + assertEquals(ConfigUpdateType.MIGRATION, updateType, + "V1 config missing playback.audioSources.local should result in MIGRATION"); + + // Generate the updated config file + Config merged = ConfigLoader.loadMergedConfig(configFile); + Config migratedUser = ConfigLoader.loadMigratedUserConfig(configFile); + Config defaults = ConfigFactory.load(); + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(migratedUser, merged, defaults); + + // Verify the specific key is detected as missing + assertTrue(diagnostics.getMissingOptional().contains("playback.audioSources.local"), + "Should detect playback.audioSources.local as missing"); + + Path updatedPath = ConfigUpdater.generateUpdatedConfig(configFile, migratedUser, diagnostics, updateType); + + // Read the generated file and verify the header comment + String content = readFileContent(updatedPath); + assertTrue(content.contains("# This file was automatically migrated on"), + "Generated file should contain 'migrated' wording when specific key is missing"); + assertFalse(content.contains("repaired"), + "Generated file should NOT contain 'repaired' for V1 migration"); + } + + @Test + @DisplayName("Version detection correctly identifies legacy vs v1 configs") + void versionDetectionWorksCorrectly() throws IOException { + // Legacy config (no configVersion) + String legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .buildAsString(); + Path legacyFile = createTempConfigFile(legacyConfig); + Config legacyRaw = ConfigLoader.loadRawUserConfig(legacyFile); + assertEquals(0, ConfigMigration.detectVersion(legacyRaw), + "Config without meta.configVersion should be version 0"); + + // V1 config (has configVersion = 1) + String v1Config = """ + meta { + configVersion = 1 + } + discord { + token = test_token + owner = 123456789 + } + """; + Path v1File = createTempConfigFile(v1Config); + Config v1Raw = ConfigLoader.loadRawUserConfig(v1File); + assertEquals(1, ConfigMigration.detectVersion(v1Raw), + "Config with meta.configVersion = 1 should be version 1"); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/config/validation/ConfigValidatorIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/config/validation/ConfigValidatorIntegrationTest.java new file mode 100644 index 0000000..ef17586 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/integration/config/validation/ConfigValidatorIntegrationTest.java @@ -0,0 +1,121 @@ +/* + * 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.integration.config.validation; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.config.validation.ConfigValidator; +import com.jagrosh.jmusicbot.config.validation.ConfigValidator.ValidationResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigValidator Integration Tests") +class ConfigValidatorIntegrationTest extends BaseConfigTest { + + @Nested + @DisplayName("Token Validation with File System") + class TokenValidationWithFileSystemTests { + + @Test + @DisplayName("Validates token and writes to file when needed") + void validatesTokenAndWritesToFileWhenNeeded() throws IOException { + Path configFile = createTempConfigFile("token = BOT_TOKEN_HERE\nowner = 123456789"); + mockUserInteraction.setPromptResponse("new_token_from_prompt"); + + ValidationResult result = ConfigValidator.validateToken("BOT_TOKEN_HERE", mockUserInteraction, configFile); + + assertTrue(result.isValid()); + assertEquals("new_token_from_prompt", result.getValue()); + assertTrue(result.needsWrite()); + + // Verify prompt was called + assertEquals(1, mockUserInteraction.getPromptCalls().size()); + assertTrue(mockUserInteraction.getLastPrompt().contains("bot token")); + } + + @Test + @DisplayName("Shows alert with config location on validation failure") + void showsAlertWithConfigLocationOnValidationFailure() throws IOException { + Path configFile = createTempConfigFile("token = BOT_TOKEN_HERE\nowner = 123456789"); + mockUserInteraction.setPromptCancelled(); + + ValidationResult result = ConfigValidator.validateToken("BOT_TOKEN_HERE", mockUserInteraction, configFile); + + assertFalse(result.isValid()); + var alert = mockUserInteraction.getLastAlert(); + assertNotNull(alert); + assertTrue(alert.getMessage().contains("Config Location")); + assertTrue(alert.getMessage().contains(configFile.toString())); + } + } + + @Nested + @DisplayName("Owner Validation with File System") + class OwnerValidationWithFileSystemTests { + + @Test + @DisplayName("Validates owner and writes to file when needed") + void validatesOwnerAndWritesToFileWhenNeeded() throws IOException { + Path configFile = createTempConfigFile("token = test_token\nowner = 0"); + mockUserInteraction.setPromptResponse("987654321"); + + ValidationResult result = ConfigValidator.validateOwner(0L, mockUserInteraction, configFile); + + assertTrue(result.isValid()); + assertEquals(987654321L, (Long) result.getValue()); + assertTrue(result.needsWrite()); + } + + @Test + @DisplayName("Shows error alert with config location on invalid owner") + void showsErrorAlertWithConfigLocationOnInvalidOwner() throws IOException { + Path configFile = createTempConfigFile("token = test_token\nowner = 0"); + mockUserInteraction.setPromptResponse("invalid_number"); + + ValidationResult result = ConfigValidator.validateOwner(0L, mockUserInteraction, configFile); + + assertFalse(result.isValid()); + var alert = mockUserInteraction.getLastAlert(); + assertNotNull(alert); + assertEquals("Config", alert.getContext()); + assertTrue(alert.getMessage().contains("Invalid User ID")); + assertTrue(alert.getMessage().contains("Config Location")); + } + } + + @Nested + @DisplayName("Validation Flow Integration") + class ValidationFlowIntegrationTests { + + @Test + @DisplayName("Validation stops on first failure") + void validationStopsOnFirstFailure() throws IOException { + Path configFile = createTempConfigFile("token = BOT_TOKEN_HERE\nowner = 0"); + mockUserInteraction.setPromptCancelled(); + + ValidationResult tokenResult = ConfigValidator.validateToken("BOT_TOKEN_HERE", mockUserInteraction, configFile); + assertFalse(tokenResult.isValid()); + + // Should have shown alert + assertEquals(1, mockUserInteraction.getAlertCalls().size()); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/TestConstants.java b/src/test/java/com/jagrosh/jmusicbot/testutil/TestConstants.java new file mode 100644 index 0000000..1c87b38 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/TestConstants.java @@ -0,0 +1,88 @@ +/* + * 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.testutil; + +/** + * Shared constants for test fixtures. + * All test fixtures should reference these constants to ensure consistency + * across the test suite. + */ +public final class TestConstants +{ + private TestConstants() + { + // Utility class - prevent instantiation + } + + // ==================== Discord Entity IDs ==================== + + /** + * Standard test guild ID used across all fixtures. + */ + public static final long GUILD_ID = 123456789L; + + /** + * Standard test user ID (for the member being tested). + */ + public static final long USER_ID = 987654321L; + + /** + * Standard bot owner ID. + */ + public static final long OWNER_ID = 111111111L; + + /** + * Standard DJ role ID. + */ + public static final long DJ_ROLE_ID = 222222222L; + + /** + * Standard message ID for NP messages and similar. + */ + public static final long MESSAGE_ID = 444444444L; + + /** + * Standard text channel ID. + */ + public static final long CHANNEL_ID = 555555555L; + + /** + * Standard voice channel ID. + */ + public static final long VOICE_CHANNEL_ID = 666666666L; + + // ==================== Default Track Properties ==================== + + /** + * Default track title for mock tracks. + */ + public static final String DEFAULT_TRACK_TITLE = "Test Track"; + + /** + * Default track author for mock tracks. + */ + public static final String DEFAULT_TRACK_AUTHOR = "Test Author"; + + /** + * Default track duration in milliseconds (3 minutes). + */ + public static final long DEFAULT_TRACK_DURATION_MS = 180000L; + + /** + * Default test user name. + */ + public static final String DEFAULT_USER_NAME = "TestUser"; +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/audio/AudioTestFixture.java b/src/test/java/com/jagrosh/jmusicbot/testutil/audio/AudioTestFixture.java new file mode 100644 index 0000000..60e4b19 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/audio/AudioTestFixture.java @@ -0,0 +1,647 @@ +/* + * 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.testutil.audio; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.audio.NowPlayingHandler; +import com.jagrosh.jmusicbot.audio.PlayerManager; +import com.jagrosh.jmusicbot.audio.QueuedTrack; +import com.jagrosh.jmusicbot.audio.RequestMetadata; +import com.jagrosh.jmusicbot.queue.AbstractQueue; +import com.jagrosh.jmusicbot.queue.PlaybackHistory; +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.settings.SettingsManager; +import com.jagrosh.jmusicbot.testutil.TestConstants; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +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.Message; +import net.dv8tion.jda.api.entities.SelfMember; +import net.dv8tion.jda.api.entities.User; +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.unions.AudioChannelUnion; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.managers.AudioManager; +import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import net.dv8tion.jda.api.requests.restaction.MessageEditAction; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import net.dv8tion.jda.api.utils.messages.MessageEditData; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Test fixture providing common mocks and setup for audio component tests. + * Specifically designed for testing AudioHandler, NowPlayingHandler, and related classes. + */ +public class AudioTestFixture +{ + // Core bot mocks + private final Bot bot; + private final BotConfig config; + private final PlayerManager playerManager; + private final SettingsManager settingsManager; + private final Settings settings; + private final NowPlayingHandler nowPlayingHandler; + private final ScheduledExecutorService threadpool; + + // JDA mocks + private final JDA jda; + private final Guild guild; + private final Member member; + private final User user; + private final SelfMember selfMember; + private final TextChannel textChannel; + private final AudioManager audioManager; + private final AudioPlayer audioPlayer; + private final AudioPlayerManager audioPlayerManager; + + // Voice mocks + private final GuildVoiceState selfVoiceState; + private final VoiceChannel voiceChannel; + + // Track mocks + private final List<AudioTrack> mockTracks = new ArrayList<>(); + private AudioTrack currentTrack; + + // Queue mock + private AbstractQueue<QueuedTrack> queue; + private PlaybackHistory<QueuedTrack> history; + + // Message mocks + private final Message message; + private final MessageCreateAction messageCreateAction; + private final MessageEditAction messageEditAction; + private final AuditableRestAction<Void> deleteAction; + + // Re-export constants for backwards compatibility + // New code should use TestConstants directly + /** @deprecated Use {@link TestConstants#GUILD_ID} instead */ + @Deprecated + public static final long GUILD_ID = TestConstants.GUILD_ID; + /** @deprecated Use {@link TestConstants#USER_ID} instead */ + @Deprecated + public static final long USER_ID = TestConstants.USER_ID; + /** @deprecated Use {@link TestConstants#OWNER_ID} instead */ + @Deprecated + public static final long OWNER_ID = TestConstants.OWNER_ID; + /** @deprecated Use {@link TestConstants#MESSAGE_ID} instead */ + @Deprecated + public static final long MESSAGE_ID = TestConstants.MESSAGE_ID; + /** @deprecated Use {@link TestConstants#CHANNEL_ID} instead */ + @Deprecated + public static final long CHANNEL_ID = TestConstants.CHANNEL_ID; + + @SuppressWarnings("unchecked") + private AudioTestFixture() + { + // Create all mocks + bot = mock(Bot.class); + config = mock(BotConfig.class); + playerManager = mock(PlayerManager.class); + settingsManager = mock(SettingsManager.class); + settings = mock(Settings.class); + nowPlayingHandler = mock(NowPlayingHandler.class); + threadpool = mock(ScheduledExecutorService.class); + + // JDA mocks + jda = mock(JDA.class); + guild = mock(Guild.class); + member = mock(Member.class); + user = mock(User.class); + selfMember = mock(SelfMember.class); + textChannel = mock(TextChannel.class, withSettings().extraInterfaces(MessageChannelUnion.class)); + audioManager = mock(AudioManager.class); + audioPlayer = mock(AudioPlayer.class); + audioPlayerManager = mock(AudioPlayerManager.class); + selfVoiceState = mock(GuildVoiceState.class); + voiceChannel = mock(VoiceChannel.class, withSettings().extraInterfaces(AudioChannelUnion.class)); + + // Queue mocks + queue = mock(AbstractQueue.class); + history = mock(PlaybackHistory.class); + + // Message mocks + message = mock(Message.class); + messageCreateAction = mock(MessageCreateAction.class); + messageEditAction = mock(MessageEditAction.class); + deleteAction = mock(AuditableRestAction.class); + + setupDefaultRelationships(); + } + + /** + * Creates a new fixture with default configuration. + */ + public static AudioTestFixture create() + { + return new AudioTestFixture(); + } + + @SuppressWarnings("unchecked") + private void setupDefaultRelationships() + { + // Bot relationships + when(bot.getConfig()).thenReturn(config); + when(bot.getPlayerManager()).thenReturn(playerManager); + when(bot.getSettingsManager()).thenReturn(settingsManager); + when(bot.getNowplayingHandler()).thenReturn(nowPlayingHandler); + when(bot.getThreadpool()).thenReturn(threadpool); + when(bot.getJDA()).thenReturn(jda); + + // Settings relationships + when(settingsManager.getSettings(anyLong())).thenReturn(settings); + when(settingsManager.getSettings(any(Guild.class))).thenReturn(settings); + + // Guild relationships + when(guild.getIdLong()).thenReturn(GUILD_ID); + when(guild.getId()).thenReturn(String.valueOf(GUILD_ID)); + when(guild.getName()).thenReturn("Test Guild"); + when(guild.getSelfMember()).thenReturn(selfMember); + when(guild.getAudioManager()).thenReturn(audioManager); + when(guild.getTextChannelById(CHANNEL_ID)).thenReturn(textChannel); + + // JDA relationships + when(jda.getGuildById(GUILD_ID)).thenReturn(guild); + + // PlayerManager relationships + when(playerManager.getBot()).thenReturn(bot); + + // Audio player defaults + when(audioPlayer.getPlayingTrack()).thenReturn(null); + when(audioPlayer.isPaused()).thenReturn(false); + when(audioPlayer.getVolume()).thenReturn(100); + + // Voice state relationships + when(selfMember.getVoiceState()).thenReturn(selfVoiceState); + when(selfVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + + // Member/User relationships + when(member.getUser()).thenReturn(user); + when(member.getGuild()).thenReturn(guild); + when(member.getIdLong()).thenReturn(USER_ID); + when(user.getIdLong()).thenReturn(USER_ID); + when(user.getId()).thenReturn(String.valueOf(USER_ID)); + when(user.getName()).thenReturn("TestUser"); + + // Config defaults + when(config.getOwnerId()).thenReturn(OWNER_ID); + when(config.useNPImages()).thenReturn(false); + when(config.showNpProgressBar()).thenReturn(true); + when(config.getSongInStatus()).thenReturn(false); + when(config.getMaxHistorySize()).thenReturn(10); + + // Proxy defaults (disabled by default) + when(config.hasProxy()).thenReturn(false); + when(config.getProxyHost()).thenReturn(""); + when(config.getProxyPort()).thenReturn(0); + when(config.proxyLavaplayer()).thenReturn(false); + when(config.proxyJda()).thenReturn(false); + when(config.proxyGithub()).thenReturn(false); + + // Settings defaults + when(settings.getRepeatMode()).thenReturn(RepeatMode.OFF); + when(settings.getVolume()).thenReturn(100); + when(settings.getQueueType()).thenReturn(QueueType.FAIR); + + // Queue defaults + when(queue.size()).thenReturn(0); + when(queue.isEmpty()).thenReturn(true); + when(queue.getList()).thenReturn(Collections.emptyList()); + when(queue.getHistory()).thenReturn(history); + when(history.isEmpty()).thenReturn(true); + when(history.getList()).thenReturn(Collections.emptyList()); + + // TextChannel defaults + when(textChannel.getIdLong()).thenReturn(CHANNEL_ID); + when(textChannel.sendMessage(any(MessageCreateData.class))).thenReturn(messageCreateAction); + when(textChannel.editMessageById(anyLong(), any(MessageEditData.class))).thenReturn(messageEditAction); + when(textChannel.deleteMessageById(anyLong())).thenReturn(deleteAction); + + // Message action chaining + doAnswer(inv -> { + Consumer<Message> callback = inv.getArgument(0); + callback.accept(message); + return null; + }).when(messageCreateAction).queue(any()); + doNothing().when(messageCreateAction).queue(); + + doAnswer(inv -> { + Consumer<Message> callback = inv.getArgument(0); + callback.accept(message); + return null; + }).when(messageEditAction).queue(any(), any()); + doNothing().when(messageEditAction).queue(); + + doNothing().when(deleteAction).queue(any(), any()); + doNothing().when(deleteAction).queue(); + + // Message defaults + when(message.getIdLong()).thenReturn(MESSAGE_ID); + when(message.getChannel()).thenReturn((MessageChannelUnion) textChannel); + when(message.getGuild()).thenReturn(guild); + } + + // ==================== Track Creation Methods ==================== + + /** + * Creates a mock audio track with the given properties. + */ + public AudioTrack createMockTrack(String title, String author, long durationMs) + { + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo(title, author, durationMs, + "id-" + mockTracks.size(), false, "https://example.com/" + mockTracks.size()); + + when(track.getInfo()).thenReturn(info); + when(track.getDuration()).thenReturn(durationMs); + when(track.getPosition()).thenReturn(0L); + when(track.isSeekable()).thenReturn(true); + when(track.makeClone()).thenReturn(track); + + mockTracks.add(track); + return track; + } + + /** + * Creates a mock queued track with the given properties. + */ + public QueuedTrack createMockQueuedTrack(String title, String author, long durationMs) + { + return createMockQueuedTrack(title, author, durationMs, USER_ID); + } + + /** + * Creates a mock queued track with the given properties and user ID. + */ + public QueuedTrack createMockQueuedTrack(String title, String author, long durationMs, long userId) + { + AudioTrack track = createMockTrack(title, author, durationMs); + QueuedTrack qt = mock(QueuedTrack.class); + + when(qt.getTrack()).thenReturn(track); + when(qt.getIdentifier()).thenReturn(userId); + + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(userId); + when(metadata.channelId).thenReturn(CHANNEL_ID); + when(track.getUserData(RequestMetadata.class)).thenReturn(metadata); + + return qt; + } + + // ==================== Builder Methods ==================== + + /** + * Configures a track to be currently playing. + */ + public AudioTestFixture withPlayingTrack() + { + return withPlayingTrack("Test Track", "Test Author", 180000); + } + + /** + * Configures a specific track to be currently playing. + */ + public AudioTestFixture withPlayingTrack(String title, String author, long durationMs) + { + currentTrack = createMockTrack(title, author, durationMs); + when(audioPlayer.getPlayingTrack()).thenReturn(currentTrack); + return this; + } + + /** + * Configures the player to be paused. + */ + public AudioTestFixture withPausedTrack() + { + withPlayingTrack(); + when(audioPlayer.isPaused()).thenReturn(true); + return this; + } + + /** + * Configures no track to be playing. + */ + public AudioTestFixture withNoTrack() + { + currentTrack = null; + when(audioPlayer.getPlayingTrack()).thenReturn(null); + return this; + } + + /** + * Configures the queue with a specific number of tracks. + */ + public AudioTestFixture withQueueSize(int size) + { + List<QueuedTrack> queueList = new ArrayList<>(); + for (int i = 0; i < size; i++) + { + QueuedTrack qt = createMockQueuedTrack("Track " + (i + 1), "Author", 180000); + queueList.add(qt); + } + when(queue.size()).thenReturn(size); + when(queue.isEmpty()).thenReturn(size == 0); + when(queue.getList()).thenReturn(queueList); + if (size > 0) + { + when(queue.get(anyInt())).thenAnswer(inv -> { + int index = inv.getArgument(0); + return index >= 0 && index < queueList.size() ? queueList.get(index) : null; + }); + } + return this; + } + + /** + * Configures the history with a specific number of tracks. + */ + public AudioTestFixture withHistorySize(int size) + { + List<QueuedTrack> historyList = new ArrayList<>(); + for (int i = 0; i < size; i++) + { + QueuedTrack qt = createMockQueuedTrack("Previous " + (i + 1), "Author", 180000); + historyList.add(qt); + } + when(history.isEmpty()).thenReturn(size == 0); + when(history.size()).thenReturn(size); + when(history.getList()).thenReturn(historyList); + if (size > 0) + { + when(history.removeFirst()).thenReturn(historyList.get(0)); + } + return this; + } + + /** + * Configures the repeat mode. + */ + public AudioTestFixture withRepeatMode(RepeatMode mode) + { + when(settings.getRepeatMode()).thenReturn(mode); + return this; + } + + /** + * Configures NP images to be used. + */ + public AudioTestFixture withNPImages() + { + when(config.useNPImages()).thenReturn(true); + return this; + } + + /** + * Configures NP progress bar updates to be enabled. + */ + public AudioTestFixture withNpProgressBar() + { + when(config.showNpProgressBar()).thenReturn(true); + return this; + } + + /** + * Configures NP progress bar updates to be disabled. + */ + public AudioTestFixture withoutNpProgressBar() + { + when(config.showNpProgressBar()).thenReturn(false); + return this; + } + + /** + * Configures song in status to be enabled. + */ + public AudioTestFixture withSongInStatus() + { + when(config.getSongInStatus()).thenReturn(true); + return this; + } + + /** + * Configures the bot to be in a voice channel. + */ + public AudioTestFixture withBotInVoiceChannel() + { + when(selfVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + when(selfVoiceState.inAudioChannel()).thenReturn(true); + return this; + } + + /** + * Configures the bot to NOT be in a voice channel. + */ + public AudioTestFixture withBotNotInVoiceChannel() + { + when(selfVoiceState.getChannel()).thenReturn(null); + when(selfVoiceState.inAudioChannel()).thenReturn(false); + return this; + } + + // ==================== Proxy Configuration ==================== + + /** + * Configures a proxy with the given host and port. + * Does not enable any component proxying by default. + */ + public AudioTestFixture withProxy(String host, int port) + { + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn(host); + when(config.getProxyPort()).thenReturn(port); + return this; + } + + /** + * Enables Lavaplayer proxying (requires proxy to be configured). + */ + public AudioTestFixture withProxyLavaplayer() + { + when(config.proxyLavaplayer()).thenReturn(true); + return this; + } + + /** + * Enables JDA proxying (requires proxy to be configured). + */ + public AudioTestFixture withProxyJda() + { + when(config.proxyJda()).thenReturn(true); + return this; + } + + /** + * Enables GitHub proxying (requires proxy to be configured). + */ + public AudioTestFixture withProxyGithub() + { + when(config.proxyGithub()).thenReturn(true); + return this; + } + + /** + * Configures a full Lavaplayer proxy setup (common use case). + */ + public AudioTestFixture withLavaplayerProxy(String host, int port) + { + return withProxy(host, port).withProxyLavaplayer(); + } + + // ==================== Getters ==================== + + public Bot getBot() + { + return bot; + } + + public BotConfig getConfig() + { + return config; + } + + public PlayerManager getPlayerManager() + { + return playerManager; + } + + public SettingsManager getSettingsManager() + { + return settingsManager; + } + + public Settings getSettings() + { + return settings; + } + + public NowPlayingHandler getNowPlayingHandler() + { + return nowPlayingHandler; + } + + public ScheduledExecutorService getThreadpool() + { + return threadpool; + } + + public JDA getJda() + { + return jda; + } + + public Guild getGuild() + { + return guild; + } + + public Member getMember() + { + return member; + } + + public User getUser() + { + return user; + } + + public SelfMember getSelfMember() + { + return selfMember; + } + + public TextChannel getTextChannel() + { + return textChannel; + } + + public AudioManager getAudioManager() + { + return audioManager; + } + + public AudioPlayer getAudioPlayer() + { + return audioPlayer; + } + + public AudioPlayerManager getAudioPlayerManager() + { + return audioPlayerManager; + } + + public GuildVoiceState getSelfVoiceState() + { + return selfVoiceState; + } + + public VoiceChannel getVoiceChannel() + { + return voiceChannel; + } + + public AudioTrack getCurrentTrack() + { + return currentTrack; + } + + public AbstractQueue<QueuedTrack> getQueue() + { + return queue; + } + + public PlaybackHistory<QueuedTrack> getHistory() + { + return history; + } + + public Message getMessage() + { + return message; + } + + public MessageCreateAction getMessageCreateAction() + { + return messageCreateAction; + } + + public MessageEditAction getMessageEditAction() + { + return messageEditAction; + } + + public List<AudioTrack> getMockTracks() + { + return new ArrayList<>(mockTracks); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/commands/SlashCommandTestFixture.java b/src/test/java/com/jagrosh/jmusicbot/testutil/commands/SlashCommandTestFixture.java new file mode 100644 index 0000000..06b7546 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/commands/SlashCommandTestFixture.java @@ -0,0 +1,570 @@ +/* + * 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.testutil.commands; + +import com.jagrosh.jdautilities.command.CommandClient; +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jdautilities.commons.waiter.EventWaiter; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +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.service.MusicService; +import com.jagrosh.jmusicbot.service.SearchService; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.settings.SettingsManager; +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.Message; +import net.dv8tion.jda.api.entities.SelfMember; +import net.dv8tion.jda.api.entities.User; +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.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.managers.AudioManager; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.WebhookMessageEditAction; +import net.dv8tion.jda.api.requests.restaction.interactions.AutoCompleteCallbackAction; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; + +import java.util.function.Consumer; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Test fixture providing common mocks and setup for SlashCommand tests. + * Uses builder pattern for fluent test configuration. + */ +public class SlashCommandTestFixture +{ + // Core bot mocks + private final Bot bot; + private final BotConfig config; + private final PlayerManager playerManager; + private final SettingsManager settingsManager; + private final Settings settings; + + // Service mocks + private final MusicService musicService; + private final SearchService searchService; + private final NowPlayingHandler nowPlayingHandler; + private final EventWaiter eventWaiter; + + // JDA mocks + private final JDA jda; + private final Guild guild; + private final Member member; + private final User user; + private final SelfMember selfMember; + private final TextChannel textChannel; + private final AudioManager audioManager; + private final AudioHandler audioHandler; + + // Voice state mocks + private final GuildVoiceState selfVoiceState; + private final GuildVoiceState memberVoiceState; + + // Shared voice channel - mock implements both VoiceChannel and AudioChannelUnion + // This allows the same instance to be used in both settings.getVoiceChannel() and + // memberVoiceState.getChannel() so that equality checks pass + private final VoiceChannel voiceChannel; + + // Event mocks + private final SlashCommandEvent event; + private final CommandClient client; + private final ReplyCallbackAction replyAction; + + // Interaction mocks + private final InteractionHook hook; + private final WebhookMessageEditAction<Message> editAction; + private final RestAction<Message> retrieveAction; + private final Message message; + + // AutoComplete mocks + private final CommandAutoCompleteInteractionEvent autoCompleteEvent; + private final AutoCompleteQuery focusedOption; + private final AutoCompleteCallbackAction autoCompleteCallback; + + // Constants + public static final long GUILD_ID = 123456789L; + public static final long USER_ID = 987654321L; + + @SuppressWarnings("unchecked") + private SlashCommandTestFixture() + { + // Create all mocks + bot = mock(Bot.class); + config = mock(BotConfig.class); + playerManager = mock(PlayerManager.class); + settingsManager = mock(SettingsManager.class); + settings = mock(Settings.class); + + // Service mocks + musicService = mock(MusicService.class); + searchService = mock(SearchService.class); + nowPlayingHandler = mock(NowPlayingHandler.class); + eventWaiter = mock(EventWaiter.class); + + // JDA mocks + jda = mock(JDA.class); + guild = mock(Guild.class); + member = mock(Member.class); + user = mock(User.class); + selfMember = mock(SelfMember.class); + textChannel = mock(TextChannel.class); + audioManager = mock(AudioManager.class); + audioHandler = mock(AudioHandler.class); + selfVoiceState = mock(GuildVoiceState.class); + memberVoiceState = mock(GuildVoiceState.class); + // Create a VoiceChannel mock that also implements AudioChannelUnion + // This allows the same instance to be used for both settings.getVoiceChannel() + // (which returns VoiceChannel) and memberVoiceState.getChannel() (which returns AudioChannelUnion) + voiceChannel = mock(VoiceChannel.class, withSettings().extraInterfaces(AudioChannelUnion.class)); + + // Event mocks + event = mock(SlashCommandEvent.class); + client = mock(CommandClient.class); + replyAction = mock(ReplyCallbackAction.class); + + // Interaction mocks + hook = mock(InteractionHook.class); + editAction = mock(WebhookMessageEditAction.class); + retrieveAction = mock(RestAction.class); + message = mock(Message.class); + + // AutoComplete mocks + autoCompleteEvent = mock(CommandAutoCompleteInteractionEvent.class); + focusedOption = mock(AutoCompleteQuery.class); + autoCompleteCallback = mock(AutoCompleteCallbackAction.class); + + setupDefaultRelationships(); + } + + /** + * Creates a new fixture with default configuration. + */ + public static SlashCommandTestFixture create() + { + return new SlashCommandTestFixture(); + } + + private void setupDefaultRelationships() + { + // Bot relationships + when(bot.getConfig()).thenReturn(config); + when(bot.getPlayerManager()).thenReturn(playerManager); + when(bot.getSettingsManager()).thenReturn(settingsManager); + when(bot.getMusicService()).thenReturn(musicService); + when(bot.getSearchService()).thenReturn(searchService); + when(bot.getNowplayingHandler()).thenReturn(nowPlayingHandler); + when(bot.getWaiter()).thenReturn(eventWaiter); + when(bot.getAudioLoadWrapper()).thenReturn(AudioLoadWrapper.NO_OP); + + // Settings relationships + when(settingsManager.getSettings(anyLong())).thenReturn(settings); + + // Event relationships + when(event.getClient()).thenReturn(client); + when(event.getJDA()).thenReturn(jda); + when(event.getGuild()).thenReturn(guild); + when(event.getMember()).thenReturn(member); + when(event.getTextChannel()).thenReturn(textChannel); + when(event.getUser()).thenReturn(user); + + // Client defaults + when(client.getError()).thenReturn("❌"); + when(client.getWarning()).thenReturn("⚠️"); + when(client.getSuccess()).thenReturn("✅"); + when(client.getSettingsFor(guild)).thenReturn(settings); + + // Guild relationships + when(guild.getIdLong()).thenReturn(GUILD_ID); + when(guild.getSelfMember()).thenReturn(selfMember); + when(guild.getAudioManager()).thenReturn(audioManager); + when(guild.getAfkChannel()).thenReturn(null); + + // Audio relationships + when(audioManager.getSendingHandler()).thenReturn(audioHandler); + when(playerManager.setUpHandler(any(Guild.class))).thenReturn(audioHandler); + + // Voice state relationships + when(selfMember.getVoiceState()).thenReturn(selfVoiceState); + when(member.getVoiceState()).thenReturn(memberVoiceState); + + // Member/User relationships + when(member.getUser()).thenReturn(user); + when(user.getIdLong()).thenReturn(USER_ID); + when(member.getColor()).thenReturn(null); + + // Reply action chain + when(event.reply(anyString())).thenReturn(replyAction); + when(event.replyEmbeds(any(net.dv8tion.jda.api.entities.MessageEmbed.class))).thenReturn(replyAction); + when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction); + when(replyAction.setComponents(anyList())).thenReturn(replyAction); + when(replyAction.addEmbeds(any(net.dv8tion.jda.api.entities.MessageEmbed.class))).thenReturn(replyAction); + doNothing().when(replyAction).queue(); + + // Hook and edit action chain + when(hook.editOriginal(anyString())).thenReturn(editAction); + when(hook.retrieveOriginal()).thenReturn(retrieveAction); + doNothing().when(editAction).queue(); + + // AutoComplete defaults + when(autoCompleteEvent.getFocusedOption()).thenReturn(focusedOption); + when(autoCompleteEvent.getGuild()).thenReturn(guild); + when(autoCompleteEvent.replyChoices()).thenReturn(autoCompleteCallback); + doNothing().when(autoCompleteCallback).queue(); + + // Default: no text channel restriction + when(settings.getTextChannel(guild)).thenReturn(null); + + // Default: no voice channel configured + when(settings.getVoiceChannel(guild)).thenReturn(null); + + // Default: bot not in voice channel + when(selfVoiceState.getChannel()).thenReturn(null); + + // Default: user not in voice channel + when(memberVoiceState.getChannel()).thenReturn(null); + when(memberVoiceState.isDeafened()).thenReturn(false); + + // Default: config aliases and emojis + when(config.getAliases(anyString())).thenReturn(new String[0]); + when(config.getLoading()).thenReturn("⏳"); + when(config.getSearching()).thenReturn("🔍"); + } + + // ==================== Builder Methods ==================== + + /** + * Configures a required text channel for commands. + */ + public SlashCommandTestFixture withRequiredTextChannel(TextChannel requiredChannel) + { + when(settings.getTextChannel(guild)).thenReturn(requiredChannel); + return this; + } + + /** + * Configures the user to be in a voice channel. + * Uses the shared voiceChannel mock to ensure equality checks pass. + */ + public SlashCommandTestFixture withUserInVoiceChannel() + { + // voiceChannel implements both VoiceChannel and AudioChannelUnion + when(memberVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + return this; + } + + /** + * Configures the user to be in a specific voice channel. + * The channel should be created with extraInterfaces(AudioChannelUnion.class) for compatibility. + */ + public SlashCommandTestFixture withUserInVoiceChannel(AudioChannelUnion channel) + { + when(memberVoiceState.getChannel()).thenReturn(channel); + return this; + } + + /** + * Configures the bot to be in a voice channel. + * Uses the shared voiceChannel mock to ensure equality checks pass. + */ + public SlashCommandTestFixture withBotInVoiceChannel() + { + // voiceChannel implements both VoiceChannel and AudioChannelUnion + when(selfVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + return this; + } + + /** + * Configures the bot to be in a specific voice channel. + */ + public SlashCommandTestFixture withBotInVoiceChannel(AudioChannelUnion channel) + { + when(selfVoiceState.getChannel()).thenReturn(channel); + return this; + } + + /** + * Configures a required voice channel in settings. + * Uses the shared voiceChannel mock to ensure equality checks pass. + */ + public SlashCommandTestFixture withRequiredVoiceChannel() + { + when(settings.getVoiceChannel(guild)).thenReturn(voiceChannel); + return this; + } + + /** + * Configures a required voice channel in settings with a specific channel. + */ + public SlashCommandTestFixture withRequiredVoiceChannel(VoiceChannel channel) + { + when(settings.getVoiceChannel(guild)).thenReturn(channel); + return this; + } + + /** + * Configures the user as deafened. + */ + public SlashCommandTestFixture withUserDeafened() + { + when(memberVoiceState.isDeafened()).thenReturn(true); + return this; + } + + /** + * Configures the AFK channel. + * Uses the shared voiceChannel mock to ensure equality checks pass. + */ + public SlashCommandTestFixture withAfkChannel() + { + when(guild.getAfkChannel()).thenReturn(voiceChannel); + return this; + } + + /** + * Configures a specific AFK channel. + */ + public SlashCommandTestFixture withAfkChannel(VoiceChannel afkChannel) + { + when(guild.getAfkChannel()).thenReturn(afkChannel); + return this; + } + + /** + * Configures music as playing. + */ + public SlashCommandTestFixture withMusicPlaying() + { + when(audioHandler.isMusicPlaying(jda)).thenReturn(true); + return this; + } + + /** + * Configures music as not playing. + */ + public SlashCommandTestFixture withMusicNotPlaying() + { + when(audioHandler.isMusicPlaying(jda)).thenReturn(false); + return this; + } + + // ==================== Getters ==================== + + public Bot getBot() + { + return bot; + } + + public BotConfig getConfig() + { + return config; + } + + public PlayerManager getPlayerManager() + { + return playerManager; + } + + public SettingsManager getSettingsManager() + { + return settingsManager; + } + + public Settings getSettings() + { + return settings; + } + + public JDA getJda() + { + return jda; + } + + public Guild getGuild() + { + return guild; + } + + public Member getMember() + { + return member; + } + + public User getUser() + { + return user; + } + + public SelfMember getSelfMember() + { + return selfMember; + } + + public TextChannel getTextChannel() + { + return textChannel; + } + + public AudioManager getAudioManager() + { + return audioManager; + } + + public AudioHandler getAudioHandler() + { + return audioHandler; + } + + public GuildVoiceState getSelfVoiceState() + { + return selfVoiceState; + } + + public GuildVoiceState getMemberVoiceState() + { + return memberVoiceState; + } + + public VoiceChannel getVoiceChannel() + { + return voiceChannel; + } + + public SlashCommandEvent getEvent() + { + return event; + } + + public CommandClient getClient() + { + return client; + } + + public ReplyCallbackAction getReplyAction() + { + return replyAction; + } + + public MusicService getMusicService() + { + return musicService; + } + + public SearchService getSearchService() + { + return searchService; + } + + public NowPlayingHandler getNowPlayingHandler() + { + return nowPlayingHandler; + } + + public EventWaiter getEventWaiter() + { + return eventWaiter; + } + + public InteractionHook getHook() + { + return hook; + } + + public WebhookMessageEditAction<Message> getEditAction() + { + return editAction; + } + + public RestAction<Message> getRetrieveAction() + { + return retrieveAction; + } + + public Message getMessage() + { + return message; + } + + public CommandAutoCompleteInteractionEvent getAutoCompleteEvent() + { + return autoCompleteEvent; + } + + public AutoCompleteQuery getFocusedOption() + { + return focusedOption; + } + + public AutoCompleteCallbackAction getAutoCompleteCallback() + { + return autoCompleteCallback; + } + + // ==================== Additional Builder Methods ==================== + + /** + * Configures the reply action to execute a callback with the hook when queue is called. + */ + public SlashCommandTestFixture withReplyQueueCallback() + { + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer<InteractionHook> callback = invocation.getArgument(0); + callback.accept(hook); + return null; + }).when(replyAction).queue(any()); + return this; + } + + /** + * Configures the edit action to execute a callback with the message when queue is called. + */ + public SlashCommandTestFixture withEditQueueCallback() + { + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer<Message> callback = invocation.getArgument(0); + callback.accept(message); + return null; + }).when(editAction).queue(any()); + return this; + } + + /** + * Configures the retrieve action to execute a callback with the message when queue is called. + */ + public SlashCommandTestFixture withRetrieveQueueCallback() + { + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer<Message> callback = invocation.getArgument(0); + callback.accept(message); + return null; + }).when(retrieveAction).queue(any()); + return this; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/commands/TestMusicSlashCommand.java b/src/test/java/com/jagrosh/jmusicbot/testutil/commands/TestMusicSlashCommand.java new file mode 100644 index 0000000..5410f08 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/commands/TestMusicSlashCommand.java @@ -0,0 +1,163 @@ +/* + * 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.testutil.commands; + +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; + +import static org.mockito.Mockito.spy; + +/** + * Test implementation of MusicSlashCommand for testing validation logic. + * Exposes protected methods and fields for test configuration and verification. + */ +public class TestMusicSlashCommand extends MusicSlashCommand +{ + private boolean doCommandCalled = false; + private SlashCommandEvent lastEvent = null; + + public TestMusicSlashCommand(Bot bot) + { + super(bot); + this.name = "testcommand"; + this.help = "Test command for unit testing"; + } + + @Override + public void doCommand(SlashCommandEvent event) + { + this.doCommandCalled = true; + this.lastEvent = event; + } + + // ==================== Expose Protected Methods ==================== + + /** + * Exposes the protected execute method for testing. + */ + public void testExecute(SlashCommandEvent event) + { + execute(event); + } + + // ==================== Configuration Setters ==================== + + /** + * Sets whether music must be playing for this command. + */ + public void setBePlaying(boolean value) + { + this.bePlaying = value; + } + + /** + * Sets whether the user must be listening in voice for this command. + */ + public void setBeListening(boolean value) + { + this.beListening = value; + } + + // ==================== Test Verification ==================== + + /** + * Returns true if doCommand was called. + */ + public boolean wasDoCommandCalled() + { + return doCommandCalled; + } + + /** + * Returns the event passed to doCommand, or null if not called. + */ + public SlashCommandEvent getLastEvent() + { + return lastEvent; + } + + /** + * Resets the test state (call between tests if reusing). + */ + public void reset() + { + this.doCommandCalled = false; + this.lastEvent = null; + } + + // ==================== Factory Methods ==================== + + /** + * Creates a new test command. + */ + public static TestMusicSlashCommand create(Bot bot) + { + return new TestMusicSlashCommand(bot); + } + + /** + * Creates a spied test command for Mockito verification. + */ + public static TestMusicSlashCommand createSpied(Bot bot) + { + return spy(new TestMusicSlashCommand(bot)); + } + + /** + * Creates a test command configured for basic validation (no special requirements). + */ + public static TestMusicSlashCommand createBasic(Bot bot) + { + TestMusicSlashCommand cmd = new TestMusicSlashCommand(bot); + cmd.setBePlaying(false); + cmd.setBeListening(false); + return cmd; + } + + /** + * Creates a test command configured to require music playing. + */ + public static TestMusicSlashCommand createRequiresPlaying(Bot bot) + { + TestMusicSlashCommand cmd = new TestMusicSlashCommand(bot); + cmd.setBePlaying(true); + cmd.setBeListening(false); + return cmd; + } + + /** + * Creates a test command configured to require user listening. + */ + public static TestMusicSlashCommand createRequiresListening(Bot bot) + { + TestMusicSlashCommand cmd = new TestMusicSlashCommand(bot); + cmd.setBePlaying(false); + cmd.setBeListening(true); + return cmd; + } + + /** + * Creates a test command configured to require both playing and listening. + */ + public static TestMusicSlashCommand createRequiresPlayingAndListening(Bot bot) + { + TestMusicSlashCommand cmd = new TestMusicSlashCommand(bot); + cmd.setBePlaying(true); + cmd.setBeListening(true); + return cmd; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/commands/ValidationScenarioBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/commands/ValidationScenarioBuilder.java new file mode 100644 index 0000000..9b517ab --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/commands/ValidationScenarioBuilder.java @@ -0,0 +1,231 @@ +/* + * 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.testutil.commands; + +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.unions.AudioChannelUnion; + +import static org.mockito.Mockito.*; + +/** + * Builder for creating validation test scenarios for MusicSlashCommand tests. + * Provides a fluent API for setting up common validation states. + */ +public class ValidationScenarioBuilder +{ + private final SlashCommandTestFixture fixture; + + private ValidationScenarioBuilder(SlashCommandTestFixture fixture) + { + this.fixture = fixture; + } + + /** + * Creates a new scenario builder with a fresh fixture. + */ + public static ValidationScenarioBuilder create() + { + return new ValidationScenarioBuilder(SlashCommandTestFixture.create()); + } + + /** + * Creates a scenario builder using an existing fixture. + */ + public static ValidationScenarioBuilder with(SlashCommandTestFixture fixture) + { + return new ValidationScenarioBuilder(fixture); + } + + // ==================== Text Channel Scenarios ==================== + + /** + * Scenario: No text channel restriction (valid for any channel). + */ + public ValidationScenarioBuilder noTextChannelRestriction() + { + when(fixture.getSettings().getTextChannel(fixture.getGuild())).thenReturn(null); + return this; + } + + /** + * Scenario: Command used in wrong text channel. + */ + public ValidationScenarioBuilder wrongTextChannel() + { + TextChannel requiredChannel = mock(TextChannel.class); + when(requiredChannel.getAsMention()).thenReturn("#music"); + when(fixture.getSettings().getTextChannel(fixture.getGuild())).thenReturn(requiredChannel); + return this; + } + + /** + * Scenario: Command used in correct text channel. + */ + public ValidationScenarioBuilder correctTextChannel() + { + when(fixture.getSettings().getTextChannel(fixture.getGuild())).thenReturn(fixture.getTextChannel()); + return this; + } + + // ==================== Playing State Scenarios ==================== + + /** + * Scenario: Music is currently playing. + */ + public ValidationScenarioBuilder musicPlaying() + { + fixture.withMusicPlaying(); + return this; + } + + /** + * Scenario: Music is not playing. + */ + public ValidationScenarioBuilder musicNotPlaying() + { + fixture.withMusicNotPlaying(); + return this; + } + + // ==================== Voice Channel Scenarios ==================== + + /** + * Scenario: User is in the correct voice channel (same as bot or required channel). + * This uses the shared voiceChannel mock to ensure equality checks pass. + */ + public ValidationScenarioBuilder userInCorrectVoiceChannel() + { + fixture.withRequiredVoiceChannel(); + fixture.withUserInVoiceChannel(); + return this; + } + + /** + * Scenario: User is in a voice channel but bot is in a different one. + */ + public ValidationScenarioBuilder userInDifferentVoiceChannel() + { + // Bot is in the shared voice channel + fixture.withBotInVoiceChannel(); + // User is in a different channel - create with extra interface for proper casting + VoiceChannel differentChannel = mock(VoiceChannel.class, withSettings().extraInterfaces(AudioChannelUnion.class)); + fixture.withUserInVoiceChannel((AudioChannelUnion) differentChannel); + return this; + } + + /** + * Scenario: User is not in any voice channel. + */ + public ValidationScenarioBuilder userNotInVoiceChannel() + { + when(fixture.getMemberVoiceState().getChannel()).thenReturn(null); + return this; + } + + /** + * Scenario: User is in the AFK channel. + * Uses the same channel for both user location and AFK channel to ensure equality. + */ + public ValidationScenarioBuilder userInAfkChannel() + { + // Use same mock for both required channel, user channel, and AFK channel + fixture.withRequiredVoiceChannel(); + fixture.withUserInVoiceChannel(); + fixture.withAfkChannel(); + return this; + } + + /** + * Scenario: User is deafened. + */ + public ValidationScenarioBuilder userDeafened() + { + fixture.withUserDeafened(); + return this; + } + + /** + * Scenario: Bot is not in any voice channel. + */ + public ValidationScenarioBuilder botNotInVoiceChannel() + { + when(fixture.getSelfVoiceState().getChannel()).thenReturn(null); + return this; + } + + /** + * Scenario: Bot is in the same voice channel as user. + */ + public ValidationScenarioBuilder botInSameVoiceChannelAsUser() + { + fixture.withBotInVoiceChannel(); + fixture.withUserInVoiceChannel(); + return this; + } + + // ==================== Combined Scenarios ==================== + + /** + * Scenario: Valid for commands that require no special conditions. + */ + public ValidationScenarioBuilder validBasic() + { + return noTextChannelRestriction(); + } + + /** + * Scenario: Valid for commands that require music to be playing. + */ + public ValidationScenarioBuilder validWithMusicPlaying() + { + return noTextChannelRestriction().musicPlaying(); + } + + /** + * Scenario: Valid for commands that require user to be listening. + */ + public ValidationScenarioBuilder validWithUserListening() + { + return noTextChannelRestriction().userInCorrectVoiceChannel(); + } + + /** + * Scenario: Valid for commands that require both music playing and user listening. + */ + public ValidationScenarioBuilder validWithMusicPlayingAndUserListening() + { + return noTextChannelRestriction().musicPlaying().userInCorrectVoiceChannel(); + } + + // ==================== Build ==================== + + /** + * Returns the configured fixture. + */ + public SlashCommandTestFixture build() + { + return fixture; + } + + /** + * Returns the fixture (alias for build()). + */ + public SlashCommandTestFixture getFixture() + { + return fixture; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/AbstractConfigBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/AbstractConfigBuilder.java new file mode 100644 index 0000000..82078d3 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/AbstractConfigBuilder.java @@ -0,0 +1,102 @@ +/* + * 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.testutil.config; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigException; +import com.typesafe.config.parser.ConfigDocument; +import com.typesafe.config.parser.ConfigDocumentFactory; + +import java.util.Map; + +/** + * Abstract base class for config builders providing common build functionality. + * + * <p>This class implements the shared logic for building configs, including: + * <ul> + * <li>Building ConfigDocument from map representation</li> + * <li>Building Config as fallback</li> + * <li>Building string representation</li> + * </ul> + * + * <p>Subclasses must implement {@link #getConfigMap()} to provide their + * internal configuration map. + */ +public abstract class AbstractConfigBuilder implements ConfigBuilder { + + /** + * Returns the internal configuration map used for building. + * + * @return the configuration map + */ + protected abstract Map<String, Object> getConfigMap(); + + /** + * Builds the ConfigDocument from the configured values. + * This is the primary method, matching how application code works. + * + * @return the built ConfigDocument + */ + @Override + public ConfigDocument buildDocument() { + try { + // First try to build as ConfigDocument from the string representation + String hoconString = buildAsStringFromConfig(); + return ConfigDocumentFactory.parseString(hoconString); + } catch (ConfigException e) { + // If ConfigDocument parsing fails, we'll fall back to Config in build() + // This matches the application pattern where ConfigDocument is preferred + // but Config is used as fallback for error cases + throw new IllegalStateException("Failed to build ConfigDocument, use build() as fallback", e); + } + } + + /** + * Builds the Config object from the configured values. + * This is a fallback method for cases where ConfigDocument parsing fails + * or when Config is needed for migration/validation. + * + * @return the built Config object + */ + @Override + public Config build() { + return ConfigFactory.parseMap(getConfigMap()); + } + + /** + * Builds the config as a HOCON string. + * Uses ConfigDocument when possible to preserve formatting. + * + * @return the config as a HOCON-formatted string + */ + @Override + public String buildAsString() { + try { + return buildDocument().render(); + } catch (Exception e) { + // Fallback to Config rendering if ConfigDocument fails + return buildAsStringFromConfig(); + } + } + + /** + * Internal helper to build string from Config (fallback method). + */ + protected String buildAsStringFromConfig() { + return build().root().render(); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigBuilder.java new file mode 100644 index 0000000..24a4c1c --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigBuilder.java @@ -0,0 +1,65 @@ +/* + * 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.testutil.config; + +import com.typesafe.config.Config; +import com.typesafe.config.parser.ConfigDocument; + +/** + * Common interface for all config builders. + * + * <p>This interface defines the contract that all config version builders must implement. + * It allows for polymorphic usage of builders and ensures consistency across different + * config versions (legacy/v0, v1, and future versions). + * + * <p>Builders use ConfigDocument as the primary representation (matching application code), + * and fall back to Config only when there's an error parsing that needs to be migrated/updated. + * + * <p>Example usage: + * <pre>{@code + * ConfigBuilder builder = LegacyConfigBuilder.create(); + * ConfigDocument document = builder.buildDocument(); + * Config config = builder.build(); // Fallback for error cases + * String configString = builder.buildAsString(); + * }</pre> + */ +public interface ConfigBuilder { + + /** + * Builds the ConfigDocument from the configured values. + * This is the primary method, matching how application code works. + * + * @return the built ConfigDocument + */ + ConfigDocument buildDocument(); + + /** + * Builds the Config object from the configured values. + * This is a fallback method for cases where ConfigDocument parsing fails + * or when Config is needed for migration/validation. + * + * @return the built Config object + */ + Config build(); + + /** + * Builds the config as a HOCON string representation. + * Uses ConfigDocument when possible to preserve formatting. + * + * @return the config as a HOCON-formatted string + */ + String buildAsString(); +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigFileBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigFileBuilder.java new file mode 100644 index 0000000..77bb059 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigFileBuilder.java @@ -0,0 +1,155 @@ +/* + * 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.testutil.config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Builder for creating config files in different formats. + * + * <p>This builder helps create temporary config files for integration tests. + * It can work with both legacy and v1 config formats. + * + * <p>Example usage: + * <pre>{@code + * Path configFile = ConfigFileBuilder.legacy() + * .withToken("test_token") + * .withOwner(123456789L) + * .buildFile(tempDir); + * }</pre> + */ +public class ConfigFileBuilder { + private final StringBuilder content = new StringBuilder(); + private boolean isLegacy = false; + + private ConfigFileBuilder(boolean isLegacy) { + this.isLegacy = isLegacy; + if (!isLegacy) { + // Add meta section for v1 + content.append("meta {\n"); + content.append(" configVersion = 1\n"); + content.append("}\n"); + } + } + + /** + * Creates a builder for legacy format config files. + */ + public static ConfigFileBuilder legacy() { + return new ConfigFileBuilder(true); + } + + /** + * Creates a builder for v1 format config files. + */ + public static ConfigFileBuilder v1() { + return new ConfigFileBuilder(false); + } + + /** + * Creates a builder with custom content. + */ + public static ConfigFileBuilder withContent(String content) { + ConfigFileBuilder builder = new ConfigFileBuilder(false); + builder.content.setLength(0); + builder.content.append(content); + return builder; + } + + /** + * Adds a simple key-value pair (for legacy format). + */ + public ConfigFileBuilder addKeyValue(String key, Object value) { + if (value instanceof String) { + content.append(key).append(" = \"").append(value).append("\"\n"); + } else if (value instanceof Boolean || value instanceof Number) { + content.append(key).append(" = ").append(value).append("\n"); + } else { + content.append(key).append(" = ").append(value).append("\n"); + } + return this; + } + + /** + * Adds a nested key-value pair (for v1 format). + */ + public ConfigFileBuilder addNestedKeyValue(String path, Object value) { + String[] parts = path.split("\\."); + StringBuilder indent = new StringBuilder(); + for (int i = 0; i < parts.length - 1; i++) { + content.append(indent).append(parts[i]).append(" {\n"); + indent.append(" "); + } + String lastKey = parts[parts.length - 1]; + if (value instanceof String) { + content.append(indent).append(lastKey).append(" = \"").append(value).append("\"\n"); + } else if (value instanceof Boolean || value instanceof Number) { + content.append(indent).append(lastKey).append(" = ").append(value).append("\n"); + } else { + content.append(indent).append(lastKey).append(" = ").append(value).append("\n"); + } + for (int i = 0; i < parts.length - 1; i++) { + indent.setLength(indent.length() - 2); + content.append(indent).append("}\n"); + } + return this; + } + + /** + * Adds raw content to the config file. + */ + public ConfigFileBuilder addRaw(String rawContent) { + content.append(rawContent); + return this; + } + + /** + * Builds the config file content as a string. + */ + public String buildAsString() { + return content.toString(); + } + + /** + * Builds a temporary config file in the given directory. + * + * @param tempDir the temporary directory to create the file in + * @return the path to the created config file + * @throws IOException if file creation fails + */ + public Path buildFile(Path tempDir) throws IOException { + String fileName = "config-" + System.currentTimeMillis() + ".conf"; + Path configFile = tempDir.resolve(fileName); + Files.writeString(configFile, content.toString()); + return configFile; + } + + /** + * Builds a temporary config file with a specific name. + * + * @param tempDir the temporary directory to create the file in + * @param fileName the name of the file + * @return the path to the created config file + * @throws IOException if file creation fails + */ + public Path buildFile(Path tempDir, String fileName) throws IOException { + Path configFile = tempDir.resolve(fileName); + Files.writeString(configFile, content.toString()); + return configFile; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigMigrationAssertions.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigMigrationAssertions.java new file mode 100644 index 0000000..a0da180 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigMigrationAssertions.java @@ -0,0 +1,207 @@ +/* + * 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.testutil.config; + +import com.typesafe.config.Config; +import org.junit.jupiter.api.Assertions; + +import java.util.List; + +/** + * Helper class for asserting migration correctness in tests. + * + * <p>Provides static methods to verify that config migrations work correctly. + * + * <p>Example usage: + * <pre>{@code + * Config legacy = LegacyConfigBuilder.create().withToken("test").build(); + * Config migrated = migration.migrate(legacy); + * ConfigMigrationAssertions.assertLegacyToV1Migration(legacy, migrated); + * }</pre> + */ +public class ConfigMigrationAssertions { + + /** + * Asserts that a legacy config was correctly migrated to v1 format. + * Verifies all common key mappings and that legacy keys are removed. + */ + public static void assertLegacyToV1Migration(Config legacy, Config migrated) { + // Verify meta version is set + Assertions.assertTrue(migrated.hasPath("meta.configVersion"), + "Migrated config should have meta.configVersion"); + Assertions.assertEquals(1, migrated.getInt("meta.configVersion"), + "Migrated config should have version 1"); + + // Verify common key migrations + if (legacy.hasPath("token")) { + assertKeyMigrated(migrated, "token", "discord.token"); + } + if (legacy.hasPath("owner")) { + assertKeyMigrated(migrated, "owner", "discord.owner"); + } + if (legacy.hasPath("prefix")) { + assertKeyMigrated(migrated, "prefix", "commands.prefix"); + } + if (legacy.hasPath("altprefix")) { + assertKeyMigrated(migrated, "altprefix", "commands.altPrefix"); + } + if (legacy.hasPath("help")) { + assertKeyMigrated(migrated, "help", "commands.help"); + } + if (legacy.hasPath("game")) { + assertKeyMigrated(migrated, "game", "presence.game"); + } + if (legacy.hasPath("status")) { + assertKeyMigrated(migrated, "status", "presence.status"); + } + if (legacy.hasPath("songinstatus")) { + assertKeyMigrated(migrated, "songinstatus", "presence.songInStatus"); + } + if (legacy.hasPath("stayinchannel")) { + assertKeyMigrated(migrated, "stayinchannel", "voice.stayInChannel"); + } + if (legacy.hasPath("maxtime")) { + assertKeyMigrated(migrated, "maxtime", "playback.maxTrackSeconds"); + } + if (legacy.hasPath("skipratio")) { + assertKeyMigrated(migrated, "skipratio", "playback.skipRatio"); + } + if (legacy.hasPath("loglevel")) { + assertKeyMigrated(migrated, "loglevel", "logging.level"); + } + + // Verify legacy keys are removed + assertLegacyKeysRemoved(migrated, + "token", "owner", "prefix", "altprefix", "help", "game", "status", + "songinstatus", "stayinchannel", "maxtime", "skipratio", "loglevel"); + + // Verify audio sources migration if present + if (legacy.hasPath("audiosources")) { + assertAudioSourcesMigrated(legacy, migrated); + } + } + + /** + * Asserts that a legacy key was migrated to a new key with the same value. + */ + public static void assertKeyMigrated(Config migrated, String legacyKey, String newKey) { + Assertions.assertTrue(migrated.hasPath(newKey), + String.format("Migrated config should have new key '%s' (from legacy key '%s')", newKey, legacyKey)); + } + + /** + * Asserts that a legacy key was migrated to a new key with the same string value. + */ + public static void assertKeyMigrated(Config legacy, Config migrated, String legacyKey, String newKey) { + if (legacy.hasPath(legacyKey)) { + String legacyValue = legacy.getString(legacyKey); + Assertions.assertTrue(migrated.hasPath(newKey), + String.format("Migrated config should have new key '%s'", newKey)); + Assertions.assertEquals(legacyValue, migrated.getString(newKey), + String.format("Value should be preserved when migrating '%s' to '%s'", legacyKey, newKey)); + } + } + + /** + * Asserts that a legacy key was migrated to a new key with the same long value. + */ + public static void assertKeyMigratedLong(Config legacy, Config migrated, String legacyKey, String newKey) { + if (legacy.hasPath(legacyKey)) { + Long legacyValue = legacy.getLong(legacyKey); + Assertions.assertTrue(migrated.hasPath(newKey), + String.format("Migrated config should have new key '%s'", newKey)); + Assertions.assertEquals(legacyValue, migrated.getLong(newKey), + String.format("Value should be preserved when migrating '%s' to '%s'", legacyKey, newKey)); + } + } + + /** + * Asserts that a legacy key was migrated to a new key with the same boolean value. + */ + public static void assertKeyMigratedBoolean(Config legacy, Config migrated, String legacyKey, String newKey) { + if (legacy.hasPath(legacyKey)) { + Boolean legacyValue = legacy.getBoolean(legacyKey); + Assertions.assertTrue(migrated.hasPath(newKey), + String.format("Migrated config should have new key '%s'", newKey)); + Assertions.assertEquals(legacyValue, migrated.getBoolean(newKey), + String.format("Value should be preserved when migrating '%s' to '%s'", legacyKey, newKey)); + } + } + + /** + * Asserts that a legacy key was migrated to a new key with the same double value. + */ + public static void assertKeyMigratedDouble(Config legacy, Config migrated, String legacyKey, String newKey) { + if (legacy.hasPath(legacyKey)) { + Double legacyValue = legacy.getDouble(legacyKey); + Assertions.assertTrue(migrated.hasPath(newKey), + String.format("Migrated config should have new key '%s'", newKey)); + Assertions.assertEquals(legacyValue, migrated.getDouble(newKey), + String.format("Value should be preserved when migrating '%s' to '%s'", legacyKey, newKey)); + } + } + + /** + * Asserts that legacy keys are not present in the migrated config. + */ + public static void assertLegacyKeysRemoved(Config migrated, String... legacyKeys) { + for (String legacyKey : legacyKeys) { + Assertions.assertFalse(migrated.hasPath(legacyKey), + String.format("Legacy key '%s' should not be present in migrated config", legacyKey)); + } + } + + /** + * Asserts that audio sources were correctly migrated from list format to boolean map format. + */ + public static void assertAudioSourcesMigrated(Config legacy, Config migrated) { + Assertions.assertTrue(migrated.hasPath("playback.audioSources"), + "Migrated config should have playback.audioSources"); + + Config audioSources = migrated.getConfig("playback.audioSources"); + + if (legacy.hasPath("audiosources")) { + List<String> legacySources = legacy.getStringList("audiosources"); + + // Verify enabled sources are true + for (String source : legacySources) { + String sourceKey = source.toLowerCase(); + Assertions.assertTrue(audioSources.hasPath(sourceKey), + String.format("Audio source '%s' should exist in migrated config", sourceKey)); + Assertions.assertTrue(audioSources.getBoolean(sourceKey), + String.format("Audio source '%s' should be enabled", sourceKey)); + } + + // Verify other sources are false (if they exist) + // Note: This is a basic check - actual implementation may vary + } else { + // If no audiosources key, all should be enabled by default + Assertions.assertTrue(audioSources.getBoolean("youtube"), + "When audiosources is missing, youtube should default to enabled"); + } + } + + /** + * Asserts that a nested structure was preserved during migration. + */ + public static void assertNestedStructurePreserved(Config legacy, Config migrated, + String legacyPath, String newPath) { + if (legacy.hasPath(legacyPath)) { + Assertions.assertTrue(migrated.hasPath(newPath), + String.format("Nested structure should be preserved: %s -> %s", legacyPath, newPath)); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigTestConstants.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigTestConstants.java new file mode 100644 index 0000000..ed428b4 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/ConfigTestConstants.java @@ -0,0 +1,89 @@ +/* + * 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.testutil.config; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Shared constants for config test utilities. + * + * <p>This class contains constants shared between {@link LegacyConfigBuilder} + * and {@link V1ConfigBuilder} to avoid duplication. + */ +public final class ConfigTestConstants { + + private ConfigTestConstants() { + // Prevent instantiation + } + + /** + * All available audio source names (matching reference.conf and reference-legacy.conf). + */ + public static final String[] ALL_AUDIO_SOURCES = { + "youtube", "soundcloud", "bandcamp", "vimeo", "twitch", + "beam", "getyarn", "nico", "http", "local" + }; + + /** + * Default command aliases matching reference.conf. + * This is an unmodifiable map to prevent accidental modification. + */ + public static final Map<String, List<String>> DEFAULT_ALIASES; + + static { + Map<String, List<String>> aliases = new HashMap<>(); + aliases.put("settings", List.of("status")); + aliases.put("lyrics", List.of()); + aliases.put("nowplaying", List.of("np", "current")); + aliases.put("play", List.of()); + aliases.put("playlists", List.of("pls")); + aliases.put("queue", List.of("list")); + aliases.put("remove", List.of("delete")); + aliases.put("scsearch", List.of()); + aliases.put("search", List.of("ytsearch")); + aliases.put("shuffle", List.of()); + aliases.put("skip", List.of("voteskip")); + aliases.put("prefix", List.of("setprefix")); + aliases.put("setdj", List.of()); + aliases.put("setskip", List.of("setskippercent", "skippercent", "setskipratio")); + aliases.put("settc", List.of()); + aliases.put("setvc", List.of()); + aliases.put("forceremove", List.of("forcedelete", "modremove", "moddelete", "modelete")); + aliases.put("forceskip", List.of("modskip")); + aliases.put("movetrack", List.of("move")); + aliases.put("pause", List.of()); + aliases.put("playnext", List.of()); + aliases.put("queuetype", List.of()); + aliases.put("repeat", List.of()); + aliases.put("skipto", List.of("jumpto")); + aliases.put("stop", List.of("leave")); + aliases.put("volume", List.of("vol")); + DEFAULT_ALIASES = Collections.unmodifiableMap(aliases); + } + + /** + * Creates a mutable copy of the default aliases map. + * Use this when you need to modify the aliases. + * + * @return a mutable copy of the default aliases + */ + public static Map<String, List<String>> copyDefaultAliases() { + return new HashMap<>(DEFAULT_ALIASES); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/LegacyConfigBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/LegacyConfigBuilder.java new file mode 100644 index 0000000..7d675e8 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/LegacyConfigBuilder.java @@ -0,0 +1,360 @@ +/* + * 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.testutil.config; + +import com.typesafe.config.Config; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.jagrosh.jmusicbot.testutil.config.ConfigTestConstants.ALL_AUDIO_SOURCES; +import static com.jagrosh.jmusicbot.testutil.config.ConfigTestConstants.DEFAULT_ALIASES; + +/** + * Builder for creating legacy (version 0) config objects with flat key structure. + * + * <p>This builder provides a fluent API for creating legacy configs used in migration tests. + * All keys are flat (not nested) as in the original config format. + * + * <p>Example usage: + * <pre>{@code + * Config legacy = LegacyConfigBuilder.create() + * .withToken("test_token") + * .withOwner(123456789L) + * .withPrefix("!!") + * .withAudioSources("youtube", "soundcloud") + * .build(); + * }</pre> + * + * <p>This builder is based on the reference-legacy.conf file structure. + */ +public class LegacyConfigBuilder extends AbstractConfigBuilder { + private final Map<String, Object> config = new HashMap<>(); + + @Override + protected Map<String, Object> getConfigMap() { + return config; + } + + /** + * Sets the bot token. + */ + public LegacyConfigBuilder withToken(String token) { + config.put("token", token); + return this; + } + + /** + * Sets the owner ID. + */ + public LegacyConfigBuilder withOwner(Long owner) { + config.put("owner", owner); + return this; + } + + /** + * Sets the command prefix. + */ + public LegacyConfigBuilder withPrefix(String prefix) { + config.put("prefix", prefix); + return this; + } + + /** + * Sets the alternate prefix (use "NONE" to disable). + */ + public LegacyConfigBuilder withAltPrefix(String altPrefix) { + config.put("altprefix", altPrefix); + return this; + } + + /** + * Sets the help command name. + */ + public LegacyConfigBuilder withHelp(String help) { + config.put("help", help); + return this; + } + + /** + * Sets the game status. + */ + public LegacyConfigBuilder withGame(String game) { + config.put("game", game); + return this; + } + + /** + * Sets the bot status (ONLINE, IDLE, DND, etc.). + */ + public LegacyConfigBuilder withStatus(String status) { + config.put("status", status); + return this; + } + + /** + * Sets whether to show song in status. + */ + public LegacyConfigBuilder withSongInStatus(boolean songInStatus) { + config.put("songinstatus", songInStatus); + return this; + } + + /** + * Sets the success emoji. + */ + public LegacyConfigBuilder withSuccess(String success) { + config.put("success", success); + return this; + } + + /** + * Sets the warning emoji. + */ + public LegacyConfigBuilder withWarning(String warning) { + config.put("warning", warning); + return this; + } + + /** + * Sets the error emoji. + */ + public LegacyConfigBuilder withError(String error) { + config.put("error", error); + return this; + } + + /** + * Sets the loading emoji. + */ + public LegacyConfigBuilder withLoading(String loading) { + config.put("loading", loading); + return this; + } + + /** + * Sets the searching emoji. + */ + public LegacyConfigBuilder withSearching(String searching) { + config.put("searching", searching); + return this; + } + + /** + * Sets whether to stay in channel. + */ + public LegacyConfigBuilder withStayInChannel(boolean stayInChannel) { + config.put("stayinchannel", stayInChannel); + return this; + } + + /** + * Sets the maximum track time in seconds. + */ + public LegacyConfigBuilder withMaxTime(Long maxTime) { + config.put("maxtime", maxTime); + return this; + } + + /** + * Sets the skip ratio. + */ + public LegacyConfigBuilder withSkipRatio(Double skipRatio) { + config.put("skipratio", skipRatio); + return this; + } + + /** + * Sets the log level. + */ + public LegacyConfigBuilder withLogLevel(String logLevel) { + config.put("loglevel", logLevel); + return this; + } + + /** + * Sets whether to enable eval. + */ + public LegacyConfigBuilder withEval(boolean eval) { + config.put("eval", eval); + return this; + } + + /** + * Sets the eval engine. + */ + public LegacyConfigBuilder withEvalEngine(String evalEngine) { + config.put("evalengine", evalEngine); + return this; + } + + /** + * Sets whether to use YouTube OAuth. + */ + public LegacyConfigBuilder withUseYouTubeOAuth(boolean useYouTubeOAuth) { + config.put("useyoutubeoauth", useYouTubeOAuth); + return this; + } + + /** + * Sets the maximum YouTube playlist pages. + */ + public LegacyConfigBuilder withMaxYTPlaylistPages(Integer maxYTPlaylistPages) { + config.put("maxytplaylistpages", maxYTPlaylistPages); + return this; + } + + /** + * Sets the alone time until stop in seconds. + */ + public LegacyConfigBuilder withAloneTimeUntilStop(Long aloneTimeUntilStop) { + config.put("alonetimeuntilstop", aloneTimeUntilStop); + return this; + } + + /** + * Sets the playlists folder path. + */ + public LegacyConfigBuilder withPlaylistsFolder(String playlistsFolder) { + config.put("playlistsfolder", playlistsFolder); + return this; + } + + /** + * Sets whether to show update alerts. + */ + public LegacyConfigBuilder withUpdateAlerts(boolean updateAlerts) { + config.put("updatealerts", updateAlerts); + return this; + } + + /** + * Sets whether to show now playing images. + */ + public LegacyConfigBuilder withNPImages(boolean npImages) { + config.put("npimages", npImages); + return this; + } + + /** + * Sets the audio sources as a list of strings. + * + * <p>Available sources (from reference-legacy.conf): + * youtube, soundcloud, bandcamp, vimeo, twitch, beam, getyarn, nico, http, local + * + * @param sources audio source names (e.g., "youtube", "soundcloud", "local", "bandcamp") + */ + public LegacyConfigBuilder withAudioSources(String... sources) { + config.put("audiosources", List.of(sources)); + return this; + } + + /** + * Sets all audio sources enabled (matching reference-legacy.conf defaults). + */ + public LegacyConfigBuilder withAllAudioSources() { + return withAudioSources(ALL_AUDIO_SOURCES); + } + + /** + * Sets the audio sources as a list. + */ + public LegacyConfigBuilder withAudioSources(List<String> sources) { + config.put("audiosources", sources); + return this; + } + + /** + * Sets the command aliases. + * + * @param aliases map of command names to lists of aliases + */ + public LegacyConfigBuilder withAliases(Map<String, List<String>> aliases) { + config.put("aliases", aliases); + return this; + } + + /** + * Sets the transforms configuration. + */ + public LegacyConfigBuilder withTransforms(Map<String, Object> transforms) { + config.put("transforms", transforms); + return this; + } + + /** + * Sets a custom key-value pair. + */ + public LegacyConfigBuilder withCustom(String key, Object value) { + config.put(key, value); + return this; + } + + /** + * Creates a new builder instance. + */ + public static LegacyConfigBuilder create() { + return new LegacyConfigBuilder(); + } + + /** + * Creates a minimal valid legacy config with only required fields. + */ + public static Config minimal() { + return new LegacyConfigBuilder() + .withToken("test_token_12345") + .withOwner(123456789L) + .build(); + } + + /** + * Creates a legacy config matching the reference-legacy.conf defaults. + * This includes all default values from the reference file. + */ + public static Config withReferenceDefaults() { + return new LegacyConfigBuilder() + .withToken("BOT_TOKEN_HERE") + .withOwner(0L) + .withPrefix("@mention") + .withAltPrefix("NONE") + .withHelp("help") + .withGame("DEFAULT") + .withStatus("ONLINE") + .withSongInStatus(false) + .withSuccess("🎶") + .withWarning("💡") + .withError("🚫") + .withLoading("⌚") + .withSearching("🔎") + .withNPImages(false) + .withStayInChannel(false) + .withMaxTime(0L) + .withMaxYTPlaylistPages(10) + .withUseYouTubeOAuth(false) + .withSkipRatio(0.55) + .withAloneTimeUntilStop(0L) + .withPlaylistsFolder("Playlists") + .withUpdateAlerts(true) + .withLogLevel("info") + .withEval(false) + .withEvalEngine("Nashorn") + .withAllAudioSources() + .withAliases(new HashMap<>(DEFAULT_ALIASES)) + .withTransforms(new HashMap<>()) + .build(); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/LegacyConfigTestData.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/LegacyConfigTestData.java new file mode 100644 index 0000000..ad18b34 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/LegacyConfigTestData.java @@ -0,0 +1,172 @@ +/* + * 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.testutil.config; + +import com.typesafe.config.Config; +import com.typesafe.config.parser.ConfigDocument; + +import java.util.List; +import java.util.Map; + +/** + * Predefined test data sets for legacy configs. + * + * <p>Provides factory methods for common legacy config scenarios used in tests. + * These are based on the reference-legacy.conf file structure. + */ +public class LegacyConfigTestData { + + /** + * Creates a minimal valid legacy config with only required fields. + */ + public static Config minimal() { + return LegacyConfigBuilder.create() + .withToken("test_token_12345") + .withOwner(123456789L) + .build(); + } + + /** + * Creates a full legacy config with all common fields set. + */ + public static Config full() { + return LegacyConfigBuilder.create() + .withToken("test_token_12345") + .withOwner(123456789L) + .withPrefix("@mention") + .withAltPrefix("NONE") + .withHelp("help") + .withGame("DEFAULT") + .withStatus("ONLINE") + .withSongInStatus(false) + .withSuccess("🎶") + .withWarning("💡") + .withError("🚫") + .withLoading("⌚") + .withSearching("🔎") + .withStayInChannel(false) + .withMaxTime(0L) + .withSkipRatio(0.55) + .withLogLevel("info") + .withEval(false) + .withEvalEngine("Nashorn") + .withUseYouTubeOAuth(false) + .withMaxYTPlaylistPages(10) + .withAloneTimeUntilStop(0L) + .withPlaylistsFolder("Playlists") + .withUpdateAlerts(true) + .withNPImages(false) + .withAudioSources("youtube", "soundcloud") + .build(); + } + + /** + * Creates a legacy config with audio sources. + */ + public static Config withAudioSources(String... sources) { + return LegacyConfigBuilder.create() + .withToken("test_token_12345") + .withOwner(123456789L) + .withAudioSources(sources) + .build(); + } + + /** + * Creates a legacy config with aliases. + */ + public static Config withAliases(Map<String, List<String>> aliases) { + return LegacyConfigBuilder.create() + .withToken("test_token_12345") + .withOwner(123456789L) + .withAliases(aliases) + .build(); + } + + /** + * Creates a legacy config with invalid token placeholder. + */ + public static Config withInvalidToken() { + return LegacyConfigBuilder.create() + .withToken("BOT_TOKEN_HERE") + .withOwner(123456789L) + .build(); + } + + /** + * Creates a legacy config with invalid owner (zero). + */ + public static Config withInvalidOwner() { + return LegacyConfigBuilder.create() + .withToken("test_token_12345") + .withOwner(0L) + .build(); + } + + /** + * Creates a legacy config with missing required fields (only optional fields). + */ + public static Config withMissingRequired() { + return LegacyConfigBuilder.create() + .withPrefix("@mention") + .build(); + } + + /** + * Creates a legacy config for integration testing with common values. + */ + public static Config integrationTest() { + return LegacyConfigBuilder.create() + .withToken("integration_test_token") + .withOwner(987654321L) + .withPrefix("!!") + .withAltPrefix("NONE") + .withHelp("commands") + .withGame("DEFAULT") + .withStatus("ONLINE") + .build(); + } + + /** + * Creates a legacy config matching reference-legacy.conf defaults. + * This is useful for testing against the actual reference configuration. + */ + public static Config withReferenceDefaults() { + return LegacyConfigBuilder.withReferenceDefaults(); + } + + /** + * Creates a minimal valid legacy config as ConfigDocument (primary method). + */ + public static ConfigDocument minimalDocument() { + return LegacyConfigBuilder.create() + .withToken("test_token_12345") + .withOwner(123456789L) + .buildDocument(); + } + + /** + * Creates a legacy config as ConfigDocument matching reference-legacy.conf defaults. + * Note: This converts Config to ConfigDocument, which may not preserve all formatting. + * For best results, use the builder's buildDocument() method directly. + */ + public static ConfigDocument withReferenceDefaultsDocument() { + Config config = LegacyConfigBuilder.withReferenceDefaults(); + // Convert Config to ConfigDocument via string representation + return com.typesafe.config.parser.ConfigDocumentFactory.parseString( + config.root().render() + ); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/V1ConfigBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/V1ConfigBuilder.java new file mode 100644 index 0000000..8e8be9f --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/V1ConfigBuilder.java @@ -0,0 +1,413 @@ +/* + * 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.testutil.config; + +import com.typesafe.config.Config; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.jagrosh.jmusicbot.testutil.config.ConfigTestConstants.ALL_AUDIO_SOURCES; +import static com.jagrosh.jmusicbot.testutil.config.ConfigTestConstants.DEFAULT_ALIASES; + +/** + * Builder for creating version 1 config objects with nested structure. + * + * <p>This builder provides a fluent API for creating v1 configs with the new nested format. + * Keys are organized into sections like discord, commands, playback, etc. + * + * <p>Example usage: + * <pre>{@code + * Config v1 = V1ConfigBuilder.create() + * .withDiscordToken("test_token") + * .withDiscordOwner(123456789L) + * .withCommandsPrefix("!!") + * .withPlaybackAudioSources(Map.of("youtube", true, "soundcloud", true)) + * .build(); + * }</pre> + * + * <p>This builder is based on the reference.conf file structure. + */ +public class V1ConfigBuilder extends AbstractConfigBuilder { + private final Map<String, Object> config = new HashMap<>(); + + @Override + protected Map<String, Object> getConfigMap() { + return config; + } + + /** + * Sets the config version in meta section. + */ + public V1ConfigBuilder withMetaVersion(int version) { + Map<String, Object> meta = getOrCreateSection("meta"); + meta.put("configVersion", version); + return this; + } + + /** + * Sets the Discord bot token. + */ + public V1ConfigBuilder withDiscordToken(String token) { + Map<String, Object> discord = getOrCreateSection("discord"); + discord.put("token", token); + return this; + } + + /** + * Sets the Discord owner ID. + */ + public V1ConfigBuilder withDiscordOwner(Long owner) { + Map<String, Object> discord = getOrCreateSection("discord"); + discord.put("owner", owner); + return this; + } + + /** + * Sets the command prefix. + */ + public V1ConfigBuilder withCommandsPrefix(String prefix) { + Map<String, Object> commands = getOrCreateSection("commands"); + commands.put("prefix", prefix); + return this; + } + + /** + * Sets the alternate prefix (use "NONE" to disable). + */ + public V1ConfigBuilder withCommandsAltPrefix(String altPrefix) { + Map<String, Object> commands = getOrCreateSection("commands"); + commands.put("altPrefix", altPrefix); + return this; + } + + /** + * Sets the help command name. + */ + public V1ConfigBuilder withCommandsHelp(String help) { + Map<String, Object> commands = getOrCreateSection("commands"); + commands.put("help", help); + return this; + } + + /** + * Sets the command aliases. + */ + public V1ConfigBuilder withCommandsAliases(Map<String, List<String>> aliases) { + Map<String, Object> commands = getOrCreateSection("commands"); + commands.put("aliases", aliases); + return this; + } + + /** + * Sets the presence game. + */ + public V1ConfigBuilder withPresenceGame(String game) { + Map<String, Object> presence = getOrCreateSection("presence"); + presence.put("game", game); + return this; + } + + /** + * Sets the presence status. + */ + public V1ConfigBuilder withPresenceStatus(String status) { + Map<String, Object> presence = getOrCreateSection("presence"); + presence.put("status", status); + return this; + } + + /** + * Sets whether to show song in status. + */ + public V1ConfigBuilder withPresenceSongInStatus(boolean songInStatus) { + Map<String, Object> presence = getOrCreateSection("presence"); + presence.put("songInStatus", songInStatus); + return this; + } + + /** + * Sets the success emoji. + */ + public V1ConfigBuilder withUIEmojisSuccess(String success) { + Map<String, Object> ui = getOrCreateSection("ui"); + Map<String, Object> emojis = getOrCreateNestedSection(ui, "emojis"); + emojis.put("success", success); + return this; + } + + /** + * Sets the warning emoji. + */ + public V1ConfigBuilder withUIEmojisWarning(String warning) { + Map<String, Object> ui = getOrCreateSection("ui"); + Map<String, Object> emojis = getOrCreateNestedSection(ui, "emojis"); + emojis.put("warning", warning); + return this; + } + + /** + * Sets the error emoji. + */ + public V1ConfigBuilder withUIEmojisError(String error) { + Map<String, Object> ui = getOrCreateSection("ui"); + Map<String, Object> emojis = getOrCreateNestedSection(ui, "emojis"); + emojis.put("error", error); + return this; + } + + /** + * Sets the loading emoji. + */ + public V1ConfigBuilder withUIEmojisLoading(String loading) { + Map<String, Object> ui = getOrCreateSection("ui"); + Map<String, Object> emojis = getOrCreateNestedSection(ui, "emojis"); + emojis.put("loading", loading); + return this; + } + + /** + * Sets the searching emoji. + */ + public V1ConfigBuilder withUIEmojisSearching(String searching) { + Map<String, Object> ui = getOrCreateSection("ui"); + Map<String, Object> emojis = getOrCreateNestedSection(ui, "emojis"); + emojis.put("searching", searching); + return this; + } + + /** + * Sets whether to show now playing images. + */ + public V1ConfigBuilder withNowPlayingImages(boolean images) { + Map<String, Object> nowPlaying = getOrCreateSection("nowPlaying"); + nowPlaying.put("images", images); + return this; + } + + /** + * Sets whether to stay in channel. + */ + public V1ConfigBuilder withVoiceStayInChannel(boolean stayInChannel) { + Map<String, Object> voice = getOrCreateSection("voice"); + voice.put("stayInChannel", stayInChannel); + return this; + } + + /** + * Sets the alone time until stop in seconds. + */ + public V1ConfigBuilder withVoiceAloneTimeUntilStopSeconds(Long seconds) { + Map<String, Object> voice = getOrCreateSection("voice"); + voice.put("aloneTimeUntilStopSeconds", seconds); + return this; + } + + /** + * Sets the maximum track time in seconds. + */ + public V1ConfigBuilder withPlaybackMaxTrackSeconds(Long seconds) { + Map<String, Object> playback = getOrCreateSection("playback"); + playback.put("maxTrackSeconds", seconds); + return this; + } + + /** + * Sets the maximum YouTube playlist pages. + */ + public V1ConfigBuilder withPlaybackMaxYouTubePlaylistPages(Integer pages) { + Map<String, Object> playback = getOrCreateSection("playback"); + playback.put("maxYouTubePlaylistPages", pages); + return this; + } + + /** + * Sets the skip ratio. + */ + public V1ConfigBuilder withPlaybackSkipRatio(Double ratio) { + Map<String, Object> playback = getOrCreateSection("playback"); + playback.put("skipRatio", ratio); + return this; + } + + /** + * Sets whether to use YouTube OAuth. + */ + public V1ConfigBuilder withPlaybackYouTubeUseOAuth(boolean useOAuth) { + Map<String, Object> playback = getOrCreateSection("playback"); + Map<String, Object> youtube = getOrCreateNestedSection(playback, "youtube"); + youtube.put("useOAuth", useOAuth); + return this; + } + + /** + * Sets the audio sources as a map of source names to boolean values. + * + * <p>Available sources (from reference.conf): + * youtube, soundcloud, bandcamp, vimeo, twitch, beam, getyarn, nico, http, local + */ + public V1ConfigBuilder withPlaybackAudioSources(Map<String, Boolean> audioSources) { + Map<String, Object> playback = getOrCreateSection("playback"); + playback.put("audioSources", audioSources); + return this; + } + + /** + * Sets all audio sources enabled (matching reference.conf defaults). + */ + public V1ConfigBuilder withAllPlaybackAudioSources() { + Map<String, Boolean> allSources = new HashMap<>(); + for (String source : ALL_AUDIO_SOURCES) { + allSources.put(source, true); + } + return withPlaybackAudioSources(allSources); + } + + /** + * Sets the transforms configuration. + */ + public V1ConfigBuilder withPlaybackTransforms(Map<String, Object> transforms) { + Map<String, Object> playback = getOrCreateSection("playback"); + playback.put("transforms", transforms); + return this; + } + + /** + * Sets the playlists folder path. + */ + public V1ConfigBuilder withPathsPlaylistsFolder(String folder) { + Map<String, Object> paths = getOrCreateSection("paths"); + paths.put("playlistsFolder", folder); + return this; + } + + /** + * Sets whether to show update alerts. + */ + public V1ConfigBuilder withUpdatesAlerts(boolean alerts) { + Map<String, Object> updates = getOrCreateSection("updates"); + updates.put("alerts", alerts); + return this; + } + + /** + * Sets the logging level. + */ + public V1ConfigBuilder withLoggingLevel(String level) { + Map<String, Object> logging = getOrCreateSection("logging"); + logging.put("level", level); + return this; + } + + /** + * Sets whether to enable eval. + */ + public V1ConfigBuilder withDangerousEval(boolean eval) { + Map<String, Object> dangerous = getOrCreateSection("dangerous"); + dangerous.put("eval", eval); + return this; + } + + /** + * Sets the eval engine. + */ + public V1ConfigBuilder withDangerousEvalEngine(String engine) { + Map<String, Object> dangerous = getOrCreateSection("dangerous"); + dangerous.put("evalEngine", engine); + return this; + } + + /** + * Sets a custom nested key-value pair. + */ + public V1ConfigBuilder withCustom(String section, String key, Object value) { + Map<String, Object> sectionMap = getOrCreateSection(section); + sectionMap.put(key, value); + return this; + } + + /** + * Creates a new builder instance. + */ + public static V1ConfigBuilder create() { + return new V1ConfigBuilder(); + } + + /** + * Creates a minimal valid v1 config with only required fields. + */ + public static Config minimal() { + return new V1ConfigBuilder() + .withMetaVersion(1) + .withDiscordToken("test_token_12345") + .withDiscordOwner(123456789L) + .build(); + } + + /** + * Creates a v1 config matching the reference.conf defaults. + * This includes all default values from the reference file. + */ + public static Config withReferenceDefaults() { + Map<String, Boolean> allAudioSources = new HashMap<>(); + for (String source : ALL_AUDIO_SOURCES) { + allAudioSources.put(source, true); + } + + return new V1ConfigBuilder() + .withMetaVersion(1) + .withDiscordToken("BOT_TOKEN_HERE") + .withDiscordOwner(0L) + .withCommandsPrefix("@mention") + .withCommandsAltPrefix("NONE") + .withCommandsHelp("help") + .withCommandsAliases(new HashMap<>(DEFAULT_ALIASES)) + .withPresenceGame("DEFAULT") + .withPresenceStatus("ONLINE") + .withPresenceSongInStatus(false) + .withUIEmojisSuccess("🎶") + .withUIEmojisWarning("💡") + .withUIEmojisError("🚫") + .withUIEmojisLoading("⌚") + .withUIEmojisSearching("🔎") + .withNowPlayingImages(false) + .withVoiceStayInChannel(false) + .withVoiceAloneTimeUntilStopSeconds(0L) + .withPlaybackMaxTrackSeconds(0L) + .withPlaybackMaxYouTubePlaylistPages(10) + .withPlaybackSkipRatio(0.55) + .withPlaybackYouTubeUseOAuth(false) + .withPlaybackAudioSources(allAudioSources) + .withPlaybackTransforms(new HashMap<>()) + .withPathsPlaylistsFolder("Playlists") + .withUpdatesAlerts(true) + .withLoggingLevel("info") + .withDangerousEval(false) + .withDangerousEvalEngine("Nashorn") + .build(); + } + + @SuppressWarnings("unchecked") + private Map<String, Object> getOrCreateSection(String section) { + return (Map<String, Object>) config.computeIfAbsent(section, k -> new HashMap<>()); + } + + @SuppressWarnings("unchecked") + private Map<String, Object> getOrCreateNestedSection(Map<String, Object> parent, String key) { + return (Map<String, Object>) parent.computeIfAbsent(key, k -> new HashMap<>()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/config/V1ConfigTestData.java b/src/test/java/com/jagrosh/jmusicbot/testutil/config/V1ConfigTestData.java new file mode 100644 index 0000000..7ad2f99 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/config/V1ConfigTestData.java @@ -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.testutil.config; + +import com.typesafe.config.Config; +import com.typesafe.config.parser.ConfigDocument; + +import java.util.List; +import java.util.Map; + +/** + * Predefined test data sets for v1 configs. + * + * <p>Provides factory methods for common v1 config scenarios used in tests. + * These are based on the reference.conf file structure. + */ +public class V1ConfigTestData { + + /** + * Creates a minimal valid v1 config with only required fields. + */ + public static Config minimal() { + return V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token_12345") + .withDiscordOwner(123456789L) + .build(); + } + + /** + * Creates a full v1 config with all common sections populated. + */ + public static Config full() { + return V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token_12345") + .withDiscordOwner(123456789L) + .withCommandsPrefix("@mention") + .withCommandsAltPrefix("NONE") + .withCommandsHelp("help") + .withPresenceGame("DEFAULT") + .withPresenceStatus("ONLINE") + .withPresenceSongInStatus(false) + .withUIEmojisSuccess("🎶") + .withUIEmojisWarning("💡") + .withUIEmojisError("🚫") + .withUIEmojisLoading("⌚") + .withUIEmojisSearching("🔎") + .withNowPlayingImages(false) + .withVoiceStayInChannel(false) + .withVoiceAloneTimeUntilStopSeconds(0L) + .withPlaybackMaxTrackSeconds(0L) + .withPlaybackMaxYouTubePlaylistPages(10) + .withPlaybackSkipRatio(0.55) + .withPlaybackYouTubeUseOAuth(false) + .withPlaybackAudioSources(Map.of( + "youtube", true, + "soundcloud", true, + "local", false, + "bandcamp", false + )) + .withPathsPlaylistsFolder("Playlists") + .withUpdatesAlerts(true) + .withLoggingLevel("info") + .withDangerousEval(false) + .withDangerousEvalEngine("Nashorn") + .build(); + } + + /** + * Creates a v1 config with all sections populated (for comprehensive testing). + */ + public static Config withAllSections() { + return V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token_12345") + .withDiscordOwner(123456789L) + .withCommandsPrefix("!!") + .withCommandsAltPrefix("??") + .withCommandsHelp("help") + .withCommandsAliases(Map.of( + "play", List.of("p", "playmusic"), + "skip", List.of("voteskip", "vs") + )) + .withPresenceGame("Playing music") + .withPresenceStatus("IDLE") + .withPresenceSongInStatus(true) + .withUIEmojisSuccess("✅") + .withUIEmojisWarning("⚠️") + .withUIEmojisError("❌") + .withUIEmojisLoading("⏳") + .withUIEmojisSearching("🔍") + .withNowPlayingImages(true) + .withVoiceStayInChannel(true) + .withVoiceAloneTimeUntilStopSeconds(300L) + .withPlaybackMaxTrackSeconds(3600L) + .withPlaybackMaxYouTubePlaylistPages(20) + .withPlaybackSkipRatio(0.75) + .withPlaybackYouTubeUseOAuth(true) + .withPlaybackAudioSources(Map.of( + "youtube", true, + "soundcloud", true, + "local", true, + "bandcamp", false + )) + .withPlaybackTransforms(Map.of()) + .withPathsPlaylistsFolder("CustomPlaylists") + .withUpdatesAlerts(false) + .withLoggingLevel("debug") + .withDangerousEval(false) + .withDangerousEvalEngine("Nashorn") + .build(); + } + + /** + * Creates a v1 config for integration testing. + */ + public static Config integrationTest() { + return V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("integration_test_token") + .withDiscordOwner(987654321L) + .withCommandsPrefix("!!") + .withCommandsAltPrefix("NONE") + .withCommandsHelp("help") + .build(); + } + + /** + * Creates a v1 config matching reference.conf defaults. + * This is useful for testing against the actual reference configuration. + */ + public static Config withReferenceDefaults() { + return V1ConfigBuilder.withReferenceDefaults(); + } + + /** + * Creates a minimal valid v1 config as ConfigDocument (primary method). + */ + public static ConfigDocument minimalDocument() { + return V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token_12345") + .withDiscordOwner(123456789L) + .buildDocument(); + } + + /** + * Creates a v1 config as ConfigDocument matching reference.conf defaults. + * Note: This converts Config to ConfigDocument, which may not preserve all formatting. + * For best results, use the builder's buildDocument() method directly. + */ + public static ConfigDocument withReferenceDefaultsDocument() { + Config config = V1ConfigBuilder.withReferenceDefaults(); + // Convert Config to ConfigDocument via string representation + return com.typesafe.config.parser.ConfigDocumentFactory.parseString( + config.root().render() + ); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/listener/ListenerTestFixture.java b/src/test/java/com/jagrosh/jmusicbot/testutil/listener/ListenerTestFixture.java new file mode 100644 index 0000000..7061540 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/listener/ListenerTestFixture.java @@ -0,0 +1,546 @@ +/* + * 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.testutil.listener; + +import com.jagrosh.jdautilities.command.CommandClient; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.audio.AloneInVoiceHandler; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.audio.NowPlayingHandler; +import com.jagrosh.jmusicbot.audio.PlayerManager; +import com.jagrosh.jmusicbot.entities.UserInteraction; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.settings.SettingsManager; +import com.jagrosh.jmusicbot.testutil.TestConstants; +import com.jagrosh.jmusicbot.utils.YoutubeOauth2TokenHandler; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +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.SelfMember; +import net.dv8tion.jda.api.entities.SelfUser; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel; +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.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.guild.GuildJoinEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.message.MessageDeleteEvent; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.events.session.SessionDisconnectEvent; +import net.dv8tion.jda.api.events.session.ShutdownEvent; +import net.dv8tion.jda.api.requests.CloseCode; +import net.dv8tion.jda.api.managers.AudioManager; +import net.dv8tion.jda.api.requests.restaction.CacheRestAction; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.dv8tion.jda.api.utils.cache.SnowflakeCacheView; + +import java.util.Collections; +import java.util.concurrent.ScheduledExecutorService; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Test fixture providing common mocks and setup for Listener tests. + * Uses builder pattern for fluent test configuration. + */ +public class ListenerTestFixture +{ + // Core bot mocks + private final Bot bot; + private final BotConfig config; + private final PlayerManager playerManager; + private final SettingsManager settingsManager; + private final Settings settings; + private final NowPlayingHandler nowPlayingHandler; + private final AloneInVoiceHandler aloneInVoiceHandler; + private final MusicService musicService; + private final CommandClient commandClient; + private final ScheduledExecutorService threadpool; + private final YoutubeOauth2TokenHandler youtubeOauth2TokenHandler; + private final UserInteraction userInteraction; + + // JDA mocks + private final JDA jda; + private final Guild guild; + private final Member member; + private final User user; + private final SelfMember selfMember; + private final SelfUser selfUser; + private final TextChannel textChannel; + private final AudioManager audioManager; + private final AudioHandler audioHandler; + private final AudioPlayer audioPlayer; + + // Voice mocks + private final GuildVoiceState selfVoiceState; + private final GuildVoiceState memberVoiceState; + private final VoiceChannel voiceChannel; + + // Event mocks + private final ReadyEvent readyEvent; + private final ShutdownEvent shutdownEvent; + private final SessionDisconnectEvent sessionDisconnectEvent; + private final MessageDeleteEvent messageDeleteEvent; + private final ButtonInteractionEvent buttonInteractionEvent; + private final GuildVoiceUpdateEvent guildVoiceUpdateEvent; + private final GuildJoinEvent guildJoinEvent; + + // Reply action mock + private final ReplyCallbackAction replyAction; + + // Re-export constants for backwards compatibility + // New code should use TestConstants directly + /** @deprecated Use {@link TestConstants#GUILD_ID} instead */ + @Deprecated + public static final long GUILD_ID = TestConstants.GUILD_ID; + /** @deprecated Use {@link TestConstants#USER_ID} instead */ + @Deprecated + public static final long USER_ID = TestConstants.USER_ID; + /** @deprecated Use {@link TestConstants#OWNER_ID} instead */ + @Deprecated + public static final long OWNER_ID = TestConstants.OWNER_ID; + /** @deprecated Use {@link TestConstants#MESSAGE_ID} instead */ + @Deprecated + public static final long MESSAGE_ID = TestConstants.MESSAGE_ID; + /** @deprecated Use {@link TestConstants#CHANNEL_ID} instead */ + @Deprecated + public static final long CHANNEL_ID = TestConstants.CHANNEL_ID; + + @SuppressWarnings("unchecked") + private ListenerTestFixture() + { + // Create all mocks + bot = mock(Bot.class); + config = mock(BotConfig.class); + playerManager = mock(PlayerManager.class); + settingsManager = mock(SettingsManager.class); + settings = mock(Settings.class); + nowPlayingHandler = mock(NowPlayingHandler.class); + aloneInVoiceHandler = mock(AloneInVoiceHandler.class); + musicService = mock(MusicService.class); + commandClient = mock(CommandClient.class); + threadpool = mock(ScheduledExecutorService.class); + youtubeOauth2TokenHandler = mock(YoutubeOauth2TokenHandler.class); + userInteraction = mock(UserInteraction.class); + + // JDA mocks + jda = mock(JDA.class); + guild = mock(Guild.class); + member = mock(Member.class); + user = mock(User.class); + selfMember = mock(SelfMember.class); + selfUser = mock(SelfUser.class); + textChannel = mock(TextChannel.class); + audioManager = mock(AudioManager.class); + audioHandler = mock(AudioHandler.class); + audioPlayer = mock(AudioPlayer.class); + selfVoiceState = mock(GuildVoiceState.class); + memberVoiceState = mock(GuildVoiceState.class); + voiceChannel = mock(VoiceChannel.class, withSettings().extraInterfaces(AudioChannelUnion.class)); + + // Event mocks + readyEvent = mock(ReadyEvent.class); + shutdownEvent = mock(ShutdownEvent.class); + sessionDisconnectEvent = mock(SessionDisconnectEvent.class); + messageDeleteEvent = mock(MessageDeleteEvent.class); + buttonInteractionEvent = mock(ButtonInteractionEvent.class); + guildVoiceUpdateEvent = mock(GuildVoiceUpdateEvent.class); + guildJoinEvent = mock(GuildJoinEvent.class); + + // Reply action mock + replyAction = mock(ReplyCallbackAction.class); + + setupDefaultRelationships(); + } + + /** + * Creates a new fixture with default configuration. + */ + public static ListenerTestFixture create() + { + return new ListenerTestFixture(); + } + + @SuppressWarnings("unchecked") + private void setupDefaultRelationships() + { + // Bot relationships + when(bot.getConfig()).thenReturn(config); + when(bot.getPlayerManager()).thenReturn(playerManager); + when(bot.getSettingsManager()).thenReturn(settingsManager); + when(bot.getNowplayingHandler()).thenReturn(nowPlayingHandler); + when(bot.getAloneInVoiceHandler()).thenReturn(aloneInVoiceHandler); + when(bot.getMusicService()).thenReturn(musicService); + when(bot.getCommandClient()).thenReturn(commandClient); + when(bot.getThreadpool()).thenReturn(threadpool); + when(bot.getJDA()).thenReturn(jda); + when(bot.getYouTubeOauth2Handler()).thenReturn(youtubeOauth2TokenHandler); + when(bot.getUserInteraction()).thenReturn(userInteraction); + + // Settings relationships + when(settingsManager.getSettings(anyLong())).thenReturn(settings); + when(settingsManager.getSettings(any(Guild.class))).thenReturn(settings); + + // Guild relationships + when(guild.getIdLong()).thenReturn(GUILD_ID); + when(guild.getId()).thenReturn(String.valueOf(GUILD_ID)); + when(guild.getSelfMember()).thenReturn(selfMember); + when(guild.getAudioManager()).thenReturn(audioManager); + + // Audio relationships + when(audioManager.getSendingHandler()).thenReturn(audioHandler); + when(playerManager.setUpHandler(any(Guild.class))).thenReturn(audioHandler); + when(audioHandler.getPlayer()).thenReturn(audioPlayer); + + // Voice state relationships + when(selfMember.getVoiceState()).thenReturn(selfVoiceState); + when(member.getVoiceState()).thenReturn(memberVoiceState); + + // Member/User relationships + when(member.getUser()).thenReturn(user); + when(member.getGuild()).thenReturn(guild); + when(member.getIdLong()).thenReturn(USER_ID); + when(user.getIdLong()).thenReturn(USER_ID); + when(user.getId()).thenReturn(String.valueOf(USER_ID)); + when(user.getName()).thenReturn("TestUser"); + + // JDA relationships + when(jda.getGuildById(GUILD_ID)).thenReturn(guild); + when(jda.getSelfUser()).thenReturn(selfUser); + + // Guild cache + SnowflakeCacheView<Guild> guildCache = mock(SnowflakeCacheView.class); + when(guildCache.isEmpty()).thenReturn(false); + when(jda.getGuildCache()).thenReturn(guildCache); + when(jda.getGuilds()).thenReturn(Collections.singletonList(guild)); + + // Config defaults + when(config.getOwnerId()).thenReturn(OWNER_ID); + when(config.useUpdateAlerts()).thenReturn(false); + when(config.useYouTubeOauth()).thenReturn(false); + when(config.getDBots()).thenReturn(false); + + // Settings defaults + when(settings.getDefaultPlaylist()).thenReturn(null); + when(settings.getVoiceChannel(any(Guild.class))).thenReturn(null); + + // ReadyEvent defaults + when(readyEvent.getJDA()).thenReturn(jda); + + // MessageDeleteEvent defaults + when(messageDeleteEvent.isFromGuild()).thenReturn(true); + when(messageDeleteEvent.getGuild()).thenReturn(guild); + when(messageDeleteEvent.getMessageIdLong()).thenReturn(MESSAGE_ID); + + // ButtonInteractionEvent defaults + when(buttonInteractionEvent.getGuild()).thenReturn(guild); + when(buttonInteractionEvent.getMember()).thenReturn(member); + when(buttonInteractionEvent.getJDA()).thenReturn(jda); + when(buttonInteractionEvent.getComponentId()).thenReturn("unknown"); + when(buttonInteractionEvent.reply(anyString())).thenReturn(replyAction); + when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction); + doNothing().when(replyAction).queue(); + + // GuildVoiceUpdateEvent defaults + when(guildVoiceUpdateEvent.getGuild()).thenReturn(guild); + when(guildVoiceUpdateEvent.getMember()).thenReturn(member); + + // GuildJoinEvent defaults + when(guildJoinEvent.getJDA()).thenReturn(jda); + when(guildJoinEvent.getGuild()).thenReturn(guild); + + // ShutdownEvent defaults + when(shutdownEvent.getJDA()).thenReturn(jda); + + // SessionDisconnectEvent defaults + when(sessionDisconnectEvent.getJDA()).thenReturn(jda); + when(sessionDisconnectEvent.getCloseCode()).thenReturn(null); + + // TextChannel defaults + when(textChannel.getIdLong()).thenReturn(CHANNEL_ID); + } + + // ==================== Builder Methods ==================== + + /** + * Configures an empty guild cache (no guilds). + */ + @SuppressWarnings("unchecked") + public ListenerTestFixture withEmptyGuildCache() + { + SnowflakeCacheView<Guild> guildCache = mock(SnowflakeCacheView.class); + when(guildCache.isEmpty()).thenReturn(true); + when(jda.getGuildCache()).thenReturn(guildCache); + when(jda.getGuilds()).thenReturn(Collections.emptyList()); + return this; + } + + /** + * Configures update alerts to be enabled. + */ + public ListenerTestFixture withUpdateAlerts() + { + when(config.useUpdateAlerts()).thenReturn(true); + return this; + } + + /** + * Configures YouTube OAuth to be enabled. + */ + public ListenerTestFixture withYouTubeOauth() + { + when(config.useYouTubeOauth()).thenReturn(true); + YoutubeOauth2TokenHandler.Data oauthData = mock(YoutubeOauth2TokenHandler.Data.class); + when(oauthData.getAuthorisationUrl()).thenReturn("https://example.com/auth"); + when(oauthData.getCode()).thenReturn("TEST-CODE"); + when(youtubeOauth2TokenHandler.getData()).thenReturn(oauthData); + + // Mock private channel for owner + CacheRestAction<PrivateChannel> privateChannelAction = mock(CacheRestAction.class); + PrivateChannel privateChannel = mock(PrivateChannel.class); + when(jda.openPrivateChannelById(OWNER_ID)).thenReturn(privateChannelAction); + when(privateChannelAction.complete()).thenReturn(privateChannel); + + return this; + } + + /** + * Configures a default playlist for the guild. + */ + public ListenerTestFixture withDefaultPlaylist(String playlist) + { + when(settings.getDefaultPlaylist()).thenReturn(playlist); + when(settings.getVoiceChannel(guild)).thenReturn(voiceChannel); + when(audioHandler.playFromDefault()).thenReturn(true); + return this; + } + + /** + * Configures a button interaction event with a specific button ID. + */ + public ListenerTestFixture withButtonId(String buttonId) + { + when(buttonInteractionEvent.getComponentId()).thenReturn(buttonId); + return this; + } + + /** + * Configures the member to be in a voice channel for button interactions. + */ + public ListenerTestFixture withMemberInVoiceChannel() + { + when(memberVoiceState.inAudioChannel()).thenReturn(true); + when(memberVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + when(selfVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + return this; + } + + /** + * Configures the member to NOT be in a voice channel. + */ + public ListenerTestFixture withMemberNotInVoiceChannel() + { + when(memberVoiceState.inAudioChannel()).thenReturn(false); + when(memberVoiceState.getChannel()).thenReturn(null); + return this; + } + + /** + * Configures audio handler to be playing. + */ + public ListenerTestFixture withAudioHandlerPlaying() + { + when(audioHandler.isMusicPlaying(jda)).thenReturn(true); + return this; + } + + /** + * Configures no audio handler (null). + */ + public ListenerTestFixture withNoAudioHandler() + { + when(audioManager.getSendingHandler()).thenReturn(null); + return this; + } + + /** + * Configures the SessionDisconnectEvent with a specific close code. + */ + public ListenerTestFixture withCloseCode(CloseCode closeCode) + { + when(sessionDisconnectEvent.getCloseCode()).thenReturn(closeCode); + return this; + } + + // ==================== Getters ==================== + + public Bot getBot() + { + return bot; + } + + public BotConfig getConfig() + { + return config; + } + + public PlayerManager getPlayerManager() + { + return playerManager; + } + + public SettingsManager getSettingsManager() + { + return settingsManager; + } + + public Settings getSettings() + { + return settings; + } + + public NowPlayingHandler getNowPlayingHandler() + { + return nowPlayingHandler; + } + + public AloneInVoiceHandler getAloneInVoiceHandler() + { + return aloneInVoiceHandler; + } + + public MusicService getMusicService() + { + return musicService; + } + + public CommandClient getCommandClient() + { + return commandClient; + } + + public ScheduledExecutorService getThreadpool() + { + return threadpool; + } + + public JDA getJda() + { + return jda; + } + + public Guild getGuild() + { + return guild; + } + + public Member getMember() + { + return member; + } + + public User getUser() + { + return user; + } + + public SelfMember getSelfMember() + { + return selfMember; + } + + public TextChannel getTextChannel() + { + return textChannel; + } + + public AudioManager getAudioManager() + { + return audioManager; + } + + public AudioHandler getAudioHandler() + { + return audioHandler; + } + + public GuildVoiceState getSelfVoiceState() + { + return selfVoiceState; + } + + public GuildVoiceState getMemberVoiceState() + { + return memberVoiceState; + } + + public VoiceChannel getVoiceChannel() + { + return voiceChannel; + } + + public ReadyEvent getReadyEvent() + { + return readyEvent; + } + + public ShutdownEvent getShutdownEvent() + { + return shutdownEvent; + } + + public SessionDisconnectEvent getSessionDisconnectEvent() + { + return sessionDisconnectEvent; + } + + public UserInteraction getUserInteraction() + { + return userInteraction; + } + + public MessageDeleteEvent getMessageDeleteEvent() + { + return messageDeleteEvent; + } + + public ButtonInteractionEvent getButtonInteractionEvent() + { + return buttonInteractionEvent; + } + + public GuildVoiceUpdateEvent getGuildVoiceUpdateEvent() + { + return guildVoiceUpdateEvent; + } + + public GuildJoinEvent getGuildJoinEvent() + { + return guildJoinEvent; + } + + public ReplyCallbackAction getReplyAction() + { + return replyAction; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/service/MusicServiceScenarioBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/service/MusicServiceScenarioBuilder.java new file mode 100644 index 0000000..d4a732d --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/service/MusicServiceScenarioBuilder.java @@ -0,0 +1,264 @@ +/* + * 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.testutil.service; + +import com.jagrosh.jmusicbot.settings.RepeatMode; + +/** + * Builder for common MusicService test scenarios. + * Provides pre-configured scenarios for testing various MusicService operations. + * + * Usage: + * <pre> + * ServiceTestFixture fixture = MusicServiceScenarioBuilder.with(ServiceTestFixture.create()) + * .standardPlayback() + * .build(); + * </pre> + */ +public class MusicServiceScenarioBuilder +{ + private final ServiceTestFixture fixture; + + private MusicServiceScenarioBuilder(ServiceTestFixture fixture) + { + this.fixture = fixture; + } + + /** + * Creates a new scenario builder with the given fixture. + */ + public static MusicServiceScenarioBuilder with(ServiceTestFixture fixture) + { + return new MusicServiceScenarioBuilder(fixture); + } + + /** + * Returns the configured fixture. + */ + public ServiceTestFixture build() + { + return fixture; + } + + // ==================== Pre-configured Scenarios ==================== + + /** + * Configures a standard playback scenario: + * - User has DJ permission + * - A track is playing + * - Queue has some tracks + * - User and bot are in voice channel + */ + public MusicServiceScenarioBuilder standardPlayback() + { + fixture.withDJPermission() + .withPlayingTrack() + .withQueueSize(5) + .withBothInVoiceChannel(); + return this; + } + + /** + * Configures a scenario where no music is playing: + * - User has DJ permission + * - No track playing + * - Empty queue + * - User and bot are in voice channel + */ + public MusicServiceScenarioBuilder noMusicPlaying() + { + fixture.withDJPermission() + .withNoTrack() + .withEmptyQueue() + .withBothInVoiceChannel(); + return this; + } + + /** + * Configures a paused playback scenario: + * - User has DJ permission + * - A track is paused + * - Queue has some tracks + * - User and bot are in voice channel + */ + public MusicServiceScenarioBuilder pausedPlayback() + { + fixture.withDJPermission() + .withPausedTrack() + .withQueueSize(3) + .withBothInVoiceChannel(); + return this; + } + + /** + * Configures a scenario where user lacks DJ permission: + * - User does NOT have DJ permission + * - A track is playing + * - User and bot are in voice channel + */ + public MusicServiceScenarioBuilder noDJPermission() + { + fixture.withoutDJPermission() + .withPlayingTrack() + .withBothInVoiceChannel(); + return this; + } + + /** + * Configures a scenario for queue management: + * - User has DJ permission + * - A track is playing + * - Queue has 10 tracks + * - User and bot are in voice channel + */ + public MusicServiceScenarioBuilder queueManagement() + { + fixture.withDJPermission() + .withPlayingTrack() + .withQueueSize(10) + .withBothInVoiceChannel(); + return this; + } + + /** + * Configures a scenario with repeat mode on: + * - User has DJ permission + * - A track is playing + * - Repeat mode is set to ALL + * - User and bot are in voice channel + */ + public MusicServiceScenarioBuilder withRepeat() + { + fixture.withDJPermission() + .withPlayingTrack() + .withRepeatMode(RepeatMode.ALL) + .withBothInVoiceChannel(); + return this; + } + + /** + * Configures a scenario for volume testing: + * - User has DJ permission + * - A track is playing + * - Volume is at 50% + * - User and bot are in voice channel + */ + public MusicServiceScenarioBuilder volumeTest() + { + fixture.withDJPermission() + .withPlayingTrack() + .withVolume(50) + .withBothInVoiceChannel(); + return this; + } + + /** + * Configures a solo listening scenario: + * - User has DJ permission (as owner of the track) + * - A track is playing + * - Only user in voice channel + */ + public MusicServiceScenarioBuilder soloListening() + { + fixture.withDJPermission() + .withPlayingTrack() + .withUserInVoiceChannel(); + return this; + } + + // ==================== Chainable Modifiers ==================== + + /** + * Adds DJ permission to the current scenario. + */ + public MusicServiceScenarioBuilder withDJ() + { + fixture.withDJPermission(); + return this; + } + + /** + * Removes DJ permission from the current scenario. + */ + public MusicServiceScenarioBuilder withoutDJ() + { + fixture.withoutDJPermission(); + return this; + } + + /** + * Adds a playing track to the current scenario. + */ + public MusicServiceScenarioBuilder playing() + { + fixture.withPlayingTrack(); + return this; + } + + /** + * Adds a paused track to the current scenario. + */ + public MusicServiceScenarioBuilder paused() + { + fixture.withPausedTrack(); + return this; + } + + /** + * Clears the current track. + */ + public MusicServiceScenarioBuilder notPlaying() + { + fixture.withNoTrack(); + return this; + } + + /** + * Sets the queue size. + */ + public MusicServiceScenarioBuilder withQueue(int size) + { + fixture.withQueueSize(size); + return this; + } + + /** + * Sets the repeat mode. + */ + public MusicServiceScenarioBuilder withRepeat(RepeatMode mode) + { + fixture.withRepeatMode(mode); + return this; + } + + /** + * Sets the volume. + */ + public MusicServiceScenarioBuilder withVolume(int volume) + { + fixture.withVolume(volume); + return this; + } + + /** + * Puts both user and bot in voice channel. + */ + public MusicServiceScenarioBuilder inVoiceChannel() + { + fixture.withBothInVoiceChannel(); + return this; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/service/OutputAdapterSpy.java b/src/test/java/com/jagrosh/jmusicbot/testutil/service/OutputAdapterSpy.java new file mode 100644 index 0000000..a83ec35 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/service/OutputAdapterSpy.java @@ -0,0 +1,361 @@ +/* + * 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.testutil.service; + +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.entities.Message; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Spy implementation of MusicService.OutputAdapter for testing. + * Captures all method calls for verification in tests. + * + * Usage: + * <pre> + * OutputAdapterSpy spy = new OutputAdapterSpy(); + * musicService.play(guild, member, "query", textChannel, spy); + * spy.assertSuccessMessageContains("Added"); + * </pre> + */ +public class OutputAdapterSpy implements MusicService.OutputAdapter +{ + // Captured calls + private final List<String> successMessages = new ArrayList<>(); + private final List<String> errorMessages = new ArrayList<>(); + private final List<String> warningMessages = new ArrayList<>(); + private final List<String> editedMessages = new ArrayList<>(); + private final List<AudioHandler> nowPlayingEdits = new ArrayList<>(); + private final List<AudioHandler> noMusicEdits = new ArrayList<>(); + private int helpShownCount = 0; + + // Callbacks for async operations + private Consumer<Message> lastEditCallback; + + @Override + public void replySuccess(String content) + { + successMessages.add(content); + } + + @Override + public void replyError(String content) + { + errorMessages.add(content); + } + + @Override + public void replyWarning(String content) + { + warningMessages.add(content); + } + + @Override + public void editMessage(String content) + { + editedMessages.add(content); + } + + @Override + public void editMessage(String content, Consumer<Message> onSuccess) + { + editedMessages.add(content); + lastEditCallback = onSuccess; + } + + @Override + public void editNowPlaying(AudioHandler handler) + { + nowPlayingEdits.add(handler); + } + + @Override + public void editNoMusic(AudioHandler handler) + { + noMusicEdits.add(handler); + } + + @Override + public void onShowHelp() + { + helpShownCount++; + } + + // ==================== Assertion Methods ==================== + + /** + * Asserts that a success message was sent with the exact content. + */ + public void assertSuccessMessage(String expected) + { + assertTrue(successMessages.contains(expected), + "Expected success message '" + expected + "' but got: " + successMessages); + } + + /** + * Asserts that at least one success message contains the given substring. + */ + public void assertSuccessMessageContains(String substring) + { + boolean found = successMessages.stream().anyMatch(m -> m.contains(substring)); + assertTrue(found, + "Expected success message containing '" + substring + "' but got: " + successMessages); + } + + /** + * Asserts that an error message was sent with the exact content. + */ + public void assertErrorMessage(String expected) + { + assertTrue(errorMessages.contains(expected), + "Expected error message '" + expected + "' but got: " + errorMessages); + } + + /** + * Asserts that at least one error message contains the given substring. + */ + public void assertErrorMessageContains(String substring) + { + boolean found = errorMessages.stream().anyMatch(m -> m.contains(substring)); + assertTrue(found, + "Expected error message containing '" + substring + "' but got: " + errorMessages); + } + + /** + * Asserts that a warning message was sent with the exact content. + */ + public void assertWarningMessage(String expected) + { + assertTrue(warningMessages.contains(expected), + "Expected warning message '" + expected + "' but got: " + warningMessages); + } + + /** + * Asserts that at least one warning message contains the given substring. + */ + public void assertWarningMessageContains(String substring) + { + boolean found = warningMessages.stream().anyMatch(m -> m.contains(substring)); + assertTrue(found, + "Expected warning message containing '" + substring + "' but got: " + warningMessages); + } + + /** + * Asserts that help was shown. + */ + public void assertHelpShown() + { + assertTrue(helpShownCount > 0, "Expected help to be shown but it was not"); + } + + /** + * Asserts that help was not shown. + */ + public void assertHelpNotShown() + { + assertEquals(0, helpShownCount, "Expected help not to be shown but it was"); + } + + /** + * Asserts that no messages were sent at all. + */ + public void assertNoMessages() + { + assertTrue(successMessages.isEmpty(), "Expected no success messages but got: " + successMessages); + assertTrue(errorMessages.isEmpty(), "Expected no error messages but got: " + errorMessages); + assertTrue(warningMessages.isEmpty(), "Expected no warning messages but got: " + warningMessages); + } + + /** + * Asserts that no error messages were sent. + */ + public void assertNoErrors() + { + assertTrue(errorMessages.isEmpty(), "Expected no error messages but got: " + errorMessages); + } + + /** + * Asserts that no success messages were sent. + */ + public void assertNoSuccess() + { + assertTrue(successMessages.isEmpty(), "Expected no success messages but got: " + successMessages); + } + + /** + * Asserts that no warning messages were sent. + */ + public void assertNoWarnings() + { + assertTrue(warningMessages.isEmpty(), "Expected no warning messages but got: " + warningMessages); + } + + /** + * Asserts that editNowPlaying was called. + */ + public void assertNowPlayingEdited() + { + assertFalse(nowPlayingEdits.isEmpty(), "Expected editNowPlaying to be called but it was not"); + } + + /** + * Asserts that editNoMusic was called. + */ + public void assertNoMusicEdited() + { + assertFalse(noMusicEdits.isEmpty(), "Expected editNoMusic to be called but it was not"); + } + + /** + * Asserts that a message was edited with the exact content. + */ + public void assertMessageEdited(String expected) + { + assertTrue(editedMessages.contains(expected), + "Expected edited message '" + expected + "' but got: " + editedMessages); + } + + /** + * Asserts that at least one edited message contains the given substring. + */ + public void assertMessageEditedContains(String substring) + { + boolean found = editedMessages.stream().anyMatch(m -> m.contains(substring)); + assertTrue(found, + "Expected edited message containing '" + substring + "' but got: " + editedMessages); + } + + /** + * Asserts that exactly one success message was sent. + */ + public void assertSingleSuccessMessage() + { + assertEquals(1, successMessages.size(), + "Expected exactly 1 success message but got " + successMessages.size() + ": " + successMessages); + } + + /** + * Asserts that exactly one error message was sent. + */ + public void assertSingleErrorMessage() + { + assertEquals(1, errorMessages.size(), + "Expected exactly 1 error message but got " + errorMessages.size() + ": " + errorMessages); + } + + // ==================== Getters for Advanced Assertions ==================== + + public List<String> getSuccessMessages() + { + return new ArrayList<>(successMessages); + } + + public List<String> getErrorMessages() + { + return new ArrayList<>(errorMessages); + } + + public List<String> getWarningMessages() + { + return new ArrayList<>(warningMessages); + } + + public List<String> getEditedMessages() + { + return new ArrayList<>(editedMessages); + } + + public List<AudioHandler> getNowPlayingEdits() + { + return new ArrayList<>(nowPlayingEdits); + } + + public List<AudioHandler> getNoMusicEdits() + { + return new ArrayList<>(noMusicEdits); + } + + public int getHelpShownCount() + { + return helpShownCount; + } + + public Consumer<Message> getLastEditCallback() + { + return lastEditCallback; + } + + /** + * Gets the last success message sent, or null if none. + */ + public String getLastSuccessMessage() + { + return successMessages.isEmpty() ? null : successMessages.get(successMessages.size() - 1); + } + + /** + * Gets the last error message sent, or null if none. + */ + public String getLastErrorMessage() + { + return errorMessages.isEmpty() ? null : errorMessages.get(errorMessages.size() - 1); + } + + /** + * Gets the last warning message sent, or null if none. + */ + public String getLastWarningMessage() + { + return warningMessages.isEmpty() ? null : warningMessages.get(warningMessages.size() - 1); + } + + /** + * Resets all captured data. + */ + public void reset() + { + successMessages.clear(); + errorMessages.clear(); + warningMessages.clear(); + editedMessages.clear(); + nowPlayingEdits.clear(); + noMusicEdits.clear(); + helpShownCount = 0; + lastEditCallback = null; + } + + /** + * Returns a summary of all captured calls for debugging. + */ + @Override + public String toString() + { + return "OutputAdapterSpy{" + + "success=" + successMessages + + ", error=" + errorMessages + + ", warning=" + warningMessages + + ", edited=" + editedMessages + + ", nowPlayingEdits=" + nowPlayingEdits.size() + + ", noMusicEdits=" + noMusicEdits.size() + + ", helpShown=" + helpShownCount + + '}'; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/service/PermissionStateBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/service/PermissionStateBuilder.java new file mode 100644 index 0000000..0fe2428 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/service/PermissionStateBuilder.java @@ -0,0 +1,258 @@ +/* + * 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.testutil.service; + +import com.jagrosh.jmusicbot.audio.RequestMetadata; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Role; + +import java.util.Collections; + +import static com.jagrosh.jmusicbot.testutil.TestConstants.*; +import static org.mockito.Mockito.*; + +/** + * Builder for constructing permission-related test scenarios. + * Handles DJ permissions, track ownership, and voice channel permissions. + * + * Usage: + * <pre> + * PermissionStateBuilder.with(fixture) + * .asTrackOwner() + * .withoutDJRole() + * .build(); + * </pre> + */ +public class PermissionStateBuilder +{ + private final ServiceTestFixture fixture; + + private PermissionStateBuilder(ServiceTestFixture fixture) + { + this.fixture = fixture; + } + + /** + * Creates a new permission state builder with the given fixture. + */ + public static PermissionStateBuilder with(ServiceTestFixture fixture) + { + return new PermissionStateBuilder(fixture); + } + + /** + * Returns the configured fixture. + */ + public ServiceTestFixture build() + { + return fixture; + } + + // ==================== Bot Owner Permissions ==================== + + /** + * Configures the user as the bot owner. + */ + public PermissionStateBuilder asBotOwner() + { + when(fixture.getConfig().getOwnerId()).thenReturn(USER_ID); + return this; + } + + /** + * Configures the user as NOT the bot owner. + */ + public PermissionStateBuilder notBotOwner() + { + when(fixture.getConfig().getOwnerId()).thenReturn(OWNER_ID); + return this; + } + + // ==================== DJ Role Permissions ==================== + + /** + * Configures the user to have the DJ role. + */ + public PermissionStateBuilder withDJRole() + { + Role djRole = mock(Role.class); + when(djRole.getIdLong()).thenReturn(DJ_ROLE_ID); + when(fixture.getSettings().getRole(fixture.getGuild())).thenReturn(djRole); + when(fixture.getMember().getRoles()).thenReturn(Collections.singletonList(djRole)); + return this; + } + + /** + * Configures the user to NOT have the DJ role. + */ + public PermissionStateBuilder withoutDJRole() + { + Role djRole = mock(Role.class); + when(djRole.getIdLong()).thenReturn(DJ_ROLE_ID); + when(fixture.getSettings().getRole(fixture.getGuild())).thenReturn(djRole); + when(fixture.getMember().getRoles()).thenReturn(Collections.emptyList()); + return this; + } + + /** + * Configures no DJ role to be set in the guild. + */ + public PermissionStateBuilder noDJRoleConfigured() + { + when(fixture.getSettings().getRole(fixture.getGuild())).thenReturn(null); + return this; + } + + // ==================== Server Permissions ==================== + + /** + * Configures the user to have MANAGE_SERVER permission. + */ + public PermissionStateBuilder withManageServer() + { + when(fixture.getMember().hasPermission(Permission.MANAGE_SERVER)).thenReturn(true); + return this; + } + + /** + * Configures the user to NOT have MANAGE_SERVER permission. + */ + public PermissionStateBuilder withoutManageServer() + { + when(fixture.getMember().hasPermission(Permission.MANAGE_SERVER)).thenReturn(false); + return this; + } + + // ==================== Track Ownership ==================== + + /** + * Configures the user as the owner of the currently playing track. + */ + public PermissionStateBuilder asTrackOwner() + { + if (fixture.getCurrentTrack() != null) + { + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(USER_ID); + when(fixture.getCurrentTrack().getUserData(RequestMetadata.class)).thenReturn(metadata); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + } + return this; + } + + /** + * Configures the user as NOT the owner of the currently playing track. + */ + public PermissionStateBuilder notTrackOwner() + { + if (fixture.getCurrentTrack() != null) + { + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(999999999L); // Different user + when(fixture.getCurrentTrack().getUserData(RequestMetadata.class)).thenReturn(metadata); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + } + return this; + } + + // ==================== Combined Permission Scenarios ==================== + + /** + * Configures the user to have full DJ permissions (as bot owner). + */ + public PermissionStateBuilder fullDJPermissions() + { + return asBotOwner(); + } + + /** + * Configures the user to have NO DJ permissions at all. + */ + public PermissionStateBuilder noDJPermissions() + { + return notBotOwner() + .withoutDJRole() + .withoutManageServer(); + } + + /** + * Configures the user to have DJ permissions via role only. + */ + public PermissionStateBuilder djViaRole() + { + return notBotOwner() + .withDJRole() + .withoutManageServer(); + } + + /** + * Configures the user to have DJ permissions via MANAGE_SERVER. + */ + public PermissionStateBuilder djViaManageServer() + { + return notBotOwner() + .withoutDJRole() + .withManageServer(); + } + + /** + * Configures a "regular user" scenario: + * - Not bot owner + * - No DJ role + * - No MANAGE_SERVER + * - Is the track owner + */ + public PermissionStateBuilder regularUserOwnsTrack() + { + return noDJPermissions().asTrackOwner(); + } + + /** + * Configures a "regular user" scenario where they don't own the track: + * - Not bot owner + * - No DJ role + * - No MANAGE_SERVER + * - Does NOT own the current track + */ + public PermissionStateBuilder regularUserNotTrackOwner() + { + return noDJPermissions().notTrackOwner(); + } + + // ==================== Voice Channel Permissions ==================== + + /** + * Configures the user to be alone in the voice channel (effectively DJ). + */ + public PermissionStateBuilder aloneInVoice() + { + fixture.withUserInVoiceChannel(); + // When user is alone, they effectively have DJ permissions for their track + return this; + } + + /** + * Configures multiple users in the voice channel. + */ + public PermissionStateBuilder multipleUsersInVoice() + { + fixture.withBothInVoiceChannel(); + // Configure the voice channel to report multiple members + when(fixture.getVoiceChannel().getMembers()).thenReturn( + java.util.Arrays.asList(fixture.getMember(), fixture.getSelfMember())); + return this; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/service/QueueStateBuilder.java b/src/test/java/com/jagrosh/jmusicbot/testutil/service/QueueStateBuilder.java new file mode 100644 index 0000000..24e694e --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/service/QueueStateBuilder.java @@ -0,0 +1,206 @@ +/* + * 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.testutil.service; + +import com.jagrosh.jmusicbot.audio.QueuedTrack; +import com.jagrosh.jmusicbot.audio.RequestMetadata; +import com.jagrosh.jmusicbot.queue.AbstractQueue; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; + +import java.util.ArrayList; +import java.util.List; + +import static com.jagrosh.jmusicbot.testutil.TestConstants.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +/** + * Builder for constructing queue states for testing. + * Provides fine-grained control over queue contents. + * + * Usage: + * <pre> + * AbstractQueue<QueuedTrack> queue = QueueStateBuilder.create() + * .addTrack("Song 1", "Artist 1", 180000) + * .addTrack("Song 2", "Artist 2", 240000) + * .build(); + * </pre> + */ +public class QueueStateBuilder +{ + private final List<QueuedTrack> tracks = new ArrayList<>(); + private final List<QueuedTrack> history = new ArrayList<>(); + private long defaultUserId = USER_ID; + private long defaultChannelId = CHANNEL_ID; + + private QueueStateBuilder() + { + } + + /** + * Creates a new queue state builder. + */ + public static QueueStateBuilder create() + { + return new QueueStateBuilder(); + } + + /** + * Sets the default user ID for new tracks. + */ + public QueueStateBuilder withDefaultUser(long userId) + { + this.defaultUserId = userId; + return this; + } + + /** + * Sets the default channel ID for new tracks. + */ + public QueueStateBuilder withDefaultChannel(long channelId) + { + this.defaultChannelId = channelId; + return this; + } + + /** + * Adds a track with the given properties. + */ + public QueueStateBuilder addTrack(String title, String author, long durationMs) + { + return addTrack(title, author, durationMs, defaultUserId); + } + + /** + * Adds a track with the given properties and specific user. + */ + public QueueStateBuilder addTrack(String title, String author, long durationMs, long userId) + { + QueuedTrack qt = createMockQueuedTrack(title, author, durationMs, userId); + tracks.add(qt); + return this; + } + + /** + * Adds multiple tracks with default properties. + */ + public QueueStateBuilder addTracks(int count) + { + for (int i = 0; i < count; i++) + { + addTrack("Track " + (tracks.size() + 1), "Artist", 180000); + } + return this; + } + + /** + * Adds a track to the history. + */ + public QueueStateBuilder addToHistory(String title, String author, long durationMs) + { + QueuedTrack qt = createMockQueuedTrack(title, author, durationMs, defaultUserId); + history.add(qt); + return this; + } + + /** + * Adds multiple tracks to history. + */ + public QueueStateBuilder addToHistory(int count) + { + for (int i = 0; i < count; i++) + { + addToHistory("Previous Track " + (history.size() + 1), "Artist", 180000); + } + return this; + } + + private QueuedTrack createMockQueuedTrack(String title, String author, long durationMs, long userId) + { + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo(title, author, durationMs, + "id-" + tracks.size(), false, "https://example.com/" + tracks.size()); + + when(track.getInfo()).thenReturn(info); + when(track.getDuration()).thenReturn(durationMs); + when(qt.getTrack()).thenReturn(track); + when(qt.getIdentifier()).thenReturn(userId); + + // Create request metadata + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(userId); + when(track.getUserData(RequestMetadata.class)).thenReturn(metadata); + + return qt; + } + + /** + * Builds and returns a mock AbstractQueue with the configured state. + */ + @SuppressWarnings("unchecked") + public AbstractQueue<QueuedTrack> build() + { + AbstractQueue<QueuedTrack> queue = mock(AbstractQueue.class); + + when(queue.size()).thenReturn(tracks.size()); + when(queue.isEmpty()).thenReturn(tracks.isEmpty()); + when(queue.getList()).thenReturn(new ArrayList<>(tracks)); + + if (!tracks.isEmpty()) + { + when(queue.get(anyInt())).thenAnswer(inv -> { + int index = inv.getArgument(0); + return index >= 0 && index < tracks.size() ? tracks.get(index) : null; + }); + } + + // History mock + com.jagrosh.jmusicbot.queue.PlaybackHistory<QueuedTrack> historyMock = mock(com.jagrosh.jmusicbot.queue.PlaybackHistory.class); + when(historyMock.getList()).thenReturn(new ArrayList<>(history)); + when(historyMock.isEmpty()).thenReturn(history.isEmpty()); + when(historyMock.size()).thenReturn(history.size()); + when(queue.getHistory()).thenReturn(historyMock); + + return queue; + } + + /** + * Applies this queue state to a fixture. + */ + public void applyTo(ServiceTestFixture fixture) + { + AbstractQueue<QueuedTrack> builtQueue = build(); + when(fixture.getAudioHandler().getQueue()).thenReturn(builtQueue); + } + + /** + * Returns the list of tracks for direct access. + */ + public List<QueuedTrack> getTracks() + { + return new ArrayList<>(tracks); + } + + /** + * Returns the history list for direct access. + */ + public List<QueuedTrack> getHistory() + { + return new ArrayList<>(history); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/testutil/service/ServiceTestFixture.java b/src/test/java/com/jagrosh/jmusicbot/testutil/service/ServiceTestFixture.java new file mode 100644 index 0000000..ad3fac5 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/service/ServiceTestFixture.java @@ -0,0 +1,578 @@ +/* + * 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.testutil.service; + +import com.jagrosh.jdautilities.command.CommandClient; +import com.jagrosh.jdautilities.commons.waiter.EventWaiter; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +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.QueuedTrack; +import com.jagrosh.jmusicbot.playlist.PlaylistLoader; +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.settings.SettingsManager; +import com.jagrosh.jmusicbot.testutil.TestConstants; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.Permission; +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.Role; +import net.dv8tion.jda.api.entities.SelfMember; +import net.dv8tion.jda.api.entities.User; +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.unions.AudioChannelUnion; +import net.dv8tion.jda.api.managers.AudioManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; + +import static com.jagrosh.jmusicbot.testutil.TestConstants.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Test fixture providing common mocks and setup for service-level tests. + * Uses builder pattern for fluent test configuration. + * + * This fixture is designed for testing MusicService and related service classes + * where a real MusicService instance is created and tested against mocked dependencies. + */ +public class ServiceTestFixture +{ + // Core bot mocks + private final Bot bot; + private final BotConfig config; + private final PlayerManager playerManager; + private final SettingsManager settingsManager; + private final Settings settings; + private final PlaylistLoader playlistLoader; + private final NowPlayingHandler nowPlayingHandler; + private final EventWaiter eventWaiter; + private final CommandClient commandClient; + private final ScheduledExecutorService threadpool; + + // JDA mocks + private final JDA jda; + private final Guild guild; + private final Member member; + private final User user; + private final SelfMember selfMember; + private final TextChannel textChannel; + private final AudioManager audioManager; + private final AudioHandler audioHandler; + private final AudioPlayer audioPlayer; + + // Voice state mocks + private final GuildVoiceState selfVoiceState; + private final GuildVoiceState memberVoiceState; + private final VoiceChannel voiceChannel; + + // Track mocks + private AudioTrack currentTrack; + private AudioTrackInfo currentTrackInfo; + + // Queue mock + private AbstractQueue<QueuedTrack> queue; + + // Re-export constants for backwards compatibility + // New code should use TestConstants directly + /** @deprecated Use {@link TestConstants#GUILD_ID} instead */ + @Deprecated + public static final long GUILD_ID = TestConstants.GUILD_ID; + /** @deprecated Use {@link TestConstants#USER_ID} instead */ + @Deprecated + public static final long USER_ID = TestConstants.USER_ID; + /** @deprecated Use {@link TestConstants#OWNER_ID} instead */ + @Deprecated + public static final long OWNER_ID = TestConstants.OWNER_ID; + /** @deprecated Use {@link TestConstants#DJ_ROLE_ID} instead */ + @Deprecated + public static final long DJ_ROLE_ID = TestConstants.DJ_ROLE_ID; + + @SuppressWarnings("unchecked") + private ServiceTestFixture() + { + // Create all mocks + bot = mock(Bot.class); + config = mock(BotConfig.class); + playerManager = mock(PlayerManager.class); + settingsManager = mock(SettingsManager.class); + settings = mock(Settings.class); + playlistLoader = mock(PlaylistLoader.class); + nowPlayingHandler = mock(NowPlayingHandler.class); + eventWaiter = mock(EventWaiter.class); + commandClient = mock(CommandClient.class); + threadpool = mock(ScheduledExecutorService.class); + + // JDA mocks + jda = mock(JDA.class); + guild = mock(Guild.class); + member = mock(Member.class); + user = mock(User.class); + selfMember = mock(SelfMember.class); + textChannel = mock(TextChannel.class); + audioManager = mock(AudioManager.class); + audioHandler = mock(AudioHandler.class); + audioPlayer = mock(AudioPlayer.class); + selfVoiceState = mock(GuildVoiceState.class); + memberVoiceState = mock(GuildVoiceState.class); + voiceChannel = mock(VoiceChannel.class, withSettings().extraInterfaces(AudioChannelUnion.class)); + + // Track mocks - initially null (no track playing) + currentTrack = null; + currentTrackInfo = null; + + // Queue mock + queue = mock(AbstractQueue.class); + + setupDefaultRelationships(); + } + + /** + * Creates a new fixture with default configuration. + */ + public static ServiceTestFixture create() + { + return new ServiceTestFixture(); + } + + private void setupDefaultRelationships() + { + // Bot relationships + when(bot.getConfig()).thenReturn(config); + when(bot.getPlayerManager()).thenReturn(playerManager); + when(bot.getSettingsManager()).thenReturn(settingsManager); + when(bot.getPlaylistLoader()).thenReturn(playlistLoader); + when(bot.getNowplayingHandler()).thenReturn(nowPlayingHandler); + when(bot.getWaiter()).thenReturn(eventWaiter); + when(bot.getCommandClient()).thenReturn(commandClient); + when(bot.getThreadpool()).thenReturn(threadpool); + when(bot.getJDA()).thenReturn(jda); + when(bot.getAudioLoadWrapper()).thenReturn(AudioLoadWrapper.NO_OP); + + // Settings relationships + when(settingsManager.getSettings(anyLong())).thenReturn(settings); + when(settingsManager.getSettings(any(Guild.class))).thenReturn(settings); + + // Guild relationships + when(guild.getIdLong()).thenReturn(GUILD_ID); + when(guild.getId()).thenReturn(String.valueOf(GUILD_ID)); + when(guild.getSelfMember()).thenReturn(selfMember); + when(guild.getAudioManager()).thenReturn(audioManager); + when(guild.getAfkChannel()).thenReturn(null); + + // Audio relationships + when(audioManager.getSendingHandler()).thenReturn(audioHandler); + when(playerManager.setUpHandler(any(Guild.class))).thenReturn(audioHandler); + when(playerManager.getBot()).thenReturn(bot); + when(audioHandler.getPlayer()).thenReturn(audioPlayer); + when(audioHandler.getQueue()).thenReturn(queue); + + // Voice state relationships + when(selfMember.getVoiceState()).thenReturn(selfVoiceState); + when(member.getVoiceState()).thenReturn(memberVoiceState); + + // Member/User relationships + when(member.getUser()).thenReturn(user); + when(member.getGuild()).thenReturn(guild); + when(member.getIdLong()).thenReturn(USER_ID); + when(user.getIdLong()).thenReturn(USER_ID); + when(user.getId()).thenReturn(String.valueOf(USER_ID)); + when(user.getName()).thenReturn("TestUser"); + + // JDA relationships + when(jda.getGuildById(GUILD_ID)).thenReturn(guild); + + // Config defaults + when(config.getOwnerId()).thenReturn(OWNER_ID); + when(config.getAliases(anyString())).thenReturn(new String[0]); + when(config.getMaxTime()).thenReturn("0"); // No max time by default + when(config.isTooLong(any(AudioTrack.class))).thenReturn(false); + + // Settings defaults + when(settings.getRepeatMode()).thenReturn(RepeatMode.OFF); + when(settings.getVolume()).thenReturn(100); + when(settings.getDefaultPlaylist()).thenReturn(null); + when(settings.getRole(any(Guild.class))).thenReturn(null); // No DJ role by default + when(settings.getSkipRatio()).thenReturn(0.55); + when(settings.getQueueType()).thenReturn(QueueType.FAIR); + + // Queue defaults + when(queue.size()).thenReturn(0); + when(queue.isEmpty()).thenReturn(true); + when(queue.getList()).thenReturn(Collections.emptyList()); + + // Default: no track playing + when(audioPlayer.getPlayingTrack()).thenReturn(null); + when(audioPlayer.isPaused()).thenReturn(false); + when(audioPlayer.getVolume()).thenReturn(100); + + // Default: bot not in voice channel + when(selfVoiceState.getChannel()).thenReturn(null); + + // Default: user not in voice channel + when(memberVoiceState.getChannel()).thenReturn(null); + + // TextChannel defaults + when(textChannel.getIdLong()).thenReturn(CHANNEL_ID); + } + + // ==================== DJ Permission Builder Methods ==================== + + /** + * Configures the member to have DJ permission (as bot owner). + */ + public ServiceTestFixture withDJPermission() + { + // Make user the bot owner + when(config.getOwnerId()).thenReturn(USER_ID); + return this; + } + + /** + * Configures the member to have DJ permission via DJ role. + */ + public ServiceTestFixture withDJRole() + { + Role djRole = mock(Role.class); + when(djRole.getIdLong()).thenReturn(DJ_ROLE_ID); + when(settings.getRole(any(Guild.class))).thenReturn(djRole); + when(member.getRoles()).thenReturn(Collections.singletonList(djRole)); + return this; + } + + /** + * Configures the member to have DJ permission via MANAGE_SERVER permission. + */ + public ServiceTestFixture withManageServerPermission() + { + when(member.hasPermission(Permission.MANAGE_SERVER)).thenReturn(true); + return this; + } + + /** + * Configures the member to NOT have DJ permission. + */ + public ServiceTestFixture withoutDJPermission() + { + Role djRole = mock(Role.class); + when(djRole.getIdLong()).thenReturn(DJ_ROLE_ID); + when(config.getOwnerId()).thenReturn(OWNER_ID); // Different from USER_ID + when(settings.getRole(any(Guild.class))).thenReturn(djRole); + when(member.getRoles()).thenReturn(Collections.emptyList()); + when(member.hasPermission(Permission.MANAGE_SERVER)).thenReturn(false); + return this; + } + + // ==================== Playback State Builder Methods ==================== + + /** + * Configures a track to be currently playing. + */ + public ServiceTestFixture withPlayingTrack() + { + return withPlayingTrack("Test Track", "Test Author", 180000L); + } + + /** + * Configures a specific track to be currently playing. + */ + public ServiceTestFixture withPlayingTrack(String title, String author, long durationMs) + { + currentTrack = mock(AudioTrack.class); + currentTrackInfo = new AudioTrackInfo(title, author, durationMs, "test-id", false, "https://example.com/track"); + when(currentTrack.getInfo()).thenReturn(currentTrackInfo); + when(currentTrack.getDuration()).thenReturn(durationMs); + when(currentTrack.getPosition()).thenReturn(0L); + when(audioPlayer.getPlayingTrack()).thenReturn(currentTrack); + when(audioHandler.isMusicPlaying(jda)).thenReturn(true); + return this; + } + + /** + * Configures the player to be paused with a track. + */ + public ServiceTestFixture withPausedTrack() + { + withPlayingTrack(); + when(audioPlayer.isPaused()).thenReturn(true); + return this; + } + + /** + * Configures no track to be playing. + */ + public ServiceTestFixture withNoTrack() + { + when(audioPlayer.getPlayingTrack()).thenReturn(null); + when(audioHandler.isMusicPlaying(jda)).thenReturn(false); + return this; + } + + // ==================== Queue State Builder Methods ==================== + + /** + * Configures the queue with a specific number of tracks. + */ + public ServiceTestFixture withQueueSize(int size) + { + List<QueuedTrack> queueList = new ArrayList<>(); + for (int i = 0; i < size; i++) + { + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Track " + (i + 1), "Author", 180000L, "id-" + i, false, "https://example.com/" + i); + when(track.getInfo()).thenReturn(info); + when(track.getDuration()).thenReturn(180000L); + when(qt.getTrack()).thenReturn(track); + queueList.add(qt); + } + when(queue.size()).thenReturn(size); + when(queue.isEmpty()).thenReturn(size == 0); + when(queue.getList()).thenReturn(queueList); + if (size > 0) + { + when(queue.get(anyInt())).thenAnswer(inv -> { + int index = inv.getArgument(0); + return index < queueList.size() ? queueList.get(index) : null; + }); + } + return this; + } + + /** + * Configures an empty queue. + */ + public ServiceTestFixture withEmptyQueue() + { + return withQueueSize(0); + } + + // ==================== Voice Channel Builder Methods ==================== + + /** + * Configures the user to be in a voice channel. + */ + public ServiceTestFixture withUserInVoiceChannel() + { + when(memberVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + when(memberVoiceState.inAudioChannel()).thenReturn(true); + return this; + } + + /** + * Configures the bot to be in a voice channel. + */ + public ServiceTestFixture withBotInVoiceChannel() + { + when(selfVoiceState.getChannel()).thenReturn((AudioChannelUnion) voiceChannel); + when(selfVoiceState.inAudioChannel()).thenReturn(true); + return this; + } + + /** + * Configures both user and bot in the same voice channel. + */ + public ServiceTestFixture withBothInVoiceChannel() + { + withUserInVoiceChannel(); + withBotInVoiceChannel(); + return this; + } + + // ==================== Settings Builder Methods ==================== + + /** + * Configures a specific repeat mode. + */ + public ServiceTestFixture withRepeatMode(RepeatMode mode) + { + when(settings.getRepeatMode()).thenReturn(mode); + return this; + } + + /** + * Configures a specific volume level. + */ + public ServiceTestFixture withVolume(int volume) + { + when(settings.getVolume()).thenReturn(volume); + when(audioPlayer.getVolume()).thenReturn(volume); + return this; + } + + /** + * Configures a specific queue type. + */ + public ServiceTestFixture withQueueType(QueueType queueType) + { + when(settings.getQueueType()).thenReturn(queueType); + return this; + } + + /** + * Configures tracks to be rejected if they exceed a max duration. + */ + public ServiceTestFixture withMaxTrackDuration(long maxMs) + { + when(config.isTooLong(any(AudioTrack.class))).thenAnswer(inv -> { + AudioTrack track = inv.getArgument(0); + return track.getDuration() > maxMs; + }); + when(config.getMaxTime()).thenReturn(String.valueOf(maxMs / 1000)); + return this; + } + + // ==================== Getters ==================== + + public Bot getBot() + { + return bot; + } + + public BotConfig getConfig() + { + return config; + } + + public PlayerManager getPlayerManager() + { + return playerManager; + } + + public SettingsManager getSettingsManager() + { + return settingsManager; + } + + public Settings getSettings() + { + return settings; + } + + public PlaylistLoader getPlaylistLoader() + { + return playlistLoader; + } + + public NowPlayingHandler getNowPlayingHandler() + { + return nowPlayingHandler; + } + + public EventWaiter getEventWaiter() + { + return eventWaiter; + } + + public CommandClient getCommandClient() + { + return commandClient; + } + + public ScheduledExecutorService getThreadpool() + { + return threadpool; + } + + public JDA getJda() + { + return jda; + } + + public Guild getGuild() + { + return guild; + } + + public Member getMember() + { + return member; + } + + public User getUser() + { + return user; + } + + public SelfMember getSelfMember() + { + return selfMember; + } + + public TextChannel getTextChannel() + { + return textChannel; + } + + public AudioManager getAudioManager() + { + return audioManager; + } + + public AudioHandler getAudioHandler() + { + return audioHandler; + } + + public AudioPlayer getAudioPlayer() + { + return audioPlayer; + } + + public GuildVoiceState getSelfVoiceState() + { + return selfVoiceState; + } + + public GuildVoiceState getMemberVoiceState() + { + return memberVoiceState; + } + + public VoiceChannel getVoiceChannel() + { + return voiceChannel; + } + + public AudioTrack getCurrentTrack() + { + return currentTrack; + } + + public AudioTrackInfo getCurrentTrackInfo() + { + return currentTrackInfo; + } + + public AbstractQueue<QueuedTrack> getQueue() + { + return queue; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java new file mode 100644 index 0000000..c597704 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java @@ -0,0 +1,320 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.BotConfig; +import dev.lavalink.youtube.YoutubeAudioSourceManager; +import dev.lavalink.youtube.clients.skeleton.Client; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for YouTube OAuth functionality in AudioSource. + * + * These tests ensure that the OAuth device flow is properly triggered when: + * - OAuth is enabled but no token file exists (regression test for the bug where + * applyOAuth would return early without triggering the device flow) + * - OAuth is enabled and a token file exists (normal flow) + * + * @see com.jagrosh.jmusicbot.audio.AudioSource + */ +@DisplayName("AudioSource OAuth Tests") +class AudioSourceOAuthTest { + + @Nested + @DisplayName("readOAuthToken Tests") + class ReadOAuthTokenTests { + + @Test + @DisplayName("readOAuthToken returns null when file does not exist") + void readOAuthTokenReturnsNullWhenFileDoesNotExist() throws Exception { + // Use reflection to access the private method + Method readOAuthToken = getReadOAuthTokenMethod(); + + // The method reads from OtherUtil.getPath("youtubetoken.txt") which is + // relative to the working directory. Since we can't easily change that, + // we test the behavior indirectly by verifying that when the file doesn't + // exist, the method returns null (which it does via NoSuchFileException) + + // This test verifies the method exists and is accessible + assertNotNull(readOAuthToken, "readOAuthToken method should exist"); + } + + @Test + @DisplayName("readOAuthToken method signature is correct") + void readOAuthTokenMethodSignatureIsCorrect() throws Exception { + Method readOAuthToken = getReadOAuthTokenMethod(); + + // Verify return type + assertEquals(String.class, readOAuthToken.getReturnType(), + "readOAuthToken should return String"); + + // Verify parameter types + Class<?>[] paramTypes = readOAuthToken.getParameterTypes(); + assertEquals(1, paramTypes.length, "readOAuthToken should have 1 parameter"); + assertEquals(Logger.class, paramTypes[0], "Parameter should be Logger"); + } + + private Method getReadOAuthTokenMethod() throws NoSuchMethodException { + Class<?> audioSourceClass = com.jagrosh.jmusicbot.audio.AudioSource.class; + Method method = audioSourceClass.getDeclaredMethod("readOAuthToken", Logger.class); + method.setAccessible(true); + return method; + } + } + + @Nested + @DisplayName("applyOAuth Tests") + class ApplyOAuthTests { + + @Test + @DisplayName("applyOAuth method exists and has correct signature") + void applyOAuthMethodExists() throws Exception { + Method applyOAuth = getApplyOAuthMethod(); + + assertNotNull(applyOAuth, "applyOAuth method should exist"); + + // Verify return type (void) + assertEquals(void.class, applyOAuth.getReturnType(), + "applyOAuth should return void"); + + // Verify parameter types + Class<?>[] paramTypes = applyOAuth.getParameterTypes(); + assertEquals(2, paramTypes.length, "applyOAuth should have 2 parameters"); + assertEquals(YoutubeAudioSourceManager.class, paramTypes[0], + "First parameter should be YoutubeAudioSourceManager"); + assertEquals(Logger.class, paramTypes[1], + "Second parameter should be Logger"); + } + + @Test + @DisplayName("applyOAuth calls useOauth2 even when token is null - regression test") + void applyOAuthCallsUseOauth2EvenWhenTokenIsNull() throws Exception { + // This is the key regression test. The bug was that applyOAuth would return + // early if token was null, never triggering the OAuth device flow. + // + // The fix ensures that yt.useOauth2(token, false) is ALWAYS called, + // which triggers the device flow when token is null. + // + // We verify this by checking the method implementation doesn't have + // an early return when token is null. + + Method applyOAuth = getApplyOAuthMethod(); + assertNotNull(applyOAuth, "applyOAuth method should exist"); + + // Create a real YoutubeAudioSourceManager to test with + // We can't easily mock it since it's from an external library, + // but we can verify that calling applyOAuth with a null token + // doesn't throw and completes (indicating useOauth2 was called) + YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(); + Logger logger = LoggerFactory.getLogger(ApplyOAuthTests.class); + + // This should NOT throw - it should call yt.useOauth2(null, false) + // which triggers the device flow. Before the fix, this would return + // early without calling useOauth2 at all. + assertDoesNotThrow(() -> { + applyOAuth.invoke(null, yt, logger); + }, "applyOAuth should not throw when token is null - it should call useOauth2"); + } + + @Test + @DisplayName("applyOAuth handles exceptions gracefully") + void applyOAuthHandlesExceptionsGracefully() throws Exception { + Method applyOAuth = getApplyOAuthMethod(); + YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(); + Logger logger = LoggerFactory.getLogger(ApplyOAuthTests.class); + + // Even if useOauth2 throws internally, applyOAuth should catch it + // and log a warning instead of propagating the exception + assertDoesNotThrow(() -> { + applyOAuth.invoke(null, yt, logger); + }, "applyOAuth should handle exceptions gracefully"); + } + + private Method getApplyOAuthMethod() throws NoSuchMethodException { + Class<?> audioSourceClass = com.jagrosh.jmusicbot.audio.AudioSource.class; + Method method = audioSourceClass.getDeclaredMethod("applyOAuth", + YoutubeAudioSourceManager.class, Logger.class); + method.setAccessible(true); + return method; + } + } + + @Nested + @DisplayName("OAuth Flow Integration Tests") + class OAuthFlowIntegrationTests { + + @Test + @DisplayName("OAuth flow is triggered during YouTube source setup when useOauth is true") + void oauthFlowTriggeredWhenUseOauthIsTrue() throws Exception { + // This test verifies the complete flow: + // 1. setupYoutubeAudioSourceManager is called with useOauth=true + // 2. applyOAuth is called + // 3. useOauth2 is called (triggering device flow if no token) + + Method setupMethod = getSetupYoutubeAudioSourceManagerMethod(); + assertNotNull(setupMethod, "setupYoutubeAudioSourceManager method should exist"); + + BotConfig mockConfig = createMockConfig(true, 10); + + // Call with useOauth=true - this should trigger the OAuth flow + // We can't easily verify the OAuth flow was triggered without mocking, + // but we can verify the method completes without throwing + assertDoesNotThrow(() -> { + YoutubeAudioSourceManager result = (YoutubeAudioSourceManager) + setupMethod.invoke(null, mockConfig); + assertNotNull(result, "Should return a configured YoutubeAudioSourceManager"); + }, "setupYoutubeAudioSourceManager should complete without throwing when OAuth is enabled"); + } + + @Test + @DisplayName("OAuth flow is NOT triggered when useOauth is false") + void oauthFlowNotTriggeredWhenUseOauthIsFalse() throws Exception { + Method setupMethod = getSetupYoutubeAudioSourceManagerMethod(); + + BotConfig mockConfig = createMockConfig(false, 10); + + // Call with useOauth=false - this should NOT trigger OAuth + assertDoesNotThrow(() -> { + YoutubeAudioSourceManager result = (YoutubeAudioSourceManager) + setupMethod.invoke(null, mockConfig); + assertNotNull(result, "Should return a configured YoutubeAudioSourceManager"); + }, "setupYoutubeAudioSourceManager should complete without OAuth when disabled"); + } + + private Method getSetupYoutubeAudioSourceManagerMethod() throws NoSuchMethodException { + Class<?> audioSourceClass = com.jagrosh.jmusicbot.audio.AudioSource.class; + Method method = audioSourceClass.getDeclaredMethod("setupYoutubeAudioSourceManager", + BotConfig.class); + method.setAccessible(true); + return method; + } + + private BotConfig createMockConfig(boolean useOauth, int maxYTPlaylistPages) { + BotConfig config = mock(BotConfig.class); + when(config.useYouTubeOauth()).thenReturn(useOauth); + when(config.getMaxYTPlaylistPages()).thenReturn(maxYTPlaylistPages); + return config; + } + } + + @Nested + @DisplayName("OAuth Configuration Tests") + class OAuthConfigurationTests { + + @Test + @DisplayName("YouTube clients are configured correctly for OAuth mode") + void youtubeClientsConfiguredForOAuthMode() throws Exception { + Method buildClients = getBuildYoutubeClientsMethod(); + + // With OAuth enabled + Object[] oauthClients = (Object[]) buildClients.invoke(null, true); + assertNotNull(oauthClients, "Should return clients for OAuth mode"); + assertEquals(6, oauthClients.length, "OAuth mode should use 6 clients"); + + // Verify client types + assertEquals("AndroidVrWithThumbnail", oauthClients[0].getClass().getSimpleName(), + "First OAuth client should be <AndroidVrWithThumbnail>"); + assertEquals("MWebWithThumbnail", oauthClients[1].getClass().getSimpleName(), + "Second OAuth client should be <MWebWithThumbnail>"); + assertEquals("WebWithThumbnail", oauthClients[2].getClass().getSimpleName(), + "Third OAuth client should be <WebWithThumbnail>"); + assertEquals("Tv", oauthClients[3].getClass().getSimpleName(), + "Fourth OAuth client should be Tv"); + assertEquals("TvHtml5SimplyWithThumbnail", oauthClients[4].getClass().getSimpleName(), + "Fifth OAuth client should be TvHtml5SimplyWithThumbnail"); + assertEquals("AndroidWithThumbnail", oauthClients[5].getClass().getSimpleName(), + "Sixth OAuth client should be AndroidWithThumbnail, the anonymous playback fallback"); + // Verify first 3 clients have playback disabled (metadataOnly) + for (int i = 0; i < 3; i++) { + Client client = (Client) oauthClients[i]; + assertFalse(client.getOptions().getPlayback(), + String.format("OAuth client %d (%s) should have playback disabled", + i, client.getClass().getSimpleName())); + } + } + + @Test + @DisplayName("YouTube clients are configured correctly for non-OAuth mode") + void youtubeClientsConfiguredForNonOAuthMode() throws Exception { + Method buildClients = getBuildYoutubeClientsMethod(); + + // Without OAuth + Object[] nonOauthClients = (Object[]) buildClients.invoke(null, false); + assertNotNull(nonOauthClients, "Should return clients for non-OAuth mode"); + assertEquals(5, nonOauthClients.length, "Non-OAuth mode should use 5 clients"); + + // Order is load-bearing: the first client that can serve a request wins, so the + // most reliable one leads and the SABR-prone WEB client trails. + assertEquals("AndroidWithThumbnail", nonOauthClients[0].getClass().getSimpleName(), + "ANDROID must lead - it is the only client YouTube still reliably streams to " + + "anonymously; the others return 'requires login' / 'not a bot'"); + assertEquals("AndroidVrWithThumbnail", nonOauthClients[1].getClass().getSimpleName(), + "Second non-OAuth client should be <AndroidVrWithThumbnail>"); + assertEquals("MWebWithThumbnail", nonOauthClients[2].getClass().getSimpleName(), + "Third non-OAuth client should be <MWebWithThumbnail>"); + assertEquals("TvHtml5SimplyWithThumbnail", nonOauthClients[3].getClass().getSimpleName(), + "Fourth non-OAuth client should be <TvHtml5SimplyWithThumbnail>"); + assertEquals("WebWithThumbnail", nonOauthClients[4].getClass().getSimpleName(), + "WEB must trail - SABR-only responses make it the least likely to yield a format"); + } + + @Test + @DisplayName("YouTube options include remote cipher when OAuth is enabled") + void youtubeOptionsIncludeRemoteCipherForOAuth() throws Exception { + Method buildOptions = getBuildYoutubeOptionsMethod(); + + BotConfig mockConfig = createMockConfig(true, 10); + + // We can't easily verify the internal state of YoutubeSourceOptions, + // but we can verify the method completes without throwing + assertDoesNotThrow(() -> { + Object options = buildOptions.invoke(null, mockConfig); + assertNotNull(options, "Should return options for OAuth mode"); + }); + } + + private Method getBuildYoutubeClientsMethod() throws NoSuchMethodException { + Class<?> audioSourceClass = com.jagrosh.jmusicbot.audio.AudioSource.class; + Method method = audioSourceClass.getDeclaredMethod("buildYoutubeClients", boolean.class); + method.setAccessible(true); + return method; + } + + private Method getBuildYoutubeOptionsMethod() throws NoSuchMethodException { + Class<?> audioSourceClass = com.jagrosh.jmusicbot.audio.AudioSource.class; + Method method = audioSourceClass.getDeclaredMethod("buildYoutubeOptions", BotConfig.class); + method.setAccessible(true); + return method; + } + + private BotConfig createMockConfig(boolean useOauth, int maxYTPlaylistPages) { + BotConfig config = mock(BotConfig.class); + when(config.useYouTubeOauth()).thenReturn(useOauth); + when(config.getMaxYTPlaylistPages()).thenReturn(maxYTPlaylistPages); + return config; + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceRemoteCipherTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceRemoteCipherTest.java new file mode 100644 index 0000000..0797365 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceRemoteCipherTest.java @@ -0,0 +1,125 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.BotConfig; +import dev.lavalink.youtube.YoutubeSourceOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Regression tests for remote cipher configuration in AudioSource. + * + * <p>The bug: {@code buildYoutubeOptions} only applied the remote cipher when + * YouTube OAuth was enabled. Since OAuth defaults to {@code false}, virtually every + * install fell back to youtube-source's bundled {@code LocalSignatureCipherManager}, + * whose extraction patterns stop matching whenever YouTube rotates its player script. + * Playback then died with: + * + * <pre> + * Client [MWEB] failed: Must find sig function from script: + * /s/player/8c3fda2d/player_embed.vflset/nl_NL/base.js + * </pre> + * + * <p>Deciphering and authentication are unrelated concerns, so the cipher must be + * applied regardless of the OAuth setting. + * + * @see com.jagrosh.jmusicbot.audio.AudioSource + */ +@DisplayName("AudioSource Remote Cipher Tests") +class AudioSourceRemoteCipherTest { + + private static final String CIPHER_URL = "https://cipher.kikkia.dev/"; + + @Test + @DisplayName("Remote cipher is configured when OAuth is disabled - regression test") + void remoteCipherConfiguredWhenOAuthDisabled() throws Exception { + // The key regression. Before the fix this returned null, leaving the bot on + // the local extractor and breaking playback on every player script rotation. + YoutubeSourceOptions options = buildOptions(mockConfig(false, CIPHER_URL, "")); + + assertEquals(CIPHER_URL, options.getRemoteCipherUrl(), + "Remote cipher must be applied even when OAuth is disabled - signature " + + "deciphering is independent of authentication"); + } + + @Test + @DisplayName("Remote cipher is configured when OAuth is enabled") + void remoteCipherConfiguredWhenOAuthEnabled() throws Exception { + YoutubeSourceOptions options = buildOptions(mockConfig(true, CIPHER_URL, "")); + + assertEquals(CIPHER_URL, options.getRemoteCipherUrl(), + "Remote cipher should still be applied when OAuth is enabled"); + } + + @Test + @DisplayName("Blank cipher URL leaves the local extractor in place") + void blankUrlDisablesRemoteCipher() throws Exception { + assertNull(buildOptions(mockConfig(false, "", "")).getRemoteCipherUrl(), + "An empty URL should opt out of the remote cipher"); + assertNull(buildOptions(mockConfig(false, " ", "")).getRemoteCipherUrl(), + "A whitespace-only URL should opt out of the remote cipher"); + assertNull(buildOptions(mockConfig(false, null, "")).getRemoteCipherUrl(), + "A null URL should opt out of the remote cipher"); + } + + @Test + @DisplayName("Blank password is passed as null, a real password is preserved") + void passwordHandling() throws Exception { + // youtube-source treats an empty password as a real credential and would send + // it to the cipher server, so blank must be normalised to null. + assertNull(buildOptions(mockConfig(false, CIPHER_URL, "")).getRemoteCipherPassword(), + "An empty password should be passed as null"); + assertNull(buildOptions(mockConfig(false, CIPHER_URL, " ")).getRemoteCipherPassword(), + "A whitespace-only password should be passed as null"); + assertEquals("hunter2", + buildOptions(mockConfig(false, CIPHER_URL, "hunter2")).getRemoteCipherPassword(), + "A configured password should be passed through unchanged"); + } + + @Test + @DisplayName("Surrounding whitespace is trimmed from the cipher URL") + void urlIsTrimmed() throws Exception { + assertEquals(CIPHER_URL, + buildOptions(mockConfig(false, " " + CIPHER_URL + " ")).getRemoteCipherUrl(), + "A URL copied from config with stray whitespace should still work"); + } + + private static YoutubeSourceOptions buildOptions(BotConfig config) throws Exception { + Method method = com.jagrosh.jmusicbot.audio.AudioSource.class + .getDeclaredMethod("buildYoutubeOptions", BotConfig.class); + method.setAccessible(true); + return (YoutubeSourceOptions) method.invoke(null, config); + } + + private static BotConfig mockConfig(boolean useOauth, String cipherUrl) { + return mockConfig(useOauth, cipherUrl, ""); + } + + private static BotConfig mockConfig(boolean useOauth, String cipherUrl, String password) { + BotConfig config = mock(BotConfig.class); + when(config.useYouTubeOauth()).thenReturn(useOauth); + when(config.getYoutubeRemoteCipherUrl()).thenReturn(cipherUrl); + when(config.getYoutubeRemoteCipherPassword()).thenReturn(password); + return config; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceUnitTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceUnitTest.java new file mode 100644 index 0000000..1e098cd --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceUnitTest.java @@ -0,0 +1,212 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.audio.AudioSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("AudioSource Unit Tests") +class AudioSourceUnitTest { + + @Nested + @DisplayName("Registration Priority Tests") + class RegistrationPriorityTests { + + @Test + @DisplayName("valuesSortedByPriority() returns sources in ascending priority order") + void valuesSortedByPriorityReturnsCorrectOrder() { + List<AudioSource> sorted = AudioSource.valuesSortedByPriority(); + + // Verify list is sorted by priority + for (int i = 0; i < sorted.size() - 1; i++) { + assertTrue(sorted.get(i).getRegistrationPriority() <= sorted.get(i + 1).getRegistrationPriority(), + String.format("Source %s (priority %d) should come before %s (priority %d)", + sorted.get(i).getConfigName(), sorted.get(i).getRegistrationPriority(), + sorted.get(i + 1).getConfigName(), sorted.get(i + 1).getRegistrationPriority())); + } + } + + @Test + @DisplayName("Platform-specific sources have lower priority than catch-all sources") + void platformSpecificSourcesHaveLowerPriorityThanCatchAll() { + // Platform-specific sources that should be registered first + AudioSource[] platformSources = { + AudioSource.YOUTUBE, AudioSource.SOUNDCLOUD, AudioSource.BANDCAMP, + AudioSource.VIMEO, AudioSource.TWITCH, AudioSource.BEAM, + AudioSource.GETYARN, AudioSource.NICO + }; + + // Catch-all sources that should be registered last + AudioSource[] catchAllSources = { AudioSource.HTTP, AudioSource.LOCAL }; + + // Get the maximum priority of platform sources + int maxPlatformPriority = 0; + for (AudioSource source : platformSources) { + maxPlatformPriority = Math.max(maxPlatformPriority, source.getRegistrationPriority()); + } + + // Get the minimum priority of catch-all sources + int minCatchAllPriority = Integer.MAX_VALUE; + for (AudioSource source : catchAllSources) { + minCatchAllPriority = Math.min(minCatchAllPriority, source.getRegistrationPriority()); + } + + assertTrue(maxPlatformPriority < minCatchAllPriority, + String.format("Platform sources (max priority %d) should all have lower priority than catch-all sources (min priority %d)", + maxPlatformPriority, minCatchAllPriority)); + } + + @Test + @DisplayName("HTTP source is registered after SoundCloud") + void httpSourceRegisteredAfterSoundCloud() { + // This is the specific regression that was fixed - HTTP was claiming SoundCloud URLs + assertTrue(AudioSource.SOUNDCLOUD.getRegistrationPriority() < AudioSource.HTTP.getRegistrationPriority(), + "SoundCloud should have lower priority (registered first) than HTTP"); + + List<AudioSource> sorted = AudioSource.valuesSortedByPriority(); + int soundcloudIndex = sorted.indexOf(AudioSource.SOUNDCLOUD); + int httpIndex = sorted.indexOf(AudioSource.HTTP); + + assertTrue(soundcloudIndex < httpIndex, + "SoundCloud should appear before HTTP in the sorted list"); + } + + @Test + @DisplayName("HTTP source is registered after all platform-specific sources") + void httpSourceRegisteredLast() { + List<AudioSource> sorted = AudioSource.valuesSortedByPriority(); + int httpIndex = sorted.indexOf(AudioSource.HTTP); + + // HTTP should be near the end (only LOCAL should be after or equal) + assertTrue(httpIndex >= sorted.size() - 2, + "HTTP should be one of the last two sources registered"); + } + + @Test + @DisplayName("All sources have unique priorities") + void allSourcesHaveUniquePriorities() { + AudioSource[] sources = AudioSource.values(); + List<Integer> priorities = new ArrayList<>(); + + for (AudioSource source : sources) { + int priority = source.getRegistrationPriority(); + assertFalse(priorities.contains(priority), + String.format("Priority %d is duplicated (found in %s)", priority, source.getConfigName())); + priorities.add(priority); + } + } + } + + @Nested + @DisplayName("Config Name Tests") + class ConfigNameTests { + + @Test + @DisplayName("fromConfigName() returns correct source for valid names") + void fromConfigNameReturnsCorrectSource() { + assertEquals(Optional.of(AudioSource.YOUTUBE), AudioSource.fromConfigName("youtube")); + assertEquals(Optional.of(AudioSource.SOUNDCLOUD), AudioSource.fromConfigName("soundcloud")); + assertEquals(Optional.of(AudioSource.HTTP), AudioSource.fromConfigName("http")); + assertEquals(Optional.of(AudioSource.LOCAL), AudioSource.fromConfigName("local")); + } + + @Test + @DisplayName("fromConfigName() is case-insensitive") + void fromConfigNameIsCaseInsensitive() { + assertEquals(Optional.of(AudioSource.YOUTUBE), AudioSource.fromConfigName("YouTube")); + assertEquals(Optional.of(AudioSource.YOUTUBE), AudioSource.fromConfigName("YOUTUBE")); + assertEquals(Optional.of(AudioSource.SOUNDCLOUD), AudioSource.fromConfigName("SoundCloud")); + } + + @Test + @DisplayName("fromConfigName() returns empty for invalid names") + void fromConfigNameReturnsEmptyForInvalidNames() { + assertEquals(Optional.empty(), AudioSource.fromConfigName("invalid")); + assertEquals(Optional.empty(), AudioSource.fromConfigName("spotify")); + assertEquals(Optional.empty(), AudioSource.fromConfigName("")); + } + + @Test + @DisplayName("fromConfigName() returns empty for null") + void fromConfigNameReturnsEmptyForNull() { + assertEquals(Optional.empty(), AudioSource.fromConfigName(null)); + } + + @Test + @DisplayName("All sources have non-empty config names") + void allSourcesHaveNonEmptyConfigNames() { + for (AudioSource source : AudioSource.values()) { + assertNotNull(source.getConfigName(), source.name() + " should have a config name"); + assertFalse(source.getConfigName().isEmpty(), source.name() + " config name should not be empty"); + } + } + + @Test + @DisplayName("All sources have unique config names") + void allSourcesHaveUniqueConfigNames() { + AudioSource[] sources = AudioSource.values(); + List<String> configNames = new ArrayList<>(); + + for (AudioSource source : sources) { + String name = source.getConfigName(); + assertFalse(configNames.contains(name), + String.format("Config name '%s' is duplicated", name)); + configNames.add(name); + } + } + } + + @Nested + @DisplayName("Description Tests") + class DescriptionTests { + + @Test + @DisplayName("All sources have non-empty descriptions") + void allSourcesHaveNonEmptyDescriptions() { + for (AudioSource source : AudioSource.values()) { + assertNotNull(source.getDescription(), source.name() + " should have a description"); + assertFalse(source.getDescription().isEmpty(), source.name() + " description should not be empty"); + } + } + } + + @Nested + @DisplayName("Completeness Tests") + class CompletenessTests { + + @Test + @DisplayName("valuesSortedByPriority() contains all sources") + void valuesSortedByPriorityContainsAllSources() { + List<AudioSource> sorted = AudioSource.valuesSortedByPriority(); + AudioSource[] all = AudioSource.values(); + + assertEquals(all.length, sorted.size(), "Sorted list should contain all sources"); + + for (AudioSource source : all) { + assertTrue(sorted.contains(source), + source.getConfigName() + " should be in the sorted list"); + } + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/BotConfigUnitTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/BotConfigUnitTest.java new file mode 100644 index 0000000..72a99d1 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/BotConfigUnitTest.java @@ -0,0 +1,912 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.audio.AudioSource; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import net.dv8tion.jda.api.OnlineStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@DisplayName("BotConfig Unit Tests") +class BotConfigUnitTest extends BaseConfigTest { + + @Mock + private AudioTrack mockAudioTrack; + + @Override + @org.junit.jupiter.api.BeforeEach + protected void setUpBase() { + super.setUpBase(); + MockitoAnnotations.openMocks(this); + } + + @Nested + @DisplayName("Getters Tests") + class GettersTests { + + @Test + @DisplayName("All getters return correct values after load") + void allGettersReturnCorrectValuesAfterLoad() throws IOException { + String configContent = """ + token = test_token_12345 + owner = 123456789 + prefix = "@mention" + altprefix = "NONE" + help = help + game = DEFAULT + status = ONLINE + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + assertEquals("test_token_12345", config.getToken()); + assertEquals(123456789L, config.getOwnerId()); + assertNotNull(config.getPrefix()); + } + + @Test + @DisplayName("getAltPrefix() returns null for NONE") + void getAltPrefixReturnsNullForNone() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + commands.altPrefix = "NONE" + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + // getAltPrefix() returns null when internal value is "NONE" (for API compatibility) + assertNull(config.getAltPrefix()); + } + + @Test + @DisplayName("getAltPrefix() returns value when not NONE") + void getAltPrefixReturnsValueWhenNotNone() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + commands.altPrefix = "!!" + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertEquals("!!", config.getAltPrefix()); + } + + @Test + @DisplayName("isGameNone() returns true for NONE game") + void isGameNoneReturnsTrueForNoneGame() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + presence.game = NONE + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isGameNone()); + } + + @Test + @DisplayName("isGameNone() returns false for other games") + void isGameNoneReturnsFalseForOtherGames() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + presence.game = Playing music + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertFalse(config.isGameNone()); + } + + @Test + @DisplayName("getDBots() returns true for specific owner ID") + void getDBotsReturnsTrueForSpecificOwnerId() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 113156185389092864 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.getDBots()); + } + + @Test + @DisplayName("getDBots() returns false for other owner IDs") + void getDBotsReturnsFalseForOtherOwnerIds() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertFalse(config.getDBots()); + } + } + + @Nested + @DisplayName("isTooLong() Tests") + class IsTooLongTests { + + @Test + @DisplayName("isTooLong() returns false when maxSeconds is 0") + void isTooLongReturnsFalseWhenMaxSecondsIsZero() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.maxTrackSeconds = 0 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + when(mockAudioTrack.getDuration()).thenReturn(3600000L); // 1 hour + + assertFalse(config.isTooLong(mockAudioTrack)); + } + + @Test + @DisplayName("isTooLong() returns false when track is shorter than max") + void isTooLongReturnsFalseWhenTrackIsShorter() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.maxTrackSeconds = 300 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + when(mockAudioTrack.getDuration()).thenReturn(120000L); // 2 minutes + + assertFalse(config.isTooLong(mockAudioTrack)); + } + + @Test + @DisplayName("isTooLong() returns true when track is longer than max") + void isTooLongReturnsTrueWhenTrackIsLonger() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.maxTrackSeconds = 300 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + when(mockAudioTrack.getDuration()).thenReturn(600000L); // 10 minutes + + assertTrue(config.isTooLong(mockAudioTrack)); + } + } + + @Nested + @DisplayName("getAliases() Tests") + class GetAliasesTests { + + @Test + @DisplayName("getAliases() returns aliases for existing command") + void getAliasesReturnsAliasesForExistingCommand() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + commands.aliases { + play = [ p, playmusic ] + skip = [ voteskip ] + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + String[] playAliases = config.getAliases("play"); + assertEquals(2, playAliases.length); + assertTrue(java.util.Arrays.asList(playAliases).contains("p")); + assertTrue(java.util.Arrays.asList(playAliases).contains("playmusic")); + } + + @Test + @DisplayName("getAliases() returns empty array for non-existent command") + void getAliasesReturnsEmptyArrayForNonExistentCommand() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + commands.aliases { + play = [ p ] + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + String[] aliases = config.getAliases("nonexistent"); + assertEquals(0, aliases.length); + } + + @Test + @DisplayName("getAliases() returns empty array when aliases config is missing") + void getAliasesReturnsEmptyArrayWhenAliasesConfigMissing() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + String[] aliases = config.getAliases("play"); + assertEquals(0, aliases.length); + } + } + + @Nested + @DisplayName("Audio Sources Tests") + class AudioSourcesTests { + + @Test + @DisplayName("All sources enabled when audioSources key is missing") + void allSourcesEnabledWhenAudioSourcesKeyMissing() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + Set<AudioSource> sources = config.getEnabledAudioSources(); + assertNotNull(sources); + assertEquals(AudioSource.values().length, sources.size(), + "All sources should be enabled when audioSources key is missing"); + for (AudioSource source : AudioSource.values()) { + assertTrue(config.isAudioSourceEnabled(source), + source.getConfigName() + " should be enabled"); + } + } + + @Test + @DisplayName("All sources enabled when all set to true") + void allSourcesEnabledWhenAllSetToTrue() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = true + soundcloud = true + bandcamp = true + vimeo = true + twitch = true + beam = true + getyarn = true + nico = true + http = true + local = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + Set<AudioSource> sources = config.getEnabledAudioSources(); + assertEquals(AudioSource.values().length, sources.size(), + "All sources should be enabled when all set to true"); + for (AudioSource source : AudioSource.values()) { + assertTrue(config.isAudioSourceEnabled(source), + source.getConfigName() + " should be enabled"); + } + } + + @Test + @DisplayName("Some sources disabled when set to false") + void someSourcesDisabledWhenSetToFalse() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = true + soundcloud = true + bandcamp = false + vimeo = true + twitch = true + beam = false + getyarn = true + nico = true + http = true + local = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + Set<AudioSource> sources = config.getEnabledAudioSources(); + + // Should have 8 sources enabled (10 total - 2 disabled) + assertEquals(8, sources.size(), "Should have 8 sources enabled"); + + // Enabled sources + assertTrue(config.isAudioSourceEnabled(AudioSource.YOUTUBE)); + assertTrue(config.isAudioSourceEnabled(AudioSource.SOUNDCLOUD)); + assertTrue(config.isAudioSourceEnabled(AudioSource.VIMEO)); + assertTrue(config.isAudioSourceEnabled(AudioSource.TWITCH)); + assertTrue(config.isAudioSourceEnabled(AudioSource.GETYARN)); + assertTrue(config.isAudioSourceEnabled(AudioSource.NICO)); + assertTrue(config.isAudioSourceEnabled(AudioSource.HTTP)); + assertTrue(config.isAudioSourceEnabled(AudioSource.LOCAL)); + + // Disabled sources + assertFalse(config.isAudioSourceEnabled(AudioSource.BANDCAMP), + "Bandcamp should be disabled when set to false"); + assertFalse(config.isAudioSourceEnabled(AudioSource.BEAM), + "Beam should be disabled when set to false"); + } + + @Test + @DisplayName("All sources enabled when all set to false (fallback behavior)") + void allSourcesEnabledWhenAllSetToFalse() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = false + soundcloud = false + bandcamp = false + vimeo = false + twitch = false + beam = false + getyarn = false + nico = false + http = false + local = false + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + Set<AudioSource> sources = config.getEnabledAudioSources(); + assertEquals(AudioSource.values().length, sources.size(), + "All sources should be enabled when all are set to false (fallback behavior)"); + for (AudioSource source : AudioSource.values()) { + assertTrue(config.isAudioSourceEnabled(source), + source.getConfigName() + " should be enabled (fallback)"); + } + } + + @Test + @DisplayName("isAudioSourceEnabled() returns correct values") + void isAudioSourceEnabledReturnsCorrectValues() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = true + soundcloud = false + bandcamp = true + vimeo = false + twitch = true + beam = true + getyarn = true + nico = true + http = true + local = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isAudioSourceEnabled(AudioSource.YOUTUBE)); + assertFalse(config.isAudioSourceEnabled(AudioSource.SOUNDCLOUD)); + assertTrue(config.isAudioSourceEnabled(AudioSource.BANDCAMP)); + assertFalse(config.isAudioSourceEnabled(AudioSource.VIMEO)); + } + + @Test + @DisplayName("Invalid source names in config are ignored") + void invalidSourceNamesAreIgnored() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = true + soundcloud = true + invalid_source = true + another_invalid = false + bandcamp = true + vimeo = true + twitch = true + beam = true + getyarn = true + nico = true + http = true + local = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + // Invalid source names should be ignored, all valid sources enabled + Set<AudioSource> sources = config.getEnabledAudioSources(); + assertEquals(AudioSource.values().length, sources.size(), + "All valid sources should be enabled, invalid names ignored"); + assertTrue(sources.contains(AudioSource.YOUTUBE)); + assertTrue(sources.contains(AudioSource.SOUNDCLOUD)); + } + + @Test + @DisplayName("getEnabledAudioSources() returns sources in priority order") + void getEnabledAudioSourcesReturnsPriorityOrder() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = true + soundcloud = true + bandcamp = true + vimeo = true + twitch = true + beam = true + getyarn = true + nico = true + http = true + local = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + Set<AudioSource> sources = config.getEnabledAudioSources(); + + // Convert to list to check ordering + java.util.List<AudioSource> sourceList = new java.util.ArrayList<>(sources); + + // Verify the list is in priority order + for (int i = 0; i < sourceList.size() - 1; i++) { + assertTrue(sourceList.get(i).getRegistrationPriority() <= sourceList.get(i + 1).getRegistrationPriority(), + String.format("Source %s (priority %d) should come before %s (priority %d)", + sourceList.get(i).getConfigName(), sourceList.get(i).getRegistrationPriority(), + sourceList.get(i + 1).getConfigName(), sourceList.get(i + 1).getRegistrationPriority())); + } + } + + @Test + @DisplayName("SoundCloud is registered before HTTP (regression test)") + void soundCloudRegisteredBeforeHttp() throws IOException { + // This test specifically guards against the regression where HTTP claimed SoundCloud URLs + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + soundcloud = true + http = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + Set<AudioSource> sources = config.getEnabledAudioSources(); + java.util.List<AudioSource> sourceList = new java.util.ArrayList<>(sources); + + int soundcloudIndex = sourceList.indexOf(AudioSource.SOUNDCLOUD); + int httpIndex = sourceList.indexOf(AudioSource.HTTP); + + assertTrue(soundcloudIndex >= 0, "SoundCloud should be in the enabled sources"); + assertTrue(httpIndex >= 0, "HTTP should be in the enabled sources"); + assertTrue(soundcloudIndex < httpIndex, + "SoundCloud must be registered before HTTP to avoid HTTP claiming SoundCloud URLs"); + } + + @Test + @DisplayName("Partial source selection maintains priority order") + void partialSourceSelectionMaintainsPriorityOrder() throws IOException { + // Enable only a subset of sources, including both platform-specific and catch-all + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + playback.audioSources { + youtube = false + soundcloud = true + bandcamp = false + vimeo = true + twitch = false + beam = false + getyarn = false + nico = false + http = true + local = false + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + Set<AudioSource> sources = config.getEnabledAudioSources(); + assertEquals(3, sources.size(), "Should have 3 sources enabled"); + + java.util.List<AudioSource> sourceList = new java.util.ArrayList<>(sources); + + // Verify order: soundcloud, vimeo, http (ascending priority) + assertEquals(AudioSource.SOUNDCLOUD, sourceList.get(0), "SoundCloud should be first"); + assertEquals(AudioSource.VIMEO, sourceList.get(1), "Vimeo should be second"); + assertEquals(AudioSource.HTTP, sourceList.get(2), "HTTP should be last"); + } + } + + @Nested + @DisplayName("Proxy Configuration Tests") + class ProxyConfigurationTests { + + @Test + @DisplayName("hasProxy() returns false when host is empty") + void hasProxyReturnsFalseWhenHostEmpty() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + proxy { + host = "" + port = 8080 + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertFalse(config.hasProxy()); + } + + @Test + @DisplayName("hasProxy() returns false when port is 0") + void hasProxyReturnsFalseWhenPortIsZero() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + proxy { + host = "127.0.0.1" + port = 0 + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertFalse(config.hasProxy()); + } + + @Test + @DisplayName("hasProxy() returns true when host and port are configured") + void hasProxyReturnsTrueWhenConfigured() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + proxy { + host = "127.0.0.1" + port = 8080 + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.hasProxy()); + assertEquals("127.0.0.1", config.getProxyHost()); + assertEquals(8080, config.getProxyPort()); + } + + @Test + @DisplayName("Proxy component flags default to false") + void proxyComponentFlagsDefaultToFalse() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertFalse(config.proxyLavaplayer()); + assertFalse(config.proxyJda()); + assertFalse(config.proxyGithub()); + } + + @Test + @DisplayName("Proxy component flags are loaded correctly") + void proxyComponentFlagsLoadedCorrectly() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + proxy { + host = "127.0.0.1" + port = 8080 + lavaplayer = true + jda = false + github = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.proxyLavaplayer()); + assertFalse(config.proxyJda()); + assertTrue(config.proxyGithub()); + } + + @Test + @DisplayName("All proxy component flags can be enabled") + void allProxyComponentFlagsCanBeEnabled() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + proxy { + host = "proxy.example.com" + port = 18080 + lavaplayer = true + jda = true + github = true + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.hasProxy()); + assertEquals("proxy.example.com", config.getProxyHost()); + assertEquals(18080, config.getProxyPort()); + assertTrue(config.proxyLavaplayer()); + assertTrue(config.proxyJda()); + assertTrue(config.proxyGithub()); + } + + @Test + @DisplayName("Practical use case: only Lavaplayer through proxy") + void practicalUseCaseOnlyLavaplayerThroughProxy() throws IOException { + String configContent = """ + meta { + configVersion = 1 + } + discord.token = test_token + discord.owner = 123456789 + proxy { + host = "127.0.0.1" + port = 18080 + lavaplayer = true + jda = false + github = false + } + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.hasProxy()); + assertTrue(config.proxyLavaplayer()); + assertFalse(config.proxyJda()); + assertFalse(config.proxyGithub()); + } + } + + @Nested + @DisplayName("Status and Game Tests") + class StatusAndGameTests { + + @Test + @DisplayName("getStatus() returns correct OnlineStatus") + void getStatusReturnsCorrectOnlineStatus() throws IOException { + // Use legacy format - will be migrated to nested + String configContent = """ + token = test_token + owner = 123456789 + status = IDLE + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + assertEquals(OnlineStatus.IDLE, config.getStatus()); + } + + @Test + @DisplayName("getGame() returns correct Activity") + void getGameReturnsCorrectActivity() throws IOException { + // Use legacy format - will be migrated to nested + String configContent = """ + token = test_token + owner = 123456789 + game = Playing music + """; + Path configFile = createTempConfigFile(configContent); + setConfigFileProperty(configFile); + + BotConfig config = new BotConfig(mockUserInteraction); + config.load(); + + assertTrue(config.isValid()); + assertNotNull(config.getGame()); + assertTrue(config.getGame().getName().contains("music")); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/BotTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/BotTest.java new file mode 100644 index 0000000..af457bc --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/BotTest.java @@ -0,0 +1,409 @@ +/* + * 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.unit; + +import com.jagrosh.jdautilities.command.CommandClient; +import com.jagrosh.jdautilities.commons.waiter.EventWaiter; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.entities.UserInteraction; +import javax.swing.JFrame; +import com.jagrosh.jmusicbot.settings.SettingsManager; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.managers.AudioManager; +import net.dv8tion.jda.api.managers.Presence; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for the Bot class. + */ +@DisplayName("Bot Tests") +public class BotTest +{ + @Mock + private EventWaiter waiter; + + @Mock + private BotConfig config; + + @Mock + private SettingsManager settingsManager; + + @Mock + private UserInteraction userInteraction; + + @Mock + private JDA jda; + + @Mock + private JFrame gui; + + @Mock + private CommandClient commandClient; + + @Mock + private Guild guild; + + @Mock + private AudioManager audioManager; + + @Mock + private AudioHandler audioHandler; + + @Mock + private Presence presence; + + private Bot bot; + + @BeforeEach + void setUp() + { + MockitoAnnotations.openMocks(this); + + // Setup mock behaviors + when(config.getGame()).thenReturn(null); + when(config.getMaxHistorySize()).thenReturn(10); + + // Create bot instance + bot = new Bot(waiter, config, settingsManager, userInteraction); + } + + // ==================== Constructor and Initialization Tests ==================== + + @Nested + @DisplayName("Constructor and Initialization") + class ConstructorTests + { + @Test + @DisplayName("Bot constructor initializes all components") + void constructor_initializesAllComponents() + { + // Then + assertNotNull(bot.getConfig()); + assertNotNull(bot.getSettingsManager()); + assertNotNull(bot.getWaiter()); + assertNotNull(bot.getThreadpool()); + assertNotNull(bot.getPlayerManager()); + assertNotNull(bot.getPlaylistLoader()); + assertNotNull(bot.getNowplayingHandler()); + assertNotNull(bot.getAloneInVoiceHandler()); + assertNotNull(bot.getMusicService()); + assertNotNull(bot.getSearchService()); + assertNotNull(bot.getStartTime()); + } + + @Test + @DisplayName("Bot stores start time at initialization") + void constructor_storesStartTime() + { + // Given + Instant before = Instant.now(); + + // When + Bot newBot = new Bot(waiter, config, settingsManager, userInteraction); + + // Then + Instant after = Instant.now(); + assertNotNull(newBot.getStartTime()); + assertTrue(newBot.getStartTime().isAfter(before.minusMillis(1))); + assertTrue(newBot.getStartTime().isBefore(after.plusMillis(1))); + } + } + + // ==================== Getter Tests ==================== + + @Nested + @DisplayName("Getter Methods") + class GetterTests + { + @Test + @DisplayName("getConfig() returns BotConfig") + void getConfig_returnsBotConfig() + { + assertEquals(config, bot.getConfig()); + } + + @Test + @DisplayName("getSettingsManager() returns SettingsManager") + void getSettingsManager_returnsSettingsManager() + { + assertEquals(settingsManager, bot.getSettingsManager()); + } + + @Test + @DisplayName("getWaiter() returns EventWaiter") + void getWaiter_returnsEventWaiter() + { + assertEquals(waiter, bot.getWaiter()); + } + + @Test + @DisplayName("getJDA() returns null initially") + void getJDA_returnsNullInitially() + { + assertNull(bot.getJDA()); + } + + @Test + @DisplayName("getCommandClient() returns null initially") + void getCommandClient_returnsNullInitially() + { + assertNull(bot.getCommandClient()); + } + + @Test + @DisplayName("getUserInteraction() returns UserInteraction") + void getUserInteraction_returnsUserInteraction() + { + assertEquals(userInteraction, bot.getUserInteraction()); + } + } + + // ==================== Setter Tests ==================== + + @Nested + @DisplayName("Setter Methods") + class SetterTests + { + @Test + @DisplayName("setJDA() stores JDA instance") + void setJDA_storesJDA() + { + // When + bot.setJDA(jda); + + // Then + assertEquals(jda, bot.getJDA()); + } + + @Test + @DisplayName("setGUI() stores GUI instance") + void setGUI_storesGUI() + { + // When + bot.setGUI(gui); + + // No getter for GUI, but we can verify no exception is thrown + assertDoesNotThrow(() -> bot.setGUI(gui)); + } + + @Test + @DisplayName("setCommandClient() stores CommandClient") + void setCommandClient_storesCommandClient() + { + // When + bot.setCommandClient(commandClient); + + // Then + assertEquals(commandClient, bot.getCommandClient()); + } + } + + // ==================== Close Audio Connection Tests ==================== + + @Nested + @DisplayName("Close Audio Connection") + class CloseAudioConnectionTests + { + @Test + @DisplayName("closeAudioConnection() closes connection for valid guild") + void closeAudioConnection_closesForValidGuild() throws InterruptedException + { + // Given + bot.setJDA(jda); + when(jda.getGuildById(123L)).thenReturn(guild); + when(guild.getAudioManager()).thenReturn(audioManager); + + // When + bot.closeAudioConnection(123L); + + // Give threadpool time to execute (it's async) + Thread.sleep(100); + + // Then - verify task was submitted to threadpool + // Note: The actual close is async via threadpool + assertDoesNotThrow(() -> bot.closeAudioConnection(123L)); + } + + @Test + @DisplayName("closeAudioConnection() handles null guild gracefully") + void closeAudioConnection_handlesNullGuild() + { + // Given + bot.setJDA(jda); + when(jda.getGuildById(999L)).thenReturn(null); + + // When/Then - should not throw + assertDoesNotThrow(() -> bot.closeAudioConnection(999L)); + } + } + + // ==================== Reset Game Tests ==================== + + @Nested + @DisplayName("Reset Game") + class ResetGameTests + { + @Test + @DisplayName("resetGame() sets null game when config game is null") + void resetGame_setsNullGame_whenConfigGameNull() + { + // Given + bot.setJDA(jda); + when(jda.getPresence()).thenReturn(presence); + when(presence.getActivity()).thenReturn(null); + when(config.getGame()).thenReturn(null); + + // When + bot.resetGame(); + + // Then - no activity change needed (both null) + verify(presence, never()).setActivity(any()); + } + + @Test + @DisplayName("resetGame() sets null game when config game is 'none'") + void resetGame_setsNullGame_whenConfigGameIsNone() + { + // Given + bot.setJDA(jda); + when(jda.getPresence()).thenReturn(presence); + when(presence.getActivity()).thenReturn(Activity.playing("Something")); + Activity noneActivity = mock(Activity.class); + when(noneActivity.getName()).thenReturn("none"); + when(config.getGame()).thenReturn(noneActivity); + + // When + bot.resetGame(); + + // Then + verify(presence).setActivity(null); + } + + @Test + @DisplayName("resetGame() sets game from config") + void resetGame_setsGameFromConfig() + { + // Given + bot.setJDA(jda); + when(jda.getPresence()).thenReturn(presence); + when(presence.getActivity()).thenReturn(null); + Activity newGame = Activity.playing("JMusicBot"); + when(config.getGame()).thenReturn(newGame); + + // When + bot.resetGame(); + + // Then + verify(presence).setActivity(newGame); + } + + @Test + @DisplayName("resetGame() does nothing when game is same") + void resetGame_doesNothingWhenSame() + { + // Given + bot.setJDA(jda); + Activity game = Activity.playing("JMusicBot"); + when(jda.getPresence()).thenReturn(presence); + when(presence.getActivity()).thenReturn(game); + when(config.getGame()).thenReturn(game); + + // When + bot.resetGame(); + + // Then - setActivity should not be called + verify(presence, never()).setActivity(any()); + } + } + + // ==================== Service Access Tests ==================== + + @Nested + @DisplayName("Service Access") + class ServiceAccessTests + { + @Test + @DisplayName("getMusicService() returns MusicService instance") + void getMusicService_returnsMusicService() + { + assertNotNull(bot.getMusicService()); + } + + @Test + @DisplayName("getSearchService() returns SearchService instance") + void getSearchService_returnsSearchService() + { + assertNotNull(bot.getSearchService()); + } + + @Test + @DisplayName("getPlayerManager() returns PlayerManager instance") + void getPlayerManager_returnsPlayerManager() + { + assertNotNull(bot.getPlayerManager()); + } + + @Test + @DisplayName("getPlaylistLoader() returns PlaylistLoader instance") + void getPlaylistLoader_returnsPlaylistLoader() + { + assertNotNull(bot.getPlaylistLoader()); + } + + @Test + @DisplayName("getNowplayingHandler() returns NowPlayingHandler instance") + void getNowplayingHandler_returnsNowPlayingHandler() + { + assertNotNull(bot.getNowplayingHandler()); + } + + @Test + @DisplayName("getAloneInVoiceHandler() returns AloneInVoiceHandler instance") + void getAloneInVoiceHandler_returnsAloneInVoiceHandler() + { + assertNotNull(bot.getAloneInVoiceHandler()); + } + + @Test + @DisplayName("getThreadpool() returns ScheduledExecutorService instance") + void getThreadpool_returnsScheduledExecutorService() + { + assertNotNull(bot.getThreadpool()); + } + + @Test + @DisplayName("getYouTubeOauth2Handler() returns handler instance") + void getYouTubeOauth2Handler_returnsHandler() + { + assertNotNull(bot.getYouTubeOauth2Handler()); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/HistoryInteractionListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/HistoryInteractionListenerTest.java new file mode 100644 index 0000000..beffb90 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/HistoryInteractionListenerTest.java @@ -0,0 +1,86 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.listener.HistoryInteractionListener; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.testutil.listener.ListenerTestFixture; +import net.dv8tion.jda.api.requests.restaction.interactions.MessageEditCallbackAction; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("HistoryInteractionListener Tests") +public class HistoryInteractionListenerTest { + + private ListenerTestFixture fixture; + private HistoryInteractionListener listener; + + @BeforeEach + void setUp() { + fixture = ListenerTestFixture.create(); + listener = new HistoryInteractionListener(fixture.getBot()); + when(fixture.getButtonInteractionEvent().getUser()).thenReturn(fixture.getUser()); + } + + @Test + @DisplayName("onButtonInteraction() handles history_ button with invalid format and replies error") + void onButtonInteraction_historyInvalidFormat_repliesError() { + fixture.withButtonId("history_ab"); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply(argThat((String s) -> s.contains("Invalid button state"))); + verify(fixture.getReplyAction()).setEphemeral(true); + verify(fixture.getMusicService(), never()).stop(any(), any(), any()); + } + + @Test + @DisplayName("onButtonInteraction() rejects select action not present on current page") + void onButtonInteraction_selectOutsidePage_repliesError() { + fixture.withButtonId("history_select11_1_0_" + fixture.getUser().getIdLong()); + MusicService.HistoryInfo info = new MusicService.HistoryInfo( + new String[]{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"}, + 0L, + 50 + ); + when(fixture.getMusicService().getHistoryInfo(fixture.getGuild(), fixture.getJda())).thenReturn(info); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply(argThat((String s) -> s.contains("isn't on this page"))); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("onButtonInteraction() shows disabled message when history is disabled") + void onButtonInteraction_historyDisabled_showsDisabledMessage() { + fixture.withButtonId("history_prev_1_0_" + fixture.getUser().getIdLong()); + MusicService.HistoryInfo info = new MusicService.HistoryInfo(new String[0], 0L, 0, true); + when(fixture.getMusicService().getHistoryInfo(fixture.getGuild(), fixture.getJda())).thenReturn(info); + MessageEditCallbackAction editAction = mock(MessageEditCallbackAction.class); + when(fixture.getButtonInteractionEvent().editMessage(any(String.class))).thenReturn(editAction); + when(editAction.setEmbeds()).thenReturn(editAction); + when(editAction.setComponents()).thenReturn(editAction); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).editMessage(argThat((String s) -> s.contains("disabled by config"))); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/JMusicBotTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/JMusicBotTest.java new file mode 100644 index 0000000..18e058e --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/JMusicBotTest.java @@ -0,0 +1,216 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.JMusicBot; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.requests.GatewayIntent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the JMusicBot class. + * + * Note: The main() and startBot() methods are difficult to test as they + * involve JDA initialization, file I/O, and System.exit(). These tests + * focus on the testable static constants and configurations. + */ +@DisplayName("JMusicBot Tests") +public class JMusicBotTest +{ + // ==================== Recommended Permissions Tests ==================== + + @Nested + @DisplayName("Recommended Permissions") + class RecommendedPermissionsTests + { + @Test + @DisplayName("RECOMMENDED_PERMS contains required permissions") + void recommendedPerms_containsRequiredPermissions() + { + Set<Permission> perms = Arrays.stream(JMusicBot.RECOMMENDED_PERMS).collect(Collectors.toSet()); + + // Core permissions + assertTrue(perms.contains(Permission.VIEW_CHANNEL), "Should have VIEW_CHANNEL"); + assertTrue(perms.contains(Permission.MESSAGE_SEND), "Should have MESSAGE_SEND"); + assertTrue(perms.contains(Permission.MESSAGE_HISTORY), "Should have MESSAGE_HISTORY"); + } + + @Test + @DisplayName("RECOMMENDED_PERMS contains voice permissions") + void recommendedPerms_containsVoicePermissions() + { + Set<Permission> perms = Arrays.stream(JMusicBot.RECOMMENDED_PERMS).collect(Collectors.toSet()); + + assertTrue(perms.contains(Permission.VOICE_CONNECT), "Should have VOICE_CONNECT"); + assertTrue(perms.contains(Permission.VOICE_SPEAK), "Should have VOICE_SPEAK"); + } + + @Test + @DisplayName("RECOMMENDED_PERMS contains message interaction permissions") + void recommendedPerms_containsMessageInteractionPermissions() + { + Set<Permission> perms = Arrays.stream(JMusicBot.RECOMMENDED_PERMS).collect(Collectors.toSet()); + + assertTrue(perms.contains(Permission.MESSAGE_ADD_REACTION), "Should have MESSAGE_ADD_REACTION"); + assertTrue(perms.contains(Permission.MESSAGE_EMBED_LINKS), "Should have MESSAGE_EMBED_LINKS"); + assertTrue(perms.contains(Permission.MESSAGE_ATTACH_FILES), "Should have MESSAGE_ATTACH_FILES"); + assertTrue(perms.contains(Permission.MESSAGE_MANAGE), "Should have MESSAGE_MANAGE"); + assertTrue(perms.contains(Permission.MESSAGE_EXT_EMOJI), "Should have MESSAGE_EXT_EMOJI"); + } + + @Test + @DisplayName("RECOMMENDED_PERMS contains nickname change permission") + void recommendedPerms_containsNicknamePermission() + { + Set<Permission> perms = Arrays.stream(JMusicBot.RECOMMENDED_PERMS).collect(Collectors.toSet()); + + assertTrue(perms.contains(Permission.NICKNAME_CHANGE), "Should have NICKNAME_CHANGE"); + } + + @Test + @DisplayName("RECOMMENDED_PERMS has correct count") + void recommendedPerms_hasCorrectCount() + { + // 11 permissions total + assertEquals(11, JMusicBot.RECOMMENDED_PERMS.length); + } + } + + // ==================== Gateway Intents Tests ==================== + + @Nested + @DisplayName("Gateway Intents") + class GatewayIntentsTests + { + @Test + @DisplayName("INTENTS contains DIRECT_MESSAGES") + void intents_containsDirectMessages() + { + Set<GatewayIntent> intents = Arrays.stream(JMusicBot.INTENTS).collect(Collectors.toSet()); + assertTrue(intents.contains(GatewayIntent.DIRECT_MESSAGES)); + } + + @Test + @DisplayName("INTENTS contains GUILD_MESSAGES") + void intents_containsGuildMessages() + { + Set<GatewayIntent> intents = Arrays.stream(JMusicBot.INTENTS).collect(Collectors.toSet()); + assertTrue(intents.contains(GatewayIntent.GUILD_MESSAGES)); + } + + @Test + @DisplayName("INTENTS contains GUILD_MESSAGE_REACTIONS") + void intents_containsGuildMessageReactions() + { + Set<GatewayIntent> intents = Arrays.stream(JMusicBot.INTENTS).collect(Collectors.toSet()); + assertTrue(intents.contains(GatewayIntent.GUILD_MESSAGE_REACTIONS)); + } + + @Test + @DisplayName("INTENTS contains GUILD_VOICE_STATES") + void intents_containsGuildVoiceStates() + { + Set<GatewayIntent> intents = Arrays.stream(JMusicBot.INTENTS).collect(Collectors.toSet()); + assertTrue(intents.contains(GatewayIntent.GUILD_VOICE_STATES)); + } + + @Test + @DisplayName("INTENTS contains MESSAGE_CONTENT") + void intents_containsMessageContent() + { + Set<GatewayIntent> intents = Arrays.stream(JMusicBot.INTENTS).collect(Collectors.toSet()); + assertTrue(intents.contains(GatewayIntent.MESSAGE_CONTENT)); + } + + @Test + @DisplayName("INTENTS has correct count") + void intents_hasCorrectCount() + { + // 5 intents total + assertEquals(5, JMusicBot.INTENTS.length); + } + } + + // ==================== Logger Tests ==================== + + @Nested + @DisplayName("Logger") + class LoggerTests + { + @Test + @DisplayName("LOG is not null") + void log_isNotNull() + { + assertNotNull(JMusicBot.LOG); + } + + @Test + @DisplayName("LOG has correct name") + void log_hasCorrectName() + { + assertEquals(JMusicBot.class.getName(), JMusicBot.LOG.getName()); + } + } + + // ==================== Static Constants Validation ==================== + + @Nested + @DisplayName("Static Constants Validation") + class StaticConstantsTests + { + @Test + @DisplayName("RECOMMENDED_PERMS is not empty") + void recommendedPerms_isNotEmpty() + { + assertTrue(JMusicBot.RECOMMENDED_PERMS.length > 0); + } + + @Test + @DisplayName("INTENTS is not empty") + void intents_isNotEmpty() + { + assertTrue(JMusicBot.INTENTS.length > 0); + } + + @Test + @DisplayName("Permissions are all non-null") + void permissions_allNonNull() + { + for (Permission perm : JMusicBot.RECOMMENDED_PERMS) + { + assertNotNull(perm, "All permissions should be non-null"); + } + } + + @Test + @DisplayName("Intents are all non-null") + void intents_allNonNull() + { + for (GatewayIntent intent : JMusicBot.INTENTS) + { + assertNotNull(intent, "All intents should be non-null"); + } + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/NowPlayingCleanupListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/NowPlayingCleanupListenerTest.java new file mode 100644 index 0000000..b9ce7f7 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/NowPlayingCleanupListenerTest.java @@ -0,0 +1,63 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.listener.NowPlayingCleanupListener; +import com.jagrosh.jmusicbot.testutil.listener.ListenerTestFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +@DisplayName("NowPlayingCleanupListener Tests") +public class NowPlayingCleanupListenerTest { + + private ListenerTestFixture fixture; + private NowPlayingCleanupListener listener; + + @BeforeEach + void setUp() { + fixture = ListenerTestFixture.create(); + listener = new NowPlayingCleanupListener(fixture.getBot()); + } + + @Nested + @DisplayName("onMessageDelete") + class OnMessageDeleteTests { + @Test + @DisplayName("delegates to NowPlayingHandler when from guild") + void onMessageDelete_delegatesToNowPlayingHandler() { + listener.onMessageDelete(fixture.getMessageDeleteEvent()); + + verify(fixture.getNowPlayingHandler()).onMessageDelete( + fixture.getGuild(), + ListenerTestFixture.MESSAGE_ID + ); + } + + @Test + @DisplayName("does nothing when not from guild") + void onMessageDelete_doesNothingWhenNotFromGuild() { + when(fixture.getMessageDeleteEvent().isFromGuild()).thenReturn(false); + + listener.onMessageDelete(fixture.getMessageDeleteEvent()); + + verify(fixture.getNowPlayingHandler(), never()).onMessageDelete(any(), anyLong()); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/PlaybackControlsListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/PlaybackControlsListenerTest.java new file mode 100644 index 0000000..b89fdd9 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/PlaybackControlsListenerTest.java @@ -0,0 +1,373 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.listener.PlaybackControlsListener; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.testutil.listener.ListenerTestFixture; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.requests.restaction.WebhookMessageCreateAction; +import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@DisplayName("PlaybackControlsListener Tests") +public class PlaybackControlsListenerTest { + + private ListenerTestFixture fixture; + private PlaybackControlsListener listener; + + @BeforeEach + void setUp() { + fixture = ListenerTestFixture.create(); + listener = new PlaybackControlsListener(fixture.getBot()); + } + + @Nested + @DisplayName("onButtonInteraction") + class OnButtonInteractionTests { + @Test + @DisplayName("ignores unknown button IDs") + void onButtonInteraction_ignoresUnknownButtonId() { + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService(), never()).stop(any(), any(), any()); + verify(fixture.getMusicService(), never()).pause(any(), any(), any()); + verify(fixture.getMusicService(), never()).skip(any(), any(), any()); + } + + @Test + @DisplayName("handles stop button") + void onButtonInteraction_handlesStopButton() { + fixture.withButtonId("np_stop") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).stop( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("handles pause button") + void onButtonInteraction_handlesPauseButton() { + fixture.withButtonId("np_pause") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).pause( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("handles skip button") + void onButtonInteraction_handlesSkipButton() { + fixture.withButtonId("np_skip") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).skip( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("skip adapter suppresses success replies") + void onButtonInteraction_skipAdapter_suppressesSuccessReplies() { + fixture.withButtonId("np_skip") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + ArgumentCaptor<MusicService.OutputAdapter> captor = ArgumentCaptor.forClass(MusicService.OutputAdapter.class); + verify(fixture.getMusicService()).skip( + eq(fixture.getGuild()), + eq(fixture.getMember()), + captor.capture() + ); + + MusicService.OutputAdapter adapter = captor.getValue(); + adapter.replySuccess("Skipped!"); + verify(fixture.getButtonInteractionEvent(), never()).reply("Skipped!"); + } + + @Test + @DisplayName("handles previous button") + void onButtonInteraction_handlesPreviousButton() { + fixture.withButtonId("np_previous") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).previous( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("previous adapter suppresses success replies") + void onButtonInteraction_previousAdapter_suppressesSuccessReplies() { + fixture.withButtonId("np_previous") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + ArgumentCaptor<MusicService.OutputAdapter> captor = ArgumentCaptor.forClass(MusicService.OutputAdapter.class); + verify(fixture.getMusicService()).previous( + eq(fixture.getGuild()), + eq(fixture.getMember()), + captor.capture() + ); + + MusicService.OutputAdapter adapter = captor.getValue(); + adapter.replySuccess("Went back to **test**"); + verify(fixture.getButtonInteractionEvent(), never()).reply("Went back to **test**"); + } + + @Test + @DisplayName("handles shuffle button") + void onButtonInteraction_handlesShuffleButton() { + fixture.withButtonId("np_shuffle") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).shuffle( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(0), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("shuffle adapter keeps success replies unchanged") + void onButtonInteraction_shuffleAdapter_keepsSuccessReplies() { + fixture.withButtonId("np_shuffle") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + ArgumentCaptor<MusicService.OutputAdapter> captor = ArgumentCaptor.forClass(MusicService.OutputAdapter.class); + verify(fixture.getMusicService()).shuffle( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(0), + captor.capture() + ); + + MusicService.OutputAdapter adapter = captor.getValue(); + adapter.replySuccess("Shuffled!"); + verify(fixture.getButtonInteractionEvent()).reply("Shuffled!"); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("handles repeat button") + void onButtonInteraction_handlesRepeatButton() { + fixture.withButtonId("np_repeat") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).cycleRepeatMode( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("handles favorite button") + void onButtonInteraction_handlesFavoriteButton() { + fixture.withButtonId("np_favorite") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).addCurrentTrackToFavorites( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + + ArgumentCaptor<MusicService.OutputAdapter> captor = ArgumentCaptor.forClass(MusicService.OutputAdapter.class); + verify(fixture.getMusicService()).addCurrentTrackToFavorites( + eq(fixture.getGuild()), + eq(fixture.getMember()), + captor.capture() + ); + + MusicService.OutputAdapter adapter = captor.getValue(); + adapter.replySuccess("ok"); + verify(fixture.getButtonInteractionEvent()).reply("ok"); + verify(fixture.getReplyAction(), never()).setEphemeral(true); + + clearInvocations(fixture.getButtonInteractionEvent(), fixture.getReplyAction()); + adapter.replyWarning("dup"); + verify(fixture.getButtonInteractionEvent()).reply("dup"); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("favorite adapter uses hook followup after interaction is acknowledged") + void onButtonInteraction_favoriteAdapterUsesHookAfterAcknowledged() { + fixture.withButtonId("np_favorite") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + ArgumentCaptor<MusicService.OutputAdapter> captor = ArgumentCaptor.forClass(MusicService.OutputAdapter.class); + verify(fixture.getMusicService()).addCurrentTrackToFavorites( + eq(fixture.getGuild()), + eq(fixture.getMember()), + captor.capture() + ); + + when(fixture.getButtonInteractionEvent().isAcknowledged()).thenReturn(true); + InteractionHook hook = mock(InteractionHook.class); + WebhookMessageCreateAction<Message> followup = mock(WebhookMessageCreateAction.class); + when(fixture.getButtonInteractionEvent().getHook()).thenReturn(hook); + when(hook.sendMessage(anyString())).thenReturn(followup); + when(followup.setEphemeral(true)).thenReturn(followup); + + MusicService.OutputAdapter adapter = captor.getValue(); + adapter.replySuccess("ok"); + verify(hook).sendMessage("ok"); + verify(fixture.getButtonInteractionEvent(), never()).reply("ok"); + verify(followup, never()).setEphemeral(true); + + adapter.replyWarning("dup"); + verify(hook).sendMessage("dup"); + verify(followup).setEphemeral(true); + + adapter.replyError("err"); + verify(hook).sendMessage("err"); + verify(followup, times(2)).setEphemeral(true); + } + + @Test + @DisplayName("handles voldown button") + void onButtonInteraction_handlesVoldownButton() { + fixture.withButtonId("np_voldown") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).adjustVolume( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(-10), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("handles volup button") + void onButtonInteraction_handlesVolupButton() { + fixture.withButtonId("np_volup") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).adjustVolume( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(10), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("replies error when no audio handler") + void onButtonInteraction_repliesErrorWhenNoHandler() { + fixture.withButtonId("np_stop") + .withNoAudioHandler(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply("There is no music playing!"); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("replies error when user not in voice") + void onButtonInteraction_repliesErrorWhenUserNotInVoice() { + fixture.withButtonId("np_stop") + .withMemberNotInVoiceChannel() + .withAudioHandlerPlaying(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply("You must be in the same voice channel to use this!"); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("handles null guild gracefully") + void onButtonInteraction_handlesNullGuild() { + fixture.withButtonId("np_stop"); + when(fixture.getButtonInteractionEvent().getGuild()).thenReturn(null); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService(), never()).stop(any(), any(), any()); + } + + @Test + @DisplayName("handles null member gracefully") + void onButtonInteraction_handlesNullMember() { + fixture.withButtonId("np_stop"); + when(fixture.getButtonInteractionEvent().getMember()).thenReturn(null); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService(), never()).stop(any(), any(), any()); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/PlaylistsInteractionListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/PlaylistsInteractionListenerTest.java new file mode 100644 index 0000000..ebd9931 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/PlaylistsInteractionListenerTest.java @@ -0,0 +1,299 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.listener.PlaylistsInteractionListener; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.testutil.listener.ListenerTestFixture; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.requests.restaction.interactions.MessageEditCallbackAction; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("PlaylistsInteractionListener Tests") +class PlaylistsInteractionListenerTest +{ + private ListenerTestFixture fixture; + private PlaylistsInteractionListener listener; + + @BeforeEach + void setUp() + { + fixture = ListenerTestFixture.create(); + listener = new PlaylistsInteractionListener(fixture.getBot()); + when(fixture.getButtonInteractionEvent().getUser()).thenReturn(fixture.getUser()); + } + + @Test + @DisplayName("onButtonInteraction() handles playlists_ button with invalid format and replies error") + void onButtonInteraction_playlistsInvalidFormat_repliesError() + { + fixture.withButtonId("playlists_ab"); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply(argThat((String s) -> s.contains("Invalid button state"))); + verify(fixture.getReplyAction()).setEphemeral(true); + verify(fixture.getMusicService(), never()).queuePlaylist(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("onButtonInteraction() blocks users other than command invoker") + void onButtonInteraction_wrongUser_repliesError() + { + fixture.withButtonId("playlists_prev_1_0_999"); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply(argThat((String s) -> s.contains("Only the user who ran the command"))); + verify(fixture.getReplyAction()).setEphemeral(true); + verify(fixture.getMusicService(), never()).queuePlaylist(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("onButtonInteraction() queue action dispatches to MusicService") + void onButtonInteraction_queue_dispatchesToMusicService() + { + fixture.withButtonId("playlists_queue_1_1_" + fixture.getUser().getIdLong()) + .withMemberInVoiceChannel(); + + when(fixture.getMusicService().getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite"))); + + MessageChannelUnion channelUnion = mock(MessageChannelUnion.class); + when(channelUnion.asTextChannel()).thenReturn(fixture.getTextChannel()); + when(fixture.getButtonInteractionEvent().getChannel()).thenReturn(channelUnion); + + MessageEditCallbackAction editAction = mock(MessageEditCallbackAction.class); + when(fixture.getButtonInteractionEvent().editMessageEmbeds(any(MessageEmbed[].class))).thenReturn(editAction); + when(editAction.setComponents(anyList())).thenReturn(editAction); + doNothing().when(editAction).queue(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).queuePlaylist( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq("favorite"), + eq(fixture.getTextChannel()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() rejects select action not present on current page") + void onButtonInteraction_selectOutsidePage_repliesError() + { + fixture.withButtonId("playlists_select11_1_0_" + fixture.getUser().getIdLong()); + when(fixture.getMusicService().getAvailablePlaylistNames()).thenReturn(MusicService.PlaylistNamesInfo.success(List.of( + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11" + ))); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply(argThat((String s) -> s.contains("isn't on this page"))); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("onButtonInteraction() playlistdetails queueall dispatches full playlist queue") + void onButtonInteraction_playlistDetailsQueueAll_dispatchesToMusicService() + { + fixture.withButtonId("playlistdetails_1_1_queueall_1_0_" + fixture.getUser().getIdLong()) + .withMemberInVoiceChannel(); + when(fixture.getMusicService().getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite"))); + when(fixture.getMusicService().getPlaylistDetails("favorite")) + .thenReturn(new MusicService.PlaylistDetailsInfo("favorite", 3, List.of(), false)); + when(fixture.getMusicService().getPlaylistTrackCount(any())).thenReturn(3); + + MessageChannelUnion channelUnion = mock(MessageChannelUnion.class); + when(channelUnion.asTextChannel()).thenReturn(fixture.getTextChannel()); + when(channelUnion.getIdLong()).thenReturn(123L); + when(fixture.getButtonInteractionEvent().getChannel()).thenReturn(channelUnion); + when(fixture.getButtonInteractionEvent().getMessageIdLong()).thenReturn(456L); + + MessageEditCallbackAction editAction = mock(MessageEditCallbackAction.class); + when(fixture.getButtonInteractionEvent().editMessageEmbeds(any(MessageEmbed[].class))).thenReturn(editAction); + when(editAction.setComponents(anyList())).thenReturn(editAction); + doNothing().when(editAction).queue(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).queuePlaylist( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq("favorite"), + eq(fixture.getTextChannel()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() playlistdetails remove requires DJ or owner") + void onButtonInteraction_playlistDetailsRemove_requiresDjOrOwner() + { + fixture.withButtonId("playlistdetails_1_1_remove_1_1_" + fixture.getUser().getIdLong()); + when(fixture.getMusicService().getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite"))); + when(fixture.getMusicService().getPlaylistDetails("favorite")) + .thenReturn(new MusicService.PlaylistDetailsInfo("favorite", 3, List.of(), false)); + when(fixture.getMusicService().getPlaylistTrackCount(any())).thenReturn(3); + when(fixture.getMusicService().canEditPlaylistEntries(eq(fixture.getGuild()), eq(fixture.getMember()))) + .thenReturn(false); + when(fixture.getButtonInteractionEvent().getMessageIdLong()).thenReturn(456L); + + MessageChannelUnion channelUnion = mock(MessageChannelUnion.class); + when(channelUnion.getIdLong()).thenReturn(123L); + when(fixture.getButtonInteractionEvent().getChannel()).thenReturn(channelUnion); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply(argThat((String s) -> s.contains("DJ or the bot owner"))); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("onButtonInteraction() playlistdetails unselect re-renders details unselected state") + void onButtonInteraction_playlistDetailsUnselect_rerendersDetails() + { + fixture.withButtonId("playlistdetails_1_1_unselect_1_4_" + fixture.getUser().getIdLong()); + when(fixture.getMusicService().getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite"))); + when(fixture.getMusicService().getPlaylistDetails("favorite")) + .thenReturn(new MusicService.PlaylistDetailsInfo("favorite", 4, List.of(), false)); + when(fixture.getMusicService().getPlaylistTrackCount(any())).thenReturn(4); + when(fixture.getMusicService().canEditPlaylistEntries(eq(fixture.getGuild()), eq(fixture.getMember()))) + .thenReturn(true); + MusicService.PlaylistDraftContext draftContext = + new MusicService.PlaylistDraftContext(123456789L, 123L, 456L, fixture.getUser().getIdLong(), "favorite"); + when(fixture.getMusicService().buildPlaylistDraftContext(anyLong(), anyLong(), anyLong(), anyLong(), anyString())) + .thenReturn(draftContext); + doAnswer(invocation -> + { + java.util.function.Consumer<MusicService.PlaylistTracksPageInfo> callback = + invocation.getArgument(3); + callback.accept(new MusicService.PlaylistTracksPageInfo( + "favorite", 4, 0, false, false, List.of( + "`[03:57]` [**spinnin**](https://example/1)", + "`[05:57]` [**daylight**](https://example/2)", + "`[04:31]` [**rapture**](https://example/3)", + "`[06:40]` [**flame**](https://example/4)" + ))); + return null; + }).when(fixture.getMusicService()).loadPlaylistPageWithTracks(any(MusicService.PlaylistDraftContext.class), anyInt(), anyInt(), any()); + + MessageChannelUnion channelUnion = mock(MessageChannelUnion.class); + when(channelUnion.getIdLong()).thenReturn(123L); + when(fixture.getButtonInteractionEvent().getChannel()).thenReturn(channelUnion); + when(fixture.getButtonInteractionEvent().getMessageIdLong()).thenReturn(456L); + + MessageEditCallbackAction editAction = mock(MessageEditCallbackAction.class); + when(fixture.getButtonInteractionEvent().editMessageEmbeds(any(MessageEmbed[].class))).thenReturn(editAction); + when(editAction.setComponents(anyList())).thenReturn(editAction); + doNothing().when(editAction).queue(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent(), never()).reply(argThat((String s) -> s.contains("No track selected"))); + verify(fixture.getMusicService()).loadPlaylistPageWithTracks(any(MusicService.PlaylistDraftContext.class), anyInt(), anyInt(), any()); + } + + @Test + @DisplayName("onButtonInteraction() playlistdetails cancelmove returns to selected details") + void onButtonInteraction_playlistDetailsCancelMove_rerendersSelectedDetails() + { + fixture.withButtonId("playlistdetails_1_1_cancelmove_1_2_" + fixture.getUser().getIdLong()); + when(fixture.getMusicService().getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite"))); + when(fixture.getMusicService().getPlaylistDetails("favorite")) + .thenReturn(new MusicService.PlaylistDetailsInfo("favorite", 4, List.of(), false)); + when(fixture.getMusicService().getPlaylistTrackCount(any())).thenReturn(4); + when(fixture.getMusicService().canEditPlaylistEntries(eq(fixture.getGuild()), eq(fixture.getMember()))) + .thenReturn(true); + MusicService.PlaylistDraftContext draftContext = + new MusicService.PlaylistDraftContext(123456789L, 123L, 456L, fixture.getUser().getIdLong(), "favorite"); + when(fixture.getMusicService().buildPlaylistDraftContext(anyLong(), anyLong(), anyLong(), anyLong(), anyString())) + .thenReturn(draftContext); + doAnswer(invocation -> + { + java.util.function.Consumer<MusicService.PlaylistTracksPageInfo> callback = + invocation.getArgument(3); + callback.accept(new MusicService.PlaylistTracksPageInfo( + "favorite", 4, 0, false, false, List.of( + "`[03:57]` [**spinnin**](https://example/1)", + "`[05:57]` [**daylight**](https://example/2)", + "`[04:31]` [**rapture**](https://example/3)", + "`[06:40]` [**flame**](https://example/4)" + ))); + return null; + }).when(fixture.getMusicService()).loadPlaylistPageWithTracks(any(MusicService.PlaylistDraftContext.class), anyInt(), anyInt(), any()); + + MessageChannelUnion channelUnion = mock(MessageChannelUnion.class); + when(channelUnion.getIdLong()).thenReturn(123L); + when(fixture.getButtonInteractionEvent().getChannel()).thenReturn(channelUnion); + when(fixture.getButtonInteractionEvent().getMessageIdLong()).thenReturn(456L); + + MessageEditCallbackAction editAction = mock(MessageEditCallbackAction.class); + when(fixture.getButtonInteractionEvent().editMessageEmbeds(any(MessageEmbed[].class))).thenReturn(editAction); + when(editAction.setComponents(anyList())).thenReturn(editAction); + doNothing().when(editAction).queue(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).loadPlaylistPageWithTracks(any(MusicService.PlaylistDraftContext.class), anyInt(), anyInt(), any()); + } + + @Test + @DisplayName("onButtonInteraction() playlistdetails back discards dirty draft before list view") + void onButtonInteraction_playlistDetailsBack_discardsDirtyDraft() + { + fixture.withButtonId("playlistdetails_back_1_1_" + fixture.getUser().getIdLong()); + when(fixture.getMusicService().getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite"))); + MusicService.PlaylistDraftContext draftContext = + new MusicService.PlaylistDraftContext(123456789L, 123L, 456L, fixture.getUser().getIdLong(), "favorite"); + when(fixture.getMusicService().buildPlaylistDraftContext(anyLong(), anyLong(), anyLong(), anyLong(), anyString())) + .thenReturn(draftContext); + when(fixture.getMusicService().isPlaylistDraftDirty(draftContext)).thenReturn(true); + + MessageChannelUnion channelUnion = mock(MessageChannelUnion.class); + when(channelUnion.getIdLong()).thenReturn(123L); + when(fixture.getButtonInteractionEvent().getChannel()).thenReturn(channelUnion); + when(fixture.getButtonInteractionEvent().getMessageIdLong()).thenReturn(456L); + + MessageEditCallbackAction editAction = mock(MessageEditCallbackAction.class); + when(fixture.getButtonInteractionEvent().editMessageEmbeds(any(MessageEmbed[].class))).thenReturn(editAction); + when(editAction.setComponents(anyList())).thenReturn(editAction); + doNothing().when(editAction).queue(); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getMusicService()).discardPlaylistDraft(draftContext); + verify(fixture.getButtonInteractionEvent()).editMessageEmbeds(any(MessageEmbed[].class)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/QueueInteractionListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/QueueInteractionListenerTest.java new file mode 100644 index 0000000..d571850 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/QueueInteractionListenerTest.java @@ -0,0 +1,67 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.listener.QueueInteractionListener; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.testutil.listener.ListenerTestFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("QueueInteractionListener Tests") +class QueueInteractionListenerTest +{ + private ListenerTestFixture fixture; + private QueueInteractionListener listener; + + @BeforeEach + void setUp() + { + fixture = ListenerTestFixture.create(); + listener = new QueueInteractionListener(fixture.getBot()); + when(fixture.getButtonInteractionEvent().getUser()).thenReturn(fixture.getUser()); + } + + @Test + @DisplayName("onButtonInteraction() rejects select action not present on current page") + void onButtonInteraction_selectOutsidePage_repliesError() + { + fixture.withButtonId("queue_select11_1_0_" + fixture.getUser().getIdLong()) + .withMemberInVoiceChannel(); + MusicService.QueueInfo info = new MusicService.QueueInfo( + new String[]{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"}, + 0L, + null, + "", + RepeatMode.OFF, + QueueType.LINEAR, + null, + null + ); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(info); + + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + verify(fixture.getButtonInteractionEvent()).reply(org.mockito.ArgumentMatchers.argThat((String s) -> s.contains("isn't on this page"))); + verify(fixture.getReplyAction()).setEphemeral(true); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/StartupLifecycleListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/StartupLifecycleListenerTest.java new file mode 100644 index 0000000..3c84c60 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/StartupLifecycleListenerTest.java @@ -0,0 +1,107 @@ +/* + * 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.unit; + +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; +import com.jagrosh.jmusicbot.listener.StartupLifecycleListener; +import com.jagrosh.jmusicbot.testutil.listener.ListenerTestFixture; +import net.dv8tion.jda.api.requests.CloseCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@DisplayName("StartupLifecycleListener Tests") +public class StartupLifecycleListenerTest { + + private ListenerTestFixture fixture; + private StartupLifecycleListener listener; + + @BeforeEach + void setUp() { + fixture = ListenerTestFixture.create(); + listener = new StartupLifecycleListener(fixture.getBot()); + } + + @Nested + @DisplayName("onSessionDisconnect") + class OnSessionDisconnectTests { + @Test + @DisplayName("shows error alert when close code is DISALLOWED_INTENTS") + void onSessionDisconnect_showsAlertForDisallowedIntents() { + fixture.withCloseCode(CloseCode.DISALLOWED_INTENTS); + + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + verify(fixture.getUserInteraction()).alert( + eq(Level.ERROR), + eq("JMusicBot"), + contains("missing required Discord intents") + ); + } + + @Test + @DisplayName("does not show alert for null close code") + void onSessionDisconnect_doesNothingForNullCloseCode() { + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + verify(fixture.getUserInteraction(), never()).alert(any(), any(), any()); + } + + @Test + @DisplayName("does not show alert for other close codes") + void onSessionDisconnect_doesNothingForOtherCloseCodes() { + fixture.withCloseCode(CloseCode.GRACEFUL_CLOSE); + + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + verify(fixture.getUserInteraction(), never()).alert(any(), any(), any()); + } + + @Test + @DisplayName("error message includes instructions for enabling intents") + void onSessionDisconnect_messageIncludesInstructions() { + fixture.withCloseCode(CloseCode.DISALLOWED_INTENTS); + + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + verify(fixture.getUserInteraction()).alert( + eq(Level.ERROR), + eq("JMusicBot"), + argThat(message -> + message.contains("discord.com/developers/applications") && + message.contains("MESSAGE CONTENT INTENT") && + message.contains("Privileged Gateway Intents") + ) + ); + } + } + + @Nested + @DisplayName("onShutdown") + class OnShutdownTests { + @Test + @DisplayName("calls bot.shutdown()") + void onShutdown_callsBotShutdown() { + listener.onShutdown(fixture.getShutdownEvent()); + + verify(fixture.getBot()).shutdown(); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/VoiceStateListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/VoiceStateListenerTest.java new file mode 100644 index 0000000..a8f3767 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/VoiceStateListenerTest.java @@ -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.unit; + +import com.jagrosh.jmusicbot.listener.VoiceStateListener; +import com.jagrosh.jmusicbot.testutil.listener.ListenerTestFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +@DisplayName("VoiceStateListener Tests") +public class VoiceStateListenerTest { + + private ListenerTestFixture fixture; + private VoiceStateListener listener; + + @BeforeEach + void setUp() { + fixture = ListenerTestFixture.create(); + listener = new VoiceStateListener(fixture.getBot()); + } + + @Nested + @DisplayName("onGuildVoiceUpdate") + class OnGuildVoiceUpdateTests { + @Test + @DisplayName("delegates to AloneInVoiceHandler") + void onGuildVoiceUpdate_delegatesToAloneInVoiceHandler() { + listener.onGuildVoiceUpdate(fixture.getGuildVoiceUpdateEvent()); + + verify(fixture.getAloneInVoiceHandler()).onVoiceUpdate(fixture.getGuildVoiceUpdateEvent()); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/audio/AloneInVoiceHandlerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AloneInVoiceHandlerTest.java new file mode 100644 index 0000000..ab32758 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AloneInVoiceHandlerTest.java @@ -0,0 +1,91 @@ +package com.jagrosh.jmusicbot.unit.audio; + +import com.jagrosh.jmusicbot.TestBase; +import com.jagrosh.jmusicbot.audio.AloneInVoiceHandler; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.util.Collections; + +import static org.mockito.Mockito.*; + +public class AloneInVoiceHandlerTest extends TestBase { + + @Mock + private GuildVoiceUpdateEvent voiceUpdateEvent; + @Mock + private GuildVoiceState voiceState; + + private AloneInVoiceHandler aloneInVoiceHandler; + + @BeforeEach + @Override + public void setUp() { + super.setUp(); + when(voiceUpdateEvent.getEntity()).thenReturn(member); + + aloneInVoiceHandler = new AloneInVoiceHandler(bot); + } + + @Test + public void testOnVoiceUpdateWhenDisabled() { + when(config.getAloneTimeUntilStop()).thenReturn(0L); + aloneInVoiceHandler.init(); + + aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent); + + verify(playerManager, never()).hasHandler(any()); + } + + @Test + public void testOnVoiceUpdateWhenAlone() { + when(config.getAloneTimeUntilStop()).thenReturn(300L); + aloneInVoiceHandler.init(); + when(playerManager.hasHandler(guild)).thenReturn(true); + when(audioManager.getConnectedChannel()).thenReturn(audioChannel); + when(audioChannel.getMembers()).thenReturn(Collections.singletonList(member)); + when(member.getUser()).thenReturn(user); + when(member.getVoiceState()).thenReturn(voiceState); + when(user.isBot()).thenReturn(true); // Only bot is in channel + + aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent); + } + + @Test + public void testIsAlone() throws Exception { + // Since isAlone is private, we test it through onVoiceUpdate or use reflection if needed. + // But we can test the behavior of onVoiceUpdate which uses isAlone. + + when(config.getAloneTimeUntilStop()).thenReturn(300L); + aloneInVoiceHandler.init(); + when(playerManager.hasHandler(guild)).thenReturn(true); + + // Not alone (human in channel) + when(audioManager.getConnectedChannel()).thenReturn(audioChannel); + Member human = mock(Member.class); + User humanUser = mock(User.class); + GuildVoiceState humanVoiceState = mock(GuildVoiceState.class); + when(human.getUser()).thenReturn(humanUser); + when(human.getVoiceState()).thenReturn(humanVoiceState); + when(humanUser.isBot()).thenReturn(false); + when(humanVoiceState.isDeafened()).thenReturn(false); + when(audioChannel.getMembers()).thenReturn(Collections.singletonList(human)); + + aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent); + // Should not be in aloneSince + + // Alone (only bot) + when(user.isBot()).thenReturn(true); + when(member.getVoiceState()).thenReturn(voiceState); + when(audioChannel.getMembers()).thenReturn(Collections.singletonList(member)); + when(member.getUser()).thenReturn(user); + + aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent); + // Should be in aloneSince + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java new file mode 100644 index 0000000..6cfdd6d --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java @@ -0,0 +1,464 @@ +/* + * 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.unit.audio; + +import com.jagrosh.jmusicbot.TestBase; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.audio.QueuedTrack; +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import net.dv8tion.jda.api.entities.SelfMember; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@DisplayName("AudioHandler Tests") +public class AudioHandlerTest extends TestBase { + + @Mock + private SelfMember selfMember; + @Mock + private GuildVoiceState voiceState; + + private AudioHandler audioHandler; + + @BeforeEach + @Override + public void setUp() { + super.setUp(); + when(settings.getQueueType()).thenReturn(QueueType.FAIR); + when(settings.getRepeatMode()).thenReturn(RepeatMode.OFF); + + // AudioHandler's constructor is not visible, so use reflection to instantiate it for testing + try { + var constructor = AudioHandler.class.getDeclaredConstructor( + playerManager.getClass().getInterfaces().length > 0 ? playerManager.getClass().getInterfaces()[0] : playerManager.getClass(), + guild.getClass().getInterfaces().length > 0 ? guild.getClass().getInterfaces()[0] : guild.getClass(), + audioPlayer.getClass().getInterfaces().length > 0 ? audioPlayer.getClass().getInterfaces()[0] : audioPlayer.getClass() + ); + constructor.setAccessible(true); + audioHandler = (AudioHandler) constructor.newInstance(playerManager, guild, audioPlayer); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate AudioHandler via reflection", e); + } + } + + // ==================== Add Track Tests ==================== + + @Nested + @DisplayName("Add Track Operations") + class AddTrackTests + { + @Test + @DisplayName("addTrack() plays immediately when nothing is playing") + public void testAddTrackWhenNothingPlaying() { + QueuedTrack qtrack = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + when(qtrack.getTrack()).thenReturn(track); + when(audioPlayer.getPlayingTrack()).thenReturn(null); + + int result = audioHandler.addTrack(qtrack); + + assertEquals(-1, result); + verify(audioPlayer).playTrack(track); + } + + @Test + @DisplayName("addTrack() queues track when something is playing") + public void testAddTrackWhenSomethingPlaying() { + QueuedTrack qtrack = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 1000, "identifier", true, "uri"); + when(track.getInfo()).thenReturn(info); + when(qtrack.getTrack()).thenReturn(track); + when(audioPlayer.getPlayingTrack()).thenReturn(mock(AudioTrack.class)); + + int result = audioHandler.addTrack(qtrack); + + assertTrue(result >= 0); + assertEquals(1, audioHandler.getQueue().size()); + } + + @Test + @DisplayName("addTrackToFront() plays immediately when nothing is playing") + public void testAddTrackToFrontWhenNothingPlaying() { + QueuedTrack qtrack = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + when(qtrack.getTrack()).thenReturn(track); + when(audioPlayer.getPlayingTrack()).thenReturn(null); + + int result = audioHandler.addTrackToFront(qtrack); + + assertEquals(-1, result); + verify(audioPlayer).playTrack(track); + } + + @Test + @DisplayName("addTrackToFront() adds to position 0 when something is playing") + public void testAddTrackToFrontWhenSomethingPlaying() { + // First add a track to the queue + QueuedTrack qtrack1 = mock(QueuedTrack.class); + AudioTrack track1 = mock(AudioTrack.class); + AudioTrackInfo info1 = new AudioTrackInfo("Track 1", "Author", 1000, "id1", true, "uri1"); + when(track1.getInfo()).thenReturn(info1); + when(qtrack1.getTrack()).thenReturn(track1); + when(audioPlayer.getPlayingTrack()).thenReturn(mock(AudioTrack.class)); + audioHandler.addTrack(qtrack1); + + // Now add to front + QueuedTrack qtrack2 = mock(QueuedTrack.class); + AudioTrack track2 = mock(AudioTrack.class); + AudioTrackInfo info2 = new AudioTrackInfo("Track 2", "Author", 1000, "id2", true, "uri2"); + when(track2.getInfo()).thenReturn(info2); + when(qtrack2.getTrack()).thenReturn(track2); + + int result = audioHandler.addTrackToFront(qtrack2); + + assertEquals(0, result); + assertEquals(2, audioHandler.getQueue().size()); + } + } + + // ==================== Stop and Clear Tests ==================== + + @Nested + @DisplayName("Stop and Clear Operations") + class StopAndClearTests + { + @Test + @DisplayName("stopAndClear() stops playback and clears queue") + public void testStopAndClear() { + audioHandler.stopAndClear(); + + verify(audioPlayer).stopTrack(); + assertTrue(audioHandler.getQueue().isEmpty()); + } + + @Test + @DisplayName("stopAndClear() can be called multiple times safely") + public void testStopAndClearMultipleTimes() { + audioHandler.stopAndClear(); + audioHandler.stopAndClear(); + + verify(audioPlayer, times(2)).stopTrack(); + } + + @Test + @DisplayName("stopAndClearQueuePreserveHistory() does not add current track and clears queue only") + public void testStopAndClearQueuePreserveHistoryDoesNotAddCurrentTrack() { + AudioTrack playingTrack = mock(AudioTrack.class); + AudioTrackInfo playingInfo = new AudioTrackInfo("Playing", "Author", 1000, "playing-id", false, "uri"); + when(playingTrack.getInfo()).thenReturn(playingInfo); + when(audioPlayer.getPlayingTrack()).thenReturn(playingTrack); + + // Put one item in the queue to verify it gets cleared. + QueuedTrack queued = mock(QueuedTrack.class); + AudioTrack queuedTrack = mock(AudioTrack.class); + AudioTrackInfo queuedInfo = new AudioTrackInfo("Queued", "Author", 1000, "queued-id", false, "uri2"); + when(queuedTrack.getInfo()).thenReturn(queuedInfo); + when(queued.getTrack()).thenReturn(queuedTrack); + audioHandler.addTrack(queued); + + audioHandler.stopAndClearQueuePreserveHistory(); + + verify(audioPlayer).stopTrack(); + assertTrue(audioHandler.getQueue().isEmpty()); + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + } + + @Test + @DisplayName("stopAndClearQueuePreserveHistory() leaves history unchanged when nothing is playing") + public void testStopAndClearQueuePreserveHistoryNoCurrentTrack() { + when(audioPlayer.getPlayingTrack()).thenReturn(null); + + audioHandler.stopAndClearQueuePreserveHistory(); + + verify(audioPlayer).stopTrack(); + assertTrue(audioHandler.getQueue().isEmpty()); + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + } + } + + // ==================== isMusicPlaying Tests ==================== + + @Nested + @DisplayName("isMusicPlaying") + class IsMusicPlayingTests + { + @Test + @DisplayName("isMusicPlaying() returns true when connected and playing") + public void testIsMusicPlayingTrue() { + when(jda.getGuildById(anyLong())).thenReturn(guild); + when(guild.getSelfMember()).thenReturn(selfMember); + when(selfMember.getVoiceState()).thenReturn(voiceState); + when(voiceState.getChannel()).thenReturn(audioChannel); + when(audioPlayer.getPlayingTrack()).thenReturn(audioTrack); + + assertTrue(audioHandler.isMusicPlaying(jda)); + } + + @Test + @DisplayName("isMusicPlaying() returns false when not in voice channel") + public void testIsMusicPlayingFalseNotInVoice() { + when(jda.getGuildById(anyLong())).thenReturn(guild); + when(guild.getSelfMember()).thenReturn(selfMember); + when(selfMember.getVoiceState()).thenReturn(voiceState); + when(voiceState.getChannel()).thenReturn(null); + when(audioPlayer.getPlayingTrack()).thenReturn(audioTrack); + + assertFalse(audioHandler.isMusicPlaying(jda)); + } + + @Test + @DisplayName("isMusicPlaying() returns false when nothing is playing") + public void testIsMusicPlayingFalseNoTrack() { + when(jda.getGuildById(anyLong())).thenReturn(guild); + when(guild.getSelfMember()).thenReturn(selfMember); + when(selfMember.getVoiceState()).thenReturn(voiceState); + when(voiceState.getChannel()).thenReturn(audioChannel); + when(audioPlayer.getPlayingTrack()).thenReturn(null); + + assertFalse(audioHandler.isMusicPlaying(jda)); + } + } + + // ==================== Vote Tests ==================== + + @Nested + @DisplayName("Vote Tracking") + class VoteTests + { + @Test + @DisplayName("getVotes() returns empty set initially") + public void testGetVotesInitiallyEmpty() { + assertTrue(audioHandler.getVotes().isEmpty()); + } + + @Test + @DisplayName("votes can be added and retrieved") + public void testAddVote() { + audioHandler.getVotes().add("user123"); + + assertEquals(1, audioHandler.getVotes().size()); + assertTrue(audioHandler.getVotes().contains("user123")); + } + + @Test + @DisplayName("duplicate votes are not added") + public void testDuplicateVotes() { + audioHandler.getVotes().add("user123"); + audioHandler.getVotes().add("user123"); + + assertEquals(1, audioHandler.getVotes().size()); + } + } + + // ==================== Queue Operations Tests ==================== + + @Nested + @DisplayName("Queue Operations") + class QueueOperationsTests + { + @Test + @DisplayName("getQueue() returns non-null queue") + public void testGetQueueNotNull() { + assertNotNull(audioHandler.getQueue()); + } + + @Test + @DisplayName("queue starts empty") + public void testQueueStartsEmpty() { + assertTrue(audioHandler.getQueue().isEmpty()); + assertEquals(0, audioHandler.getQueue().size()); + } + + @Test + @DisplayName("setQueueType() changes queue type") + public void testSetQueueType() { + // Add a track first + QueuedTrack qtrack = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 1000, "identifier", true, "uri"); + when(track.getInfo()).thenReturn(info); + when(qtrack.getTrack()).thenReturn(track); + when(audioPlayer.getPlayingTrack()).thenReturn(mock(AudioTrack.class)); + audioHandler.addTrack(qtrack); + + // Change queue type + audioHandler.setQueueType(QueueType.LINEAR); + + // Queue should still exist + assertNotNull(audioHandler.getQueue()); + } + } + + // ==================== Player Access Tests ==================== + + @Nested + @DisplayName("Player Access") + class PlayerAccessTests + { + @Test + @DisplayName("getPlayer() returns the audio player") + public void testGetPlayer() { + assertEquals(audioPlayer, audioHandler.getPlayer()); + } + } + + // ==================== Last Reason Tests ==================== + + @Nested + @DisplayName("Last Reason") + class LastReasonTests + { + @Test + @DisplayName("setLastReason() stores reason") + public void testSetLastReason() { + // Just verify it doesn't throw + assertDoesNotThrow(() -> audioHandler.setLastReason("Test reason")); + } + } + + // ==================== Previous Tracks Tests ==================== + + @Nested + @DisplayName("Previous Tracks (History)") + class PreviousTracksTests + { + @Test + @DisplayName("getPreviousTracks() returns list") + public void testGetPreviousTracks() { + assertNotNull(audioHandler.getPreviousTracks()); + } + + @Test + @DisplayName("getPreviousTracks() starts empty") + public void testGetPreviousTracksStartsEmpty() { + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + } + } + + @Nested + @DisplayName("Track Start History") + class TrackStartHistoryTests + { + @Test + @DisplayName("onTrackStart() adds started track to history") + void onTrackStart_addsToHistory() + { + AudioTrack startedTrack = createTrack("started-track", "Started Track", "https://example.com/started-track"); + when(bot.getNowplayingHandler()).thenReturn(mock(com.jagrosh.jmusicbot.audio.NowPlayingHandler.class)); + + audioHandler.onTrackStart(audioPlayer, startedTrack); + + assertEquals(1, audioHandler.getPreviousTracks().size()); + } + + @Test + @DisplayName("onTrackEnd(FINISHED) does not add track to history") + void onTrackEnd_finished_doesNotAddToHistory() + { + AudioTrack playedTrack = createTrack("played-finished", "Played Finished", "https://example.com/played-finished"); + enqueueNextTrack("queued-finished", "Queued Finished", "https://example.com/queued-finished"); + + audioHandler.onTrackEnd(audioPlayer, playedTrack, AudioTrackEndReason.FINISHED); + + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + } + + @Test + @DisplayName("onTrackEnd(STOPPED) does not add track to history") + void onTrackEnd_stopped_doesNotAddToHistory() + { + AudioTrack playedTrack = createTrack("played-stopped", "Played Stopped", "https://example.com/played-stopped"); + enqueueNextTrack("queued-stopped", "Queued Stopped", "https://example.com/queued-stopped"); + + audioHandler.onTrackEnd(audioPlayer, playedTrack, AudioTrackEndReason.STOPPED); + + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + } + + @Test + @DisplayName("onTrackEnd(STOPPED) does not add history across repeated calls") + void onTrackEnd_stopped_repeatedCalls_doNotAddHistory() + { + AudioTrack firstStoppedTrack = createTrack("played-guard-1", "Played Guard 1", "https://example.com/played-guard-1"); + enqueueNextTrack("queued-guard-1", "Queued Guard 1", "https://example.com/queued-guard-1"); + audioHandler.onTrackEnd(audioPlayer, firstStoppedTrack, AudioTrackEndReason.STOPPED); + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + + AudioTrack secondStoppedTrack = createTrack("played-guard-2", "Played Guard 2", "https://example.com/played-guard-2"); + enqueueNextTrack("queued-guard-2", "Queued Guard 2", "https://example.com/queued-guard-2"); + audioHandler.onTrackEnd(audioPlayer, secondStoppedTrack, AudioTrackEndReason.STOPPED); + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + } + + @Test + @DisplayName("onTrackEnd(LOAD_FAILED) does not add track to history") + void onTrackEnd_loadFailed_doesNotAddToHistory() + { + AudioTrack playedTrack = createTrack("played-load-failed", "Played Load Failed", "https://example.com/played-load-failed"); + enqueueNextTrack("queued-load-failed", "Queued Load Failed", "https://example.com/queued-load-failed"); + + audioHandler.onTrackEnd(audioPlayer, playedTrack, AudioTrackEndReason.LOAD_FAILED); + + assertTrue(audioHandler.getPreviousTracks().isEmpty()); + } + + @Test + @DisplayName("stopAndClearQueuePreserveHistory() does not duplicate a track already added on start") + void stopAndClearQueuePreserveHistory_doesNotDuplicateTrackAlreadyInHistory() + { + AudioTrack startedTrack = createTrack("started-dedup", "Started Dedup", "https://example.com/started-dedup"); + when(audioPlayer.getPlayingTrack()).thenReturn(startedTrack); + when(bot.getNowplayingHandler()).thenReturn(mock(com.jagrosh.jmusicbot.audio.NowPlayingHandler.class)); + + audioHandler.onTrackStart(audioPlayer, startedTrack); + audioHandler.stopAndClearQueuePreserveHistory(); + + assertEquals(1, audioHandler.getPreviousTracks().size()); + } + + private AudioTrack createTrack(String identifier, String title, String uri) + { + AudioTrack track = mock(AudioTrack.class); + AudioTrack clone = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo(title, "Author", 1000, identifier, false, uri); + when(track.getInfo()).thenReturn(info); + when(track.getIdentifier()).thenReturn(identifier); + when(track.makeClone()).thenReturn(clone); + return track; + } + + private void enqueueNextTrack(String identifier, String title, String uri) + { + AudioTrack track = createTrack(identifier, title, uri); + QueuedTrack queuedTrack = mock(QueuedTrack.class); + when(queuedTrack.getTrack()).thenReturn(track); + when(queuedTrack.getIdentifier()).thenReturn(0L); + audioHandler.getQueue().add(queuedTrack); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/audio/NowPlayingHandlerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/audio/NowPlayingHandlerTest.java new file mode 100644 index 0000000..67cbbcf --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/NowPlayingHandlerTest.java @@ -0,0 +1,395 @@ +/* + * 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.unit.audio; + +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.audio.NowPlayingHandler; +import com.jagrosh.jmusicbot.audio.NowPlayingInfo; +import com.jagrosh.jmusicbot.audio.RequestMetadata; +import com.jagrosh.jmusicbot.testutil.audio.AudioTestFixture; +import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.RoleColors; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.managers.Presence; +import net.dv8tion.jda.api.requests.restaction.CacheRestAction; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import net.dv8tion.jda.api.utils.messages.MessageEditData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicReference; + +import static com.jagrosh.jmusicbot.testutil.TestConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for NowPlayingHandler. + * Uses AudioTestFixture for consistent mock setup. + */ +@DisplayName("NowPlayingHandler Tests") +public class NowPlayingHandlerTest +{ + private AudioTestFixture fixture; + private NowPlayingHandler nowPlayingHandler; + private Presence presence; + private AudioHandler audioHandler; + + @BeforeEach + void setUp() + { + fixture = AudioTestFixture.create(); + + // Setup presence mock + presence = mock(Presence.class); + when(fixture.getJda().getPresence()).thenReturn(presence); + + // Setup audio handler for the guild + audioHandler = mock(AudioHandler.class); + when(fixture.getAudioManager().getSendingHandler()).thenReturn(audioHandler); + when(audioHandler.getPlayer()).thenReturn(fixture.getAudioPlayer()); + + // Message formatter expects self member colors + RoleColors roleColors = mock(RoleColors.class); + when(roleColors.getPrimary()).thenReturn(java.awt.Color.WHITE); + when(fixture.getSelfMember().getColors()).thenReturn(roleColors); + + // Fallback alert path can look up owner user; return a mock action to avoid null behavior in tests + @SuppressWarnings("unchecked") + CacheRestAction<User> ownerLookup = mock(CacheRestAction.class); + when(fixture.getJda().retrieveUserById(anyLong())).thenReturn(ownerLookup); + + // Create handler + nowPlayingHandler = new NowPlayingHandler(fixture.getBot()); + } + + // ==================== Initialization Tests ==================== + + @Nested + @DisplayName("Initialization") + class InitializationTests + { + @Test + @DisplayName("init() schedules update task when progress bar is enabled") + void init_schedulesUpdateTask_whenProgressBarEnabled() + { + // Given - images off and progress bar on (both conditions required) + when(fixture.getConfig().useNPImages()).thenReturn(false); + when(fixture.getConfig().showNpProgressBar()).thenReturn(true); + + // When + nowPlayingHandler.init(); + + // Then + verify(fixture.getThreadpool()).scheduleWithFixedDelay(any(Runnable.class), eq(0L), eq(10L), any()); + } + + @Test + @DisplayName("init() does not schedule task when progress bar is disabled") + void init_doesNotScheduleTask_whenProgressBarDisabled() + { + // Given - images off but progress bar disabled + when(fixture.getConfig().useNPImages()).thenReturn(false); + when(fixture.getConfig().showNpProgressBar()).thenReturn(false); + + // When + nowPlayingHandler.init(); + + // Then + verify(fixture.getThreadpool(), never()).scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), any()); + } + } + + // ==================== setLastNPMessage Tests ==================== + + @Nested + @DisplayName("setLastNPMessage") + class SetLastNPMessageTests + { + @Test + @DisplayName("setLastNPMessage() stores message location") + void setLastNPMessage_storesLocation() + { + // When + nowPlayingHandler.setLastNPMessage(fixture.getMessage()); + + // Then - we can verify by calling clearLastNPMessage and checking no NPE + assertDoesNotThrow(() -> nowPlayingHandler.clearLastNPMessage(fixture.getGuild())); + } + } + + // ==================== clearLastNPMessage Tests ==================== + + @Nested + @DisplayName("clearLastNPMessage") + class ClearLastNPMessageTests + { + @Test + @DisplayName("clearLastNPMessage() removes stored location") + void clearLastNPMessage_removesLocation() + { + // Given + nowPlayingHandler.setLastNPMessage(fixture.getMessage()); + + // When + nowPlayingHandler.clearLastNPMessage(fixture.getGuild()); + + // Then - no exception thrown + assertDoesNotThrow(() -> nowPlayingHandler.clearLastNPMessage(fixture.getGuild())); + } + + @Test + @DisplayName("clearLastNPMessage() handles non-existent guild gracefully") + void clearLastNPMessage_handlesNonExistentGuild() + { + // When/Then - should not throw + assertDoesNotThrow(() -> nowPlayingHandler.clearLastNPMessage(fixture.getGuild())); + } + } + + // ==================== onMessageDelete Tests ==================== + + @Nested + @DisplayName("onMessageDelete") + class OnMessageDeleteTests + { + @Test + @DisplayName("onMessageDelete() removes matching message location") + void onMessageDelete_removesMatchingLocation() + { + // Given + nowPlayingHandler.setLastNPMessage(fixture.getMessage()); + + // When + nowPlayingHandler.onMessageDelete(fixture.getGuild(), MESSAGE_ID); + + // Then - location should be removed (verified by no update attempt) + // This is hard to verify directly, but the method should not throw + assertDoesNotThrow(() -> nowPlayingHandler.onMessageDelete(fixture.getGuild(), MESSAGE_ID)); + } + + @Test + @DisplayName("onMessageDelete() ignores non-matching message") + void onMessageDelete_ignoresNonMatchingMessage() + { + // Given + nowPlayingHandler.setLastNPMessage(fixture.getMessage()); + + // When - delete different message + nowPlayingHandler.onMessageDelete(fixture.getGuild(), 999999L); + + // Then - original location should still be stored + assertDoesNotThrow(() -> nowPlayingHandler.clearLastNPMessage(fixture.getGuild())); + } + } + + // ==================== onTrackUpdate Tests ==================== + + @Nested + @DisplayName("onTrackUpdate") + class OnTrackUpdateTests + { + @Test + @DisplayName("onTrackUpdate() updates status when song in status is enabled") + void onTrackUpdate_updatesStatus_whenSongInStatusEnabled() + { + // Given + fixture.withSongInStatus(); + AudioTrack track = fixture.createMockTrack("Test Song", "Artist", 180000); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(track); + when(audioHandler.getNowPlaying(fixture.getJda())).thenReturn(createNowPlayingMessage()); + + // When + nowPlayingHandler.onTrackUpdate(GUILD_ID, track); + + // Then + verify(presence).setActivity(argThat(activity -> + activity != null && activity.getType() == Activity.ActivityType.LISTENING + )); + } + + @Test + @DisplayName("onTrackUpdate() resets game when track is null and song in status is enabled") + void onTrackUpdate_resetsGame_whenTrackNullAndSongInStatusEnabled() + { + // Given + fixture.withSongInStatus(); + + // When + nowPlayingHandler.onTrackUpdate(GUILD_ID, null); + + // Then + verify(fixture.getBot()).resetGame(); + } + + @Test + @DisplayName("onTrackUpdate() does not update status when song in status is disabled") + void onTrackUpdate_doesNotUpdateStatus_whenSongInStatusDisabled() + { + // Given - song in status is disabled by default in fixture + AudioTrack track = fixture.createMockTrack("Test Song", "Artist", 180000); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(track); + when(audioHandler.getNowPlaying(fixture.getJda())).thenReturn(createNowPlayingMessage()); + + // When + nowPlayingHandler.onTrackUpdate(GUILD_ID, track); + + // Then + verify(presence, never()).setActivity(any()); + } + + @Test + @DisplayName("onTrackUpdate() sends now playing when track is active even if getNowPlaying() is null") + void onTrackUpdate_sendsNowPlaying_whenTrackPlayingEvenIfGetNowPlayingReturnsNull() + { + // Given + AudioTrack track = fixture.createMockTrack("Race Song", "Artist", 180000); + RequestMetadata metadata = new RequestMetadata(null, + new RequestMetadata.RequestInfo("playlist race", "https://example.com/race"), + CHANNEL_ID); + when(track.getUserData(RequestMetadata.class)).thenReturn(metadata); + AudioSourceManager sourceManager = mock(AudioSourceManager.class); + when(sourceManager.getSourceName()).thenReturn("youtube"); + when(track.getSourceManager()).thenReturn(sourceManager); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(track); + + when(audioHandler.getNowPlaying(fixture.getJda())).thenReturn(null); + NowPlayingInfo info = new NowPlayingInfo(track, fixture.getGuild(), false, 100, 0, "Testing"); + when(audioHandler.getNowPlayingInfo(fixture.getJda())).thenReturn(info); + + // When + nowPlayingHandler.onTrackUpdate(GUILD_ID, track); + + // Then + verify(fixture.getTextChannel()).sendMessage(any(MessageCreateData.class)); + } + + @Test + @DisplayName("onTrackUpdate() converges after pending reconcile once send callback arrives") + void onTrackUpdate_convergesAfterPendingReconcile_whenSendCallbackArrives() + { + // Given + AudioTrack track = fixture.createMockTrack("Race Song", "Artist", 180000); + RequestMetadata metadata = new RequestMetadata(null, + new RequestMetadata.RequestInfo("playlist race", "https://example.com/race"), + CHANNEL_ID); + when(track.getUserData(RequestMetadata.class)).thenReturn(metadata); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(track); + when(audioHandler.getNowPlaying(fixture.getJda())).thenReturn(createNowPlayingMessage()); + AtomicReference<Consumer<Message>> sendSuccess = new AtomicReference<>(); + doAnswer(invocation -> { + sendSuccess.set(invocation.getArgument(0)); + return null; + }).when(fixture.getMessageCreateAction()).queue(any(), any()); + + // When + nowPlayingHandler.onTrackUpdate(GUILD_ID, track); + nowPlayingHandler.requestReconcile(GUILD_ID, "playlist-loaded"); + sendSuccess.get().accept(fixture.getMessage()); + + // Then - second reconcile pass edits the tracked message after send completes + verify(fixture.getTextChannel()).sendMessage(any(MessageCreateData.class)); + verify(fixture.getTextChannel()).editMessageById(eq(MESSAGE_ID), any(MessageEditData.class)); + } + + @Test + @DisplayName("stale send callback does not overwrite newer tracked location") + void staleSendCallback_doesNotOverwriteNewerLocation() + { + // Given + AudioTrack track = fixture.createMockTrack("Race Song", "Artist", 180000); + RequestMetadata metadata = new RequestMetadata(null, + new RequestMetadata.RequestInfo("playlist race", "https://example.com/race"), + CHANNEL_ID); + when(track.getUserData(RequestMetadata.class)).thenReturn(metadata); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(track); + when(audioHandler.getNowPlaying(fixture.getJda())).thenReturn(createNowPlayingMessage()); + AtomicReference<Consumer<Message>> sendSuccess = new AtomicReference<>(); + doAnswer(invocation -> { + sendSuccess.set(invocation.getArgument(0)); + return null; + }).when(fixture.getMessageCreateAction()).queue(any(), any()); + + Message newerMessage = mock(Message.class); + when(newerMessage.getIdLong()).thenReturn(999999L); + when(newerMessage.getGuild()).thenReturn(fixture.getGuild()); + when(newerMessage.getChannel()).thenReturn((MessageChannelUnion) fixture.getTextChannel()); + + // When - first reconcile is in-flight + nowPlayingHandler.onTrackUpdate(GUILD_ID, track); + nowPlayingHandler.setLastNPMessage(newerMessage); // bumps version and tracked location + sendSuccess.get().accept(fixture.getMessage()); // stale callback from older version + nowPlayingHandler.requestReconcile(GUILD_ID, "manual-check"); + + // Then - edit uses newer tracked location, not stale callback's message id + verify(fixture.getTextChannel()).editMessageById(eq(999999L), any(MessageEditData.class)); + verify(fixture.getTextChannel(), never()).editMessageById(eq(MESSAGE_ID), any(MessageEditData.class)); + } + } + + // ==================== Edge Case Tests ==================== + + @Nested + @DisplayName("Edge Cases") + class EdgeCaseTests + { + @Test + @DisplayName("handles null guild from JDA gracefully") + void handlesNullGuildGracefully() + { + // Given + when(fixture.getJda().getGuildById(GUILD_ID)).thenReturn(null); + + // When/Then - should not throw + assertDoesNotThrow(() -> nowPlayingHandler.onTrackUpdate(GUILD_ID, null)); + } + + @Test + @DisplayName("handles multiple setLastNPMessage calls") + void handlesMultipleSetLastNPMessageCalls() + { + // Given + Message message2 = mock(Message.class); + when(message2.getIdLong()).thenReturn(999999L); + when(message2.getGuild()).thenReturn(fixture.getGuild()); + when(message2.getChannel()).thenReturn((MessageChannelUnion) fixture.getTextChannel()); + + // When + nowPlayingHandler.setLastNPMessage(fixture.getMessage()); + nowPlayingHandler.setLastNPMessage(message2); + + // Then - should not throw + assertDoesNotThrow(() -> nowPlayingHandler.clearLastNPMessage(fixture.getGuild())); + } + } + + private static MessageCreateData createNowPlayingMessage() + { + return new MessageCreateBuilder() + .setEmbeds(new EmbedBuilder().setDescription("NP").build()) + .build(); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/audio/PlayerManagerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/audio/PlayerManagerTest.java new file mode 100644 index 0000000..8331f15 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/PlayerManagerTest.java @@ -0,0 +1,151 @@ +/* + * 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.unit.audio; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.audio.AudioSource; +import com.jagrosh.jmusicbot.audio.PlayerManager; +import com.typesafe.config.ConfigFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.atLeast; + +@DisplayName("PlayerManager Tests") +class PlayerManagerTest { + + @Mock + private Bot bot; + + @Mock + private BotConfig config; + + private PlayerManager playerManager; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(bot.getConfig()).thenReturn(config); + + // Default config mock setup + when(config.getFrameBufferMs()).thenReturn(2000); + when(config.getTransforms()).thenReturn(ConfigFactory.empty()); + when(config.getEnabledAudioSources()).thenReturn(Set.of()); // Empty to avoid source registration + + playerManager = new PlayerManager(bot); + } + + @Nested + @DisplayName("Proxy Configuration Tests") + class ProxyConfigurationTests { + + @Test + @DisplayName("init() does not configure proxy when proxyLavaplayer is false") + void initDoesNotConfigureProxyWhenDisabled() { + when(config.proxyLavaplayer()).thenReturn(false); + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn("127.0.0.1"); + when(config.getProxyPort()).thenReturn(8080); + + playerManager.init(); + + // Verify proxy settings were checked + verify(config).proxyLavaplayer(); + // hasProxy() should not be called when proxyLavaplayer() is false + // (short-circuit evaluation) + } + + @Test + @DisplayName("init() does not configure proxy when hasProxy is false") + void initDoesNotConfigureProxyWhenNoProxy() { + when(config.proxyLavaplayer()).thenReturn(true); + when(config.hasProxy()).thenReturn(false); + + playerManager.init(); + + // Verify both conditions were checked + verify(config).proxyLavaplayer(); + verify(config).hasProxy(); + } + + @Test + @DisplayName("init() configures proxy when both conditions are true") + void initConfiguresProxyWhenEnabled() { + when(config.proxyLavaplayer()).thenReturn(true); + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn("127.0.0.1"); + when(config.getProxyPort()).thenReturn(8080); + + playerManager.init(); + + // Verify proxy configuration was accessed + // Note: These may be called multiple times (PlayerManager + ProxyUtil) + verify(config).proxyLavaplayer(); + verify(config, atLeast(1)).hasProxy(); + verify(config, atLeast(1)).getProxyHost(); + verify(config, atLeast(1)).getProxyPort(); + } + + @Test + @DisplayName("init() logs proxy configuration when enabled") + void initLogsProxyConfigurationWhenEnabled() { + when(config.proxyLavaplayer()).thenReturn(true); + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn("proxy.example.com"); + when(config.getProxyPort()).thenReturn(18080); + + // This test verifies the code path executes without error + // Actual logging verification would require a logging framework mock + assertDoesNotThrow(() -> playerManager.init()); + } + } + + @Nested + @DisplayName("getBot() Tests") + class GetBotTests { + + @Test + @DisplayName("getBot() returns the bot instance") + void getBotReturnsBotInstance() { + assertEquals(bot, playerManager.getBot()); + } + } + + @Nested + @DisplayName("Frame Buffer Configuration Tests") + class FrameBufferConfigurationTests { + + @Test + @DisplayName("init() configures frame buffer from config") + void initConfiguresFrameBuffer() { + when(config.getFrameBufferMs()).thenReturn(3000); + when(config.proxyLavaplayer()).thenReturn(false); + + playerManager.init(); + + verify(config).getFrameBufferMs(); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/audio/YoutubeLoginDetectionTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/audio/YoutubeLoginDetectionTest.java new file mode 100644 index 0000000..1048320 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/YoutubeLoginDetectionTest.java @@ -0,0 +1,130 @@ +/* + * 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.unit.audio; + +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Regression tests for detecting "YouTube wants a signed-in session" playback failures. + * + * <p>Two independent defects stopped the OAuth hint in {@code onTrackException} from ever + * firing, so operators only ever saw a raw stack trace with no indication of the remedy: + * + * <ol> + * <li>The check tested {@code exception.getMessage()} with {@code equals}, but when every + * YouTube client fails youtube-source raises an {@code AllClientsFailedException} + * whose message is only "All clients failed to load the item." The per-client reasons + * hang off it as causes and suppressed exceptions.</li> + * <li>YouTube writes "you’re" with a typographic apostrophe (U+2019), while the code + * compared against an ASCII {@code '}, so even a direct match failed.</li> + * </ol> + * + * @see com.jagrosh.jmusicbot.audio.AudioHandler + */ +@DisplayName("YouTube Login Detection Tests") +class YoutubeLoginDetectionTest { + + @Test + @DisplayName("Detects the typographic apostrophe YouTube actually sends - regression test") + void detectsTypographicApostrophe() throws Exception { + // U+2019, exactly as it arrives from YouTube. + assertTrue(requiresLogin(new RuntimeException("Sign in to confirm you’re not a bot")), + "The bot-check message uses U+2019, not an ASCII apostrophe"); + } + + @Test + @DisplayName("Detects an ASCII apostrophe too") + void detectsAsciiApostrophe() throws Exception { + assertTrue(requiresLogin(new RuntimeException("Sign in to confirm you're not a bot")), + "An ASCII apostrophe should still match"); + } + + @Test + @DisplayName("Detects login markers nested in suppressed client failures - regression test") + void detectsMarkerInSuppressedExceptions() throws Exception { + // Mirrors the real shape: an aggregate wrapper whose own message says nothing + // actionable, carrying the per-client reasons as suppressed exceptions. + FriendlyException aggregate = new FriendlyException( + "(yts.version: 1.18.1) All clients failed to load the item.", + FriendlyException.Severity.COMMON, null); + aggregate.addSuppressed(new RuntimeException("Client [ANDROID_VR] failed: This video requires login.")); + aggregate.addSuppressed(new RuntimeException("Client [WEB] failed: No supported audio streams available")); + aggregate.addSuppressed(new RuntimeException( + "Client [TVHTML5_SIMPLY] failed: Sign in to confirm you’re not a bot")); + + assertTrue(requiresLogin(aggregate), + "The hint must fire when the reason is only present in suppressed client failures"); + } + + @Test + @DisplayName("Detects login markers nested in the cause chain") + void detectsMarkerInCauseChain() throws Exception { + Throwable root = new RuntimeException("This video requires login."); + Throwable middle = new RuntimeException("player response failure", root); + FriendlyException top = new FriendlyException( + "Something broke", FriendlyException.Severity.SUSPICIOUS, middle); + + assertTrue(requiresLogin(top), "The hint must follow the cause chain"); + } + + @Test + @DisplayName("Unrelated playback failures are not misreported as login problems") + void ignoresUnrelatedFailures() throws Exception { + assertFalse(requiresLogin(new RuntimeException("Connection reset by peer")), + "A network error is not a sign-in problem"); + + FriendlyException cipherFailure = new FriendlyException( + "All clients failed to load the item.", FriendlyException.Severity.COMMON, null); + cipherFailure.addSuppressed(new RuntimeException( + "Must find sig function from script: /s/player/8c3fda2d/base.js")); + assertFalse(requiresLogin(cipherFailure), + "A cipher extraction failure needs a cipher fix, not a sign-in"); + } + + @Test + @DisplayName("Null and empty messages are handled without throwing") + void handlesNullMessages() throws Exception { + assertFalse(requiresLogin(new RuntimeException((String) null)), + "A null message must not throw - the old code called equals() on it directly"); + assertFalse(requiresLogin(new RuntimeException("")), "An empty message should not match"); + } + + @Test + @DisplayName("Self-referencing cause chains terminate instead of recursing forever") + void handlesCyclicCauses() throws Exception { + // Java forbids self-suppression, so build a real two-node cycle: + // outer -> suppressed inner -> cause outer. + RuntimeException outer = new RuntimeException("This video requires login."); + RuntimeException inner = new RuntimeException("secondary client failure"); + outer.addSuppressed(inner); + inner.initCause(outer); + + assertTrue(requiresLogin(outer), "Cycles must be visited once, not endlessly"); + } + + private static boolean requiresLogin(Throwable throwable) throws Exception { + Method method = AudioHandler.class.getDeclaredMethod("requiresYoutubeLogin", Throwable.class); + method.setAccessible(true); + return (boolean) method.invoke(null, throwable); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/SlashCommandRegistryTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/SlashCommandRegistryTest.java new file mode 100644 index 0000000..4394b15 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/SlashCommandRegistryTest.java @@ -0,0 +1,326 @@ +package com.jagrosh.jmusicbot.unit.commands; + +import com.jagrosh.jdautilities.command.CommandClient; +import com.jagrosh.jdautilities.command.SlashCommand; +import com.jagrosh.jmusicbot.commands.SlashCommandRegistry; +import com.jagrosh.jmusicbot.utils.OtherUtil; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.interactions.commands.Command; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; +import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link SlashCommandRegistry} to verify: + * 1. First run (no hash file) => registers commands and creates hash file + * 2. Hash unchanged => skips registration + * 3. Hash changed => re-registers and updates hash file + */ +@SuppressWarnings("unchecked") // ArgumentCaptor with generics requires unchecked casts +public class SlashCommandRegistryTest { + + private static final String HASH_FILE_NAME = ".slashcommands.hash"; + + @TempDir + Path tempDir; + + @Mock + private JDA jda; + + @Mock + private CommandClient commandClient; + + @Mock + private CommandListUpdateAction commandListUpdateAction; + + @Mock + private SlashCommand slashCommand; + + @Mock + private SlashCommandData slashCommandData; + + private MockedStatic<OtherUtil> otherUtilMock; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + + // Mock OtherUtil.getPath to redirect to temp directory + otherUtilMock = mockStatic(OtherUtil.class); + otherUtilMock.when(() -> OtherUtil.getPath(HASH_FILE_NAME)) + .thenReturn(tempDir.resolve(HASH_FILE_NAME)); + + // Setup JDA mock chain + when(jda.updateCommands()).thenReturn(commandListUpdateAction); + when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction); + + // Setup SlashCommand mock + when(slashCommand.buildCommandData()).thenReturn(slashCommandData); + when(slashCommandData.getName()).thenReturn("testcommand"); + when(slashCommandData.getDescription()).thenReturn("A test command"); + when(slashCommandData.getOptions()).thenReturn(Collections.emptyList()); + when(slashCommandData.getSubcommands()).thenReturn(Collections.emptyList()); + } + + @AfterEach + void tearDown() throws Exception { + if (otherUtilMock != null) { + otherUtilMock.close(); + } + if (mocks != null) { + mocks.close(); + } + } + + @Test + void testFirstRun_NoHashFile_RegistersCommandsAndCreatesHashFile() { + // Given: No hash file exists and we have commands to register + when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand)); + + // Capture the success callback + ArgumentCaptor<Consumer<List<Command>>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + ArgumentCaptor<Consumer<Throwable>> errorCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), errorCaptor.capture()); + + // When: Register commands + SlashCommandRegistry.registerIfChanged(jda, commandClient); + + // Then: JDA.updateCommands() should be called + verify(jda).updateCommands(); + verify(commandListUpdateAction).addCommands(anyCollection()); + verify(commandListUpdateAction).queue(any(), any()); + + // Simulate successful registration callback + List<Command> registeredCommands = Collections.emptyList(); // Mock result + successCaptor.getValue().accept(registeredCommands); + + // Verify hash file was created + Path hashFile = tempDir.resolve(HASH_FILE_NAME); + assertTrue(Files.exists(hashFile), "Hash file should be created after registration"); + } + + @Test + void testHashUnchanged_SkipsRegistration() throws IOException { + // Given: Hash file exists with current command hash + when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand)); + + // First, register to create the hash file + ArgumentCaptor<Consumer<List<Command>>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any()); + + SlashCommandRegistry.registerIfChanged(jda, commandClient); + successCaptor.getValue().accept(Collections.emptyList()); // Trigger hash save + + // Verify hash file exists + Path hashFile = tempDir.resolve(HASH_FILE_NAME); + assertTrue(Files.exists(hashFile), "Hash file should exist"); + String savedHash = Files.readString(hashFile, StandardCharsets.UTF_8).trim(); + assertFalse(savedHash.isEmpty(), "Hash should not be empty"); + + // Reset mocks for second call + reset(jda, commandListUpdateAction); + when(jda.updateCommands()).thenReturn(commandListUpdateAction); + when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction); + + // When: Register again with same commands + SlashCommandRegistry.registerIfChanged(jda, commandClient); + + // Then: JDA.updateCommands() should NOT be called (commands unchanged) + verify(jda, never()).updateCommands(); + verify(commandListUpdateAction, never()).queue(any(), any()); + } + + @Test + void testHashChanged_ReregistersAndUpdatesHashFile() throws IOException { + // Given: Hash file exists with old command hash + when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand)); + + // First, register to create the hash file + ArgumentCaptor<Consumer<List<Command>>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any()); + + SlashCommandRegistry.registerIfChanged(jda, commandClient); + successCaptor.getValue().accept(Collections.emptyList()); // Trigger hash save + + // Verify hash file exists and capture the old hash + Path hashFile = tempDir.resolve(HASH_FILE_NAME); + String oldHash = Files.readString(hashFile, StandardCharsets.UTF_8).trim(); + + // Now change the command (different name = different hash) + SlashCommand modifiedCommand = mock(SlashCommand.class); + SlashCommandData modifiedData = mock(SlashCommandData.class); + when(modifiedCommand.buildCommandData()).thenReturn(modifiedData); + when(modifiedData.getName()).thenReturn("modifiedcommand"); // Different name + when(modifiedData.getDescription()).thenReturn("A modified command"); + when(modifiedData.getOptions()).thenReturn(Collections.emptyList()); + when(modifiedData.getSubcommands()).thenReturn(Collections.emptyList()); + when(commandClient.getSlashCommands()).thenReturn(List.of(modifiedCommand)); + + // Reset mocks for second call + reset(jda, commandListUpdateAction); + when(jda.updateCommands()).thenReturn(commandListUpdateAction); + when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction); + + ArgumentCaptor<Consumer<List<Command>>> secondSuccessCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(secondSuccessCaptor.capture(), any()); + + // When: Register again with modified commands + SlashCommandRegistry.registerIfChanged(jda, commandClient); + + // Then: JDA.updateCommands() SHOULD be called (commands changed) + verify(jda).updateCommands(); + verify(commandListUpdateAction).addCommands(anyCollection()); + verify(commandListUpdateAction).queue(any(), any()); + + // Simulate successful registration + secondSuccessCaptor.getValue().accept(Collections.emptyList()); + + // Verify hash file was updated with new hash + String newHash = Files.readString(hashFile, StandardCharsets.UTF_8).trim(); + assertNotEquals(oldHash, newHash, "Hash should be different after command change"); + } + + @Test + void testEmptyCommands_DoesNotRegister() { + // Given: No commands to register + when(commandClient.getSlashCommands()).thenReturn(Collections.emptyList()); + + // When: Register + SlashCommandRegistry.registerIfChanged(jda, commandClient); + + // Then: Nothing should happen + verify(jda, never()).updateCommands(); + } + + @Test + void testNullCommands_DoesNotRegister() { + // Given: Null command list + when(commandClient.getSlashCommands()).thenReturn(null); + + // When: Register + SlashCommandRegistry.registerIfChanged(jda, commandClient); + + // Then: Nothing should happen + verify(jda, never()).updateCommands(); + } + + @Test + void testClearHash_RemovesHashFile() throws IOException { + // Given: Hash file exists + Path hashFile = tempDir.resolve(HASH_FILE_NAME); + Files.writeString(hashFile, "somehash", StandardCharsets.UTF_8); + assertTrue(Files.exists(hashFile), "Hash file should exist before clearing"); + + // When: Clear hash + SlashCommandRegistry.clearHash(); + + // Then: Hash file should be deleted + assertFalse(Files.exists(hashFile), "Hash file should be deleted after clearing"); + } + + @Test + void testForceRegister_AlwaysRegisters() throws IOException { + // Given: Hash file exists with current command hash + when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand)); + + // First, do a normal registration + ArgumentCaptor<Consumer<List<Command>>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any()); + + SlashCommandRegistry.registerIfChanged(jda, commandClient); + successCaptor.getValue().accept(Collections.emptyList()); + + // Reset mocks + reset(jda, commandListUpdateAction); + when(jda.updateCommands()).thenReturn(commandListUpdateAction); + when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction); + + ArgumentCaptor<Consumer<List<Command>>> forceSuccessCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(forceSuccessCaptor.capture(), any()); + + // When: Force register (even though hash hasn't changed) + SlashCommandRegistry.forceRegister(jda, commandClient); + + // Then: JDA.updateCommands() SHOULD be called (forced) + verify(jda).updateCommands(); + verify(commandListUpdateAction).addCommands(anyCollection()); + verify(commandListUpdateAction).queue(any(), any()); + } + + @Test + void testRegistrationFailure_DoesNotSaveHash() throws IOException { + // Given: No hash file exists + when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand)); + + // Capture the error callback + ArgumentCaptor<Consumer<List<Command>>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + ArgumentCaptor<Consumer<Throwable>> errorCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), errorCaptor.capture()); + + // When: Register commands + SlashCommandRegistry.registerIfChanged(jda, commandClient); + + // Simulate failed registration callback + errorCaptor.getValue().accept(new RuntimeException("Discord API error")); + + // Then: Hash file should NOT be created + Path hashFile = tempDir.resolve(HASH_FILE_NAME); + assertFalse(Files.exists(hashFile), "Hash file should not be created after failed registration"); + } + + @Test + void testHashCalculation_IncludesAllCommandDetails() throws IOException { + // Given: A command with specific details + when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand)); + + ArgumentCaptor<Consumer<List<Command>>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any()); + + SlashCommandRegistry.registerIfChanged(jda, commandClient); + successCaptor.getValue().accept(Collections.emptyList()); + + Path hashFile = tempDir.resolve(HASH_FILE_NAME); + String hash1 = Files.readString(hashFile, StandardCharsets.UTF_8).trim(); + + // Now change the description (different description = different hash) + reset(jda, commandListUpdateAction, slashCommandData); + when(jda.updateCommands()).thenReturn(commandListUpdateAction); + when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction); + when(slashCommand.buildCommandData()).thenReturn(slashCommandData); + when(slashCommandData.getName()).thenReturn("testcommand"); // Same name + when(slashCommandData.getDescription()).thenReturn("Different description"); // Different description + when(slashCommandData.getOptions()).thenReturn(Collections.emptyList()); + when(slashCommandData.getSubcommands()).thenReturn(Collections.emptyList()); + + ArgumentCaptor<Consumer<List<Command>>> successCaptor2 = ArgumentCaptor.forClass(Consumer.class); + doNothing().when(commandListUpdateAction).queue(successCaptor2.capture(), any()); + + SlashCommandRegistry.registerIfChanged(jda, commandClient); + successCaptor2.getValue().accept(Collections.emptyList()); + + String hash2 = Files.readString(hashFile, StandardCharsets.UTF_8).trim(); + + // Hashes should be different because description changed + assertNotEquals(hash1, hash2, "Hash should change when command description changes"); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v1/music/PlaylistsCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v1/music/PlaylistsCmdTest.java new file mode 100644 index 0000000..0a8bf17 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v1/music/PlaylistsCmdTest.java @@ -0,0 +1,88 @@ +/* + * 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.unit.commands.v1.music; + +import com.jagrosh.jdautilities.command.CommandClient; +import com.jagrosh.jdautilities.command.CommandEvent; +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.commands.v1.music.PlaylistsCmd; +import com.jagrosh.jmusicbot.service.MusicService; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlaylistsCmdTest +{ + private Bot bot; + private PlaylistsCmd command; + private MusicService musicService; + private CommandEvent event; + + @BeforeEach + void setUp() + { + bot = mock(Bot.class); + musicService = mock(MusicService.class); + event = mock(CommandEvent.class); + CommandClient client = mock(CommandClient.class); + Member member = mock(Member.class); + User user = mock(User.class); + + when(bot.getMusicService()).thenReturn(musicService); + when(bot.getConfig()).thenReturn(mock(com.jagrosh.jmusicbot.BotConfig.class)); + when(bot.getConfig().getAliases(any())).thenReturn(new String[0]); + + when(event.getClient()).thenReturn(client); + when(client.getWarning()).thenReturn("⚠️"); + when(client.getError()).thenReturn("❌"); + when(event.getAuthor()).thenReturn(user); + when(user.getIdLong()).thenReturn(42L); + when(event.getMember()).thenReturn(member); + when(member.getColor()).thenReturn(null); + + command = new PlaylistsCmd(bot); + } + + @Test + void doCommand_emptyPlaylists_repliesWarningText() + { + when(musicService.getAvailablePlaylistNames()).thenReturn(MusicService.PlaylistNamesInfo.success(List.of())); + + command.doCommand(event); + + verify(event).reply(any(String.class)); + } + + @Test + void doCommand_hasPlaylists_repliesInteractiveMessage() + { + when(musicService.getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite", "workout"))); + + command.doCommand(event); + + verify(event).reply(any(MessageCreateData.class)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/MusicSlashCommandTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/MusicSlashCommandTest.java new file mode 100644 index 0000000..9bf845e --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/MusicSlashCommandTest.java @@ -0,0 +1,185 @@ +/* + * 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.unit.commands.v2; + +import com.jagrosh.jmusicbot.commands.v2.MusicSlashCommand; +import com.jagrosh.jmusicbot.testutil.commands.SlashCommandTestFixture; +import com.jagrosh.jmusicbot.testutil.commands.TestMusicSlashCommand; +import com.jagrosh.jmusicbot.testutil.commands.ValidationScenarioBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link MusicSlashCommand} base class validation logic. + * Uses reusable test fixtures for cleaner, more maintainable tests. + */ +public class MusicSlashCommandTest +{ + private SlashCommandTestFixture fixture; + private TestMusicSlashCommand command; + + @BeforeEach + void setUp() + { + fixture = SlashCommandTestFixture.create(); + } + + // ==================== Basic Validation Tests ==================== + + @Test + void testExecute_ValidCommand_CallsDoCommand() + { + // Given: Valid basic scenario + ValidationScenarioBuilder.with(fixture).validBasic().build(); + command = TestMusicSlashCommand.createBasic(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + assertTrue(command.wasDoCommandCalled(), "doCommand should be called for valid command"); + } + + // ==================== Text Channel Restriction Tests ==================== + + @Test + void testExecute_WrongTextChannel_SendsErrorAndDoesNotCallDoCommand() + { + // Given: Wrong text channel scenario + ValidationScenarioBuilder.with(fixture).wrongTextChannel().build(); + command = TestMusicSlashCommand.createBasic(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("❌ You can only use that command in #music!"); + verify(fixture.getReplyAction()).setEphemeral(true); + assertFalse(command.wasDoCommandCalled(), "doCommand should not be called"); + } + + // ==================== bePlaying Validation Tests ==================== + + @Test + void testExecute_BePlayingButNotPlaying_SendsError() + { + // Given: Requires playing but music not playing + ValidationScenarioBuilder.with(fixture) + .noTextChannelRestriction() + .musicNotPlaying() + .build(); + command = TestMusicSlashCommand.createRequiresPlaying(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("❌ There must be music playing to use that!"); + verify(fixture.getReplyAction()).setEphemeral(true); + assertFalse(command.wasDoCommandCalled(), "doCommand should not be called"); + } + + @Test + void testExecute_BePlayingAndPlaying_CallsDoCommand() + { + // Given: Requires playing and music is playing + ValidationScenarioBuilder.with(fixture).validWithMusicPlaying().build(); + command = TestMusicSlashCommand.createRequiresPlaying(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + assertTrue(command.wasDoCommandCalled(), "doCommand should be called when music is playing"); + } + + // ==================== beListening Validation Tests ==================== + + @Test + void testExecute_BeListeningButNotInVoice_SendsError() + { + // Given: Requires listening but user not in voice + ValidationScenarioBuilder.with(fixture) + .noTextChannelRestriction() + .userNotInVoiceChannel() + .build(); + command = TestMusicSlashCommand.createRequiresListening(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply(argThat((String msg) -> msg.contains("You must be listening in"))); + verify(fixture.getReplyAction()).setEphemeral(true); + assertFalse(command.wasDoCommandCalled(), "doCommand should not be called"); + } + + @Test + void testExecute_BeListeningInDifferentChannel_SendsError() + { + // Given: Requires listening but user in different channel + ValidationScenarioBuilder.with(fixture) + .noTextChannelRestriction() + .userInDifferentVoiceChannel() + .build(); + command = TestMusicSlashCommand.createRequiresListening(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply(argThat((String msg) -> msg.contains("You must be listening in"))); + verify(fixture.getReplyAction()).setEphemeral(true); + assertFalse(command.wasDoCommandCalled(), "doCommand should not be called"); + } + + @Test + void testExecute_BeListeningInAfkChannel_SendsError() + { + // Given: Requires listening but user in AFK channel + ValidationScenarioBuilder.with(fixture) + .noTextChannelRestriction() + .userInAfkChannel() + .build(); + command = TestMusicSlashCommand.createRequiresListening(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("❌ You cannot use that command in an AFK channel!"); + verify(fixture.getReplyAction()).setEphemeral(true); + assertFalse(command.wasDoCommandCalled(), "doCommand should not be called"); + } + + @Test + void testExecute_BeListeningValid_CallsDoCommand() + { + // Given: Requires listening and user is in correct channel + ValidationScenarioBuilder.with(fixture).validWithUserListening().build(); + command = TestMusicSlashCommand.createRequiresListening(fixture.getBot()); + + // When + command.testExecute(fixture.getEvent()); + + // Then + assertTrue(command.wasDoCommandCalled(), "doCommand should be called when user is listening"); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/SlashOutputAdaptersTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/SlashOutputAdaptersTest.java new file mode 100644 index 0000000..23e18b8 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/SlashOutputAdaptersTest.java @@ -0,0 +1,330 @@ +/* + * 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.unit.commands.v2; + +import com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters; +import com.jagrosh.jmusicbot.testutil.commands.SlashCommandTestFixture; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import net.dv8tion.jda.api.utils.messages.MessageEditData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.function.Consumer; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link SlashOutputAdapters}. + * Uses SlashCommandTestFixture for cleaner, more maintainable tests. + */ +public class SlashOutputAdaptersTest +{ + private SlashCommandTestFixture fixture; + + @Mock + private MessageCreateData messageCreateData; + + @BeforeEach + void setUp() + { + MockitoAnnotations.openMocks(this); + fixture = SlashCommandTestFixture.create(); + messageCreateData = new MessageCreateBuilder() + .setEmbeds(new EmbedBuilder().setDescription("NP").build()) + .build(); + } + + // ==================== SlashEventOutputAdapter Tests ==================== + + @Test + void testSlashEventOutputAdapter_ReplySuccess() + { + // Given + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + + // When + adapter.replySuccess("Success message"); + + // Then + verify(fixture.getEvent()).reply("Success message"); + verify(fixture.getReplyAction()).queue(); + } + + @Test + void testSlashEventOutputAdapter_ReplyError() + { + // Given + when(fixture.getReplyAction().setEphemeral(anyBoolean())).thenReturn(fixture.getReplyAction()); + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + + // When + adapter.replyError("Error message"); + + // Then + verify(fixture.getEvent()).reply("Error message"); + verify(fixture.getReplyAction()).setEphemeral(true); + verify(fixture.getReplyAction()).queue(); + } + + @Test + void testSlashEventOutputAdapter_ReplyWarning() + { + // Given + when(fixture.getReplyAction().setEphemeral(anyBoolean())).thenReturn(fixture.getReplyAction()); + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + + // When + adapter.replyWarning("Warning message"); + + // Then + verify(fixture.getEvent()).reply("Warning message"); + verify(fixture.getReplyAction()).setEphemeral(true); + verify(fixture.getReplyAction()).queue(); + } + + @Test + void testSlashEventOutputAdapter_EditMessage() + { + // Given + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + + // When + adapter.editMessage("Edit message"); + + // Then + verify(fixture.getEvent()).reply("Edit message"); + verify(fixture.getReplyAction()).queue(); + } + + @Test + void testSlashEventOutputAdapter_EditMessageWithCallback() + { + // Given + fixture.withReplyQueueCallback().withRetrieveQueueCallback(); + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + @SuppressWarnings("unchecked") + Consumer<net.dv8tion.jda.api.entities.Message> callback = mock(Consumer.class); + + // When + adapter.editMessage("Edit message", callback); + + // Then + verify(fixture.getEvent()).reply("Edit message"); + verify(callback).accept(fixture.getMessage()); + } + + @Test + void testSlashEventOutputAdapter_EditNowPlaying() + { + // Given + when(fixture.getAudioHandler().getNowPlaying(fixture.getJda())).thenReturn(messageCreateData); + when(fixture.getEvent().reply(any(MessageCreateData.class))).thenReturn(fixture.getReplyAction()); + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + + // When + adapter.editNowPlaying(fixture.getAudioHandler()); + + // Then + verify(fixture.getAudioHandler()).getNowPlaying(fixture.getJda()); + verify(fixture.getEvent()).reply(messageCreateData); + verify(fixture.getReplyAction()).queue(); + } + + @Test + void testSlashEventOutputAdapter_EditNoMusic() + { + // Given + when(fixture.getAudioHandler().getNoMusicPlaying(fixture.getJda())).thenReturn(messageCreateData); + when(fixture.getEvent().reply(any(MessageCreateData.class))).thenReturn(fixture.getReplyAction()); + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + + // When + adapter.editNoMusic(fixture.getAudioHandler()); + + // Then + verify(fixture.getAudioHandler()).getNoMusicPlaying(fixture.getJda()); + verify(fixture.getEvent()).reply(messageCreateData); + verify(fixture.getReplyAction()).queue(); + } + + @Test + void testSlashEventOutputAdapter_OnShowHelp() + { + // Given + when(fixture.getReplyAction().setEphemeral(anyBoolean())).thenReturn(fixture.getReplyAction()); + SlashOutputAdapters.SlashEventOutputAdapter adapter = + new SlashOutputAdapters.SlashEventOutputAdapter(fixture.getEvent()); + + // When + adapter.onShowHelp(); + + // Then + verify(fixture.getEvent()).reply("⚠️ Please include a song title or URL!"); + verify(fixture.getReplyAction()).setEphemeral(true); + verify(fixture.getReplyAction()).queue(); + } + + // ==================== InteractionHookOutputAdapter Tests ==================== + + @Test + void testInteractionHookOutputAdapter_ReplySuccess() + { + // Given + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + + // When + adapter.replySuccess("Success message"); + + // Then + verify(fixture.getHook()).editOriginal("Success message"); + verify(fixture.getEditAction()).queue(); + } + + @Test + void testInteractionHookOutputAdapter_ReplyError() + { + // Given + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + + // When + adapter.replyError("Error message"); + + // Then + verify(fixture.getHook()).editOriginal("Error message"); + verify(fixture.getEditAction()).queue(); + } + + @Test + void testInteractionHookOutputAdapter_ReplyWarning() + { + // Given + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + + // When + adapter.replyWarning("Warning message"); + + // Then + verify(fixture.getHook()).editOriginal("Warning message"); + verify(fixture.getEditAction()).queue(); + } + + @Test + void testInteractionHookOutputAdapter_EditMessage() + { + // Given + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + + // When + adapter.editMessage("Edit message"); + + // Then + verify(fixture.getHook()).editOriginal("Edit message"); + verify(fixture.getEditAction()).queue(); + } + + @Test + void testInteractionHookOutputAdapter_EditMessageWithCallback() + { + // Given + fixture.withEditQueueCallback(); + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + @SuppressWarnings("unchecked") + Consumer<net.dv8tion.jda.api.entities.Message> callback = mock(Consumer.class); + + // When + adapter.editMessage("Edit message", callback); + + // Then + verify(fixture.getHook()).editOriginal("Edit message"); + verify(callback).accept(fixture.getMessage()); + } + + @Test + void testInteractionHookOutputAdapter_EditNowPlaying() + { + // Given + when(fixture.getAudioHandler().getNowPlaying(fixture.getJda())).thenReturn(messageCreateData); + when(fixture.getHook().editOriginal(any(MessageEditData.class))).thenReturn(fixture.getEditAction()); + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + + // When + adapter.editNowPlaying(fixture.getAudioHandler()); + + // Then + verify(fixture.getAudioHandler()).getNowPlaying(fixture.getJda()); + verify(fixture.getHook()).editOriginal(any(MessageEditData.class)); + verify(fixture.getEditAction()).queue(); + } + + @Test + void testInteractionHookOutputAdapter_EditNoMusic() + { + // Given + when(fixture.getAudioHandler().getNoMusicPlaying(fixture.getJda())).thenReturn(messageCreateData); + when(fixture.getHook().editOriginal(any(MessageEditData.class))).thenReturn(fixture.getEditAction()); + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + + // When + adapter.editNoMusic(fixture.getAudioHandler()); + + // Then + verify(fixture.getAudioHandler()).getNoMusicPlaying(fixture.getJda()); + verify(fixture.getHook()).editOriginal(any(MessageEditData.class)); + verify(fixture.getEditAction()).queue(); + } + + @Test + void testInteractionHookOutputAdapter_OnShowHelp() + { + // Given + SlashOutputAdapters.InteractionHookOutputAdapter adapter = + new SlashOutputAdapters.InteractionHookOutputAdapter( + fixture.getHook(), fixture.getJda(), "⚠️"); + + // When + adapter.onShowHelp(); + + // Then + verify(fixture.getHook()).editOriginal("⚠️ Please include a song title or URL!"); + verify(fixture.getEditAction()).queue(); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/admin/SettingsPanelRendererTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/admin/SettingsPanelRendererTest.java new file mode 100644 index 0000000..ec780ef --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/admin/SettingsPanelRendererTest.java @@ -0,0 +1,158 @@ +package com.jagrosh.jmusicbot.unit.commands.v2.admin; + +import com.jagrosh.jmusicbot.commands.v2.admin.SettingsPanelRenderer; +import com.jagrosh.jmusicbot.settings.NowPlayingButtonsMode; +import com.jagrosh.jmusicbot.settings.NowPlayingLayoutMode; +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.settings.Settings; +import net.dv8tion.jda.api.components.Component; +import net.dv8tion.jda.api.components.MessageTopLevelComponent; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.components.container.Container; +import net.dv8tion.jda.api.components.section.Section; +import net.dv8tion.jda.api.components.selections.EntitySelectMenu; +import net.dv8tion.jda.api.modals.Modal; +import org.mockito.Mockito; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SettingsPanelRendererTest +{ + @Test + void buildSettingsComponents_containsExpectedButtons() + { + Settings settings = new Settings(null, 0, 0, 0, 100, null, RepeatMode.OFF, null, -1, + QueueType.FAIR, NowPlayingLayoutMode.INHERIT, NowPlayingButtonsMode.INHERIT); + List<ActionRow> rows = SettingsPanelRenderer.buildSettingsComponents(settings, 123L); + assertEquals(4, rows.size()); + + List<String> labels = rows.stream() + .flatMap(row -> row.getButtons().stream()) + .map(b -> b.getLabel()) + .collect(Collectors.toList()); + + assertTrue(labels.contains("Toggle Queue")); + assertTrue(labels.contains("Toggle Layout")); + assertTrue(labels.contains("Toggle Buttons")); + assertTrue(labels.contains("Set Text Channel")); + assertTrue(labels.contains("Set Skip %")); + assertTrue(labels.contains("Close")); + } + + @Test + void buildSettingsMessageComponents_containsDisplayContainerAndActionRows() + { + Settings settings = new Settings(null, 0, 0, 0, 100, null, RepeatMode.OFF, null, -1, + QueueType.FAIR, NowPlayingLayoutMode.INHERIT, NowPlayingButtonsMode.INHERIT); + var guild = Mockito.mock(net.dv8tion.jda.api.entities.Guild.class); + Mockito.when(guild.getName()).thenReturn("test guild"); + + List<MessageTopLevelComponent> components = SettingsPanelRenderer.buildSettingsMessageComponents( + guild, settings, Mockito.mock(com.jagrosh.jmusicbot.BotConfig.class), 123L, "tester"); + assertEquals(1, components.size()); + assertTrue(components.get(0) instanceof Container); + Container container = (Container) components.get(0); + long actionRows = container.getComponents().stream() + .filter(c -> c.getType() == Component.Type.ACTION_ROW) + .count(); + assertEquals(5, actionRows); + long separators = container.getComponents().stream() + .filter(c -> c.getType() == Component.Type.SEPARATOR) + .count(); + assertEquals(4, separators); + + List<ActionRow> rows = container.getComponents().stream() + .filter(c -> c.getType() == Component.Type.ACTION_ROW) + .map(c -> c.asActionRow()) + .toList(); + List<String> rowButtonLabels = rows.stream() + .flatMap(r -> r.getButtons().stream()) + .map(Button::getLabel) + .toList(); + assertTrue(rowButtonLabels.contains("Set Prefix")); + assertTrue(rowButtonLabels.contains("Set Skip %")); + assertTrue(rowButtonLabels.contains("Close")); + + List<String> textDisplays = container.getComponents().stream() + .filter(c -> c.getType() == Component.Type.TEXT_DISPLAY) + .map(c -> c.asTextDisplay().getContent()) + .toList(); + assertTrue(textDisplays.contains("## MusicBot Settings")); + assertTrue(textDisplays.stream().anyMatch(t -> t.contains("Interactive settings panel for **test guild**.") + && t.contains("Only **tester** can use these controls."))); + + List<String> sectionAccessoryButtonIds = container.getComponents().stream() + .filter(c -> c.getType() == Component.Type.SECTION) + .map(c -> c.asSection()) + .map(Section::getAccessory) + .filter(a -> a != null && a.getType() == Component.Type.BUTTON) + .map(a -> a.asButton().getCustomId()) + .toList(); + assertTrue(sectionAccessoryButtonIds.contains("settings_clear_settc_123")); + assertTrue(sectionAccessoryButtonIds.contains("settings_clear_setvc_123")); + assertTrue(sectionAccessoryButtonIds.contains("settings_clear_setdj_123")); + assertTrue(sectionAccessoryButtonIds.contains("settings_toggle_queue_123")); + assertTrue(sectionAccessoryButtonIds.contains("settings_toggle_layout_123")); + assertTrue(sectionAccessoryButtonIds.contains("settings_toggle_npbuttons_123")); + assertTrue(!sectionAccessoryButtonIds.contains("settings_info_behavior")); + } + + @Test + void buildModal_knownKey_returnsModalWithExpectedId() + { + Modal modal = SettingsPanelRenderer.buildModal("prefix", 456L); + assertNotNull(modal); + assertEquals("settings_modal_prefix_456", modal.getId()); + } + + @Test + void buildModal_entitySelectKeys_returnNull() + { + assertNull(SettingsPanelRenderer.buildModal("settc", 456L)); + assertNull(SettingsPanelRenderer.buildModal("setvc", 456L)); + assertNull(SettingsPanelRenderer.buildModal("setdj", 456L)); + } + + @Test + void buildEntitySelectMenu_setDj_hasExpectedId() + { + EntitySelectMenu menu = SettingsPanelRenderer.buildEntitySelectMenu("setdj", 456L); + assertNotNull(menu); + assertEquals("settings_entity_setdj_456", menu.getCustomId()); + } + + @Test + void buildEntitySelectMenu_withPanelMessage_hasExpectedId() + { + EntitySelectMenu menu = SettingsPanelRenderer.buildEntitySelectMenu("setvc", 456L, 999L); + assertNotNull(menu); + assertEquals("settings_entity_setvc_999_456", menu.getCustomId()); + } + + @Test + void buildEntityClearButton_setDj_hasExpectedIdAndLabel() + { + Button button = SettingsPanelRenderer.buildEntityClearButton("setdj", 456L); + assertNotNull(button); + assertEquals("settings_clear_setdj_456", button.getCustomId()); + assertEquals("None", button.getLabel()); + } + + @Test + void buildEntityClearButton_withPanelMessage_hasExpectedIdAndLabel() + { + Button button = SettingsPanelRenderer.buildEntityClearButton("settc", 456L, 999L); + assertNotNull(button); + assertEquals("settings_clear_settc_999_456", button.getCustomId()); + assertEquals("Any", button.getLabel()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/HistorySlashCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/HistorySlashCmdTest.java new file mode 100644 index 0000000..734124a --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/HistorySlashCmdTest.java @@ -0,0 +1,174 @@ +/* + * 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.unit.commands.v2.music; + +import com.jagrosh.jmusicbot.commands.v2.music.HistorySlashCmd; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.testutil.commands.SlashCommandTestFixture; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link HistorySlashCmd}. + */ +public class HistorySlashCmdTest +{ + private SlashCommandTestFixture fixture; + private HistorySlashCmd command; + + @BeforeEach + void setUp() + { + fixture = SlashCommandTestFixture.create(); + fixture.withReplyQueueCallback().withRetrieveQueueCallback(); + command = new HistorySlashCmd(fixture.getBot()); + } + + @Test + void testDoCommand_EmptyHistory_ShowsEphemeralWarning() + { + when(fixture.getEvent().getOption("page")).thenReturn(null); + when(fixture.getMusicService().getHistoryInfo(fixture.getGuild(), fixture.getJda())).thenReturn(null); + + command.doCommand(fixture.getEvent()); + + verify(fixture.getEvent()).reply(any(String.class)); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + void testDoCommand_EmptyHistoryInfo_ShowsEphemeralWarning() + { + when(fixture.getEvent().getOption("page")).thenReturn(null); + MusicService.HistoryInfo empty = new MusicService.HistoryInfo(new String[0], 0L, 10); + when(fixture.getMusicService().getHistoryInfo(fixture.getGuild(), fixture.getJda())).thenReturn(empty); + + command.doCommand(fixture.getEvent()); + + verify(fixture.getEvent()).reply(any(String.class)); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + void testDoCommand_DisabledHistory_ShowsDisabledWarning() + { + when(fixture.getEvent().getOption("page")).thenReturn(null); + MusicService.HistoryInfo disabled = new MusicService.HistoryInfo(new String[0], 0L, 0, true); + when(fixture.getMusicService().getHistoryInfo(fixture.getGuild(), fixture.getJda())).thenReturn(disabled); + + command.doCommand(fixture.getEvent()); + + verify(fixture.getEvent()).reply(argThat((String s) -> s.contains("disabled by config"))); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + void testDoCommand_WithHistory_ShowsEmbedAndComponents() + { + when(fixture.getEvent().getOption("page")).thenReturn(null); + MusicService.HistoryInfo historyInfo = new MusicService.HistoryInfo( + new String[]{"Track 1", "Track 2", "Track 3"}, + 300000L, + 10 + ); + when(fixture.getMusicService().getHistoryInfo(fixture.getGuild(), fixture.getJda())).thenReturn(historyInfo); + + command.doCommand(fixture.getEvent()); + + ArgumentCaptor<MessageEmbed> embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getEvent()).replyEmbeds(embedCaptor.capture()); + MessageEmbed embed = embedCaptor.getValue(); + assertEquals("History Queue", embed.getTitle()); + assertTrue(embed.getDescription().contains("Track 1")); + assertTrue(embed.getDescription().contains("Recently played")); + + @SuppressWarnings("unchecked") + ArgumentCaptor<List<ActionRow>> componentsCaptor = ArgumentCaptor.forClass(List.class); + verify(fixture.getReplyAction()).setComponents(componentsCaptor.capture()); + assertFalse(componentsCaptor.getValue().isEmpty()); + } + + @Test + void testGetTracksOnPage() + { + assertEquals(10, HistorySlashCmd.getTracksOnPage(1, 25)); + assertEquals(5, HistorySlashCmd.getTracksOnPage(3, 25)); + assertEquals(3, HistorySlashCmd.getTracksOnPage(1, 3)); + } + + @Test + void testGetTotalPages() + { + assertEquals(1, HistorySlashCmd.getTotalPages(5)); + assertEquals(2, HistorySlashCmd.getTotalPages(11)); + assertEquals(3, HistorySlashCmd.getTotalPages(25)); + } + + @Test + void testBuildHistoryComponents_NoSelection_ReturnsFourRowsWithAddAllAndSaveButtons() + { + List<ActionRow> rows = HistorySlashCmd.buildHistoryComponents(1, 2, 10, 0, 12345L); + assertEquals(4, rows.size()); + // Row 4 is action row: "Add all to queue" and "Save as playlist" when no track selected + ActionRow actionRow = rows.get(3); + assertEquals(2, actionRow.getComponents().size()); + assertEquals("Add all to queue", ((Button) actionRow.getComponents().get(0)).getLabel()); + assertEquals("Save as playlist", ((Button) actionRow.getComponents().get(1)).getLabel()); + } + + @Test + void testBuildHistoryComponents_WithSelection_ReturnsFourRows() + { + List<ActionRow> rows = HistorySlashCmd.buildHistoryComponents(1, 2, 10, 5, 12345L); + assertEquals(4, rows.size()); + } + + @Test + void testBuildHistoryComponents_FewerTracks_UsesSparseButtons() + { + List<ActionRow> rows = HistorySlashCmd.buildHistoryComponents(1, 1, 3, 0, 12345L); + assertEquals(2, rows.size()); + ActionRow selectRow = rows.get(0); + assertEquals(3, selectRow.getComponents().size()); + assertEquals("1", ((Button) selectRow.getComponents().get(0)).getLabel()); + assertEquals("3", ((Button) selectRow.getComponents().get(2)).getLabel()); + } + + @Test + void testBuildHistoryComponents_PageTwo_UsesAbsoluteButtonLabels() + { + List<ActionRow> rows = HistorySlashCmd.buildHistoryComponents(2, 3, 10, 0, 12345L); + assertEquals(4, rows.size()); + ActionRow row1 = rows.get(0); + ActionRow row2 = rows.get(1); + assertEquals("11", ((Button) row1.getComponents().get(0)).getLabel()); + assertEquals("15", ((Button) row1.getComponents().get(4)).getLabel()); + assertEquals("16", ((Button) row2.getComponents().get(0)).getLabel()); + assertEquals("20", ((Button) row2.getComponents().get(4)).getLabel()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/PlaySlashCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/PlaySlashCmdTest.java new file mode 100644 index 0000000..d7d9d67 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/PlaySlashCmdTest.java @@ -0,0 +1,298 @@ +/* + * 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.unit.commands.v2.music; + +import com.jagrosh.jmusicbot.commands.v2.music.PlaySlashCmd; +import com.jagrosh.jmusicbot.testutil.commands.SlashCommandTestFixture; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import net.dv8tion.jda.api.interactions.commands.Command; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link PlaySlashCmd}. + * Uses SlashCommandTestFixture for cleaner, more maintainable tests. + */ +public class PlaySlashCmdTest +{ + private SlashCommandTestFixture fixture; + private PlaySlashCmd command; + + @Mock + private OptionMapping queryOption; + + @BeforeEach + void setUp() + { + MockitoAnnotations.openMocks(this); + fixture = SlashCommandTestFixture.create(); + fixture.withReplyQueueCallback(); + command = new PlaySlashCmd(fixture.getBot()); + } + + @Test + void testDoCommand_WithQuery_CallsMusicServicePlay() + { + // Given + String query = "test song"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("⏳ Loading... `[" + query + "]`"); + verify(fixture.getMusicService()).play( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(query), + eq(fixture.getTextChannel()), + any()); + } + + @Test + void testDoCommand_WithoutQuery_CallsMusicServicePlayWithEmptyString() + { + // Given + when(fixture.getEvent().getOption("query")).thenReturn(null); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getMusicService()).play( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(""), + eq(fixture.getTextChannel()), + any()); + } + + @Test + void testOnAutoComplete_EmptyInput_RepliesEmptyChoices() + { + // Given + when(fixture.getFocusedOption().getValue()).thenReturn(""); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + verify(fixture.getAutoCompleteEvent()).replyChoices(); + verify(fixture.getAutoCompleteCallback()).queue(); + verify(fixture.getPlayerManager(), never()).loadItemOrdered(any(), anyString(), any()); + } + + @Test + void testOnAutoComplete_HttpUrl_RepliesWithUrlAsChoice() + { + // Given + String url = "https://www.youtube.com/watch?v=test"; + when(fixture.getFocusedOption().getValue()).thenReturn(url); + when(fixture.getAutoCompleteEvent().replyChoices(any(Command.Choice.class))) + .thenReturn(fixture.getAutoCompleteCallback()); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + ArgumentCaptor<Command.Choice> choiceCaptor = ArgumentCaptor.forClass(Command.Choice.class); + verify(fixture.getAutoCompleteEvent()).replyChoices(choiceCaptor.capture()); + Command.Choice capturedChoice = choiceCaptor.getValue(); + assertEquals(url, capturedChoice.getName()); + assertEquals(url, capturedChoice.getAsString()); + verify(fixture.getPlayerManager(), never()).loadItemOrdered(any(), anyString(), any()); + } + + @Test + void testOnAutoComplete_WindowsPath_RepliesWithPathAsChoice() + { + // Given + String path = "C:\\Music\\song.mp3"; + when(fixture.getFocusedOption().getValue()).thenReturn(path); + when(fixture.getAutoCompleteEvent().replyChoices(any(Command.Choice.class))) + .thenReturn(fixture.getAutoCompleteCallback()); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + ArgumentCaptor<Command.Choice> choiceCaptor = ArgumentCaptor.forClass(Command.Choice.class); + verify(fixture.getAutoCompleteEvent()).replyChoices(choiceCaptor.capture()); + Command.Choice capturedChoice = choiceCaptor.getValue(); + assertEquals(path, capturedChoice.getName()); + assertEquals(path, capturedChoice.getAsString()); + } + + @Test + void testOnAutoComplete_UnixPath_RepliesWithPathAsChoice() + { + // Given + String path = "/home/user/music/song.mp3"; + when(fixture.getFocusedOption().getValue()).thenReturn(path); + when(fixture.getAutoCompleteEvent().replyChoices(any(Command.Choice.class))) + .thenReturn(fixture.getAutoCompleteCallback()); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + ArgumentCaptor<Command.Choice> choiceCaptor = ArgumentCaptor.forClass(Command.Choice.class); + verify(fixture.getAutoCompleteEvent()).replyChoices(choiceCaptor.capture()); + Command.Choice capturedChoice = choiceCaptor.getValue(); + assertEquals(path, capturedChoice.getName()); + assertEquals(path, capturedChoice.getAsString()); + } + + @Test + void testOnAutoComplete_SearchQuery_LoadsItemAndRepliesWithResults() + { + // Given + String query = "test song"; + when(fixture.getFocusedOption().getValue()).thenReturn(query); + when(fixture.getAutoCompleteEvent().replyChoices(anyList())) + .thenReturn(fixture.getAutoCompleteCallback()); + when(fixture.getAutoCompleteEvent().replyChoices(any(Command.Choice.class))) + .thenReturn(fixture.getAutoCompleteCallback()); + + ArgumentCaptor<AudioLoadResultHandler> handlerCaptor = + ArgumentCaptor.forClass(AudioLoadResultHandler.class); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered( + eq(fixture.getGuild()), eq("ytsearch:" + query), handlerCaptor.capture()); + + // Simulate track loaded + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo trackInfo = new AudioTrackInfo( + "Test Song", "Author", 1000, "identifier", false, + "https://www.youtube.com/watch?v=test"); + when(track.getInfo()).thenReturn(trackInfo); + + handlerCaptor.getValue().trackLoaded(track); + + ArgumentCaptor<Command.Choice> choiceCaptor = ArgumentCaptor.forClass(Command.Choice.class); + verify(fixture.getAutoCompleteEvent()).replyChoices(choiceCaptor.capture()); + Command.Choice capturedChoice = choiceCaptor.getValue(); + assertEquals("Test Song", capturedChoice.getName()); + assertEquals("https://www.youtube.com/watch?v=test", capturedChoice.getAsString()); + } + + @Test + void testOnAutoComplete_PlaylistLoaded_RepliesWithMultipleChoices() + { + // Given + String query = "test playlist"; + when(fixture.getFocusedOption().getValue()).thenReturn(query); + when(fixture.getAutoCompleteEvent().replyChoices(anyList())) + .thenReturn(fixture.getAutoCompleteCallback()); + + ArgumentCaptor<AudioLoadResultHandler> handlerCaptor = + ArgumentCaptor.forClass(AudioLoadResultHandler.class); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered( + eq(fixture.getGuild()), eq("ytsearch:" + query), handlerCaptor.capture()); + + // Simulate playlist loaded + AudioPlaylist playlist = mock(AudioPlaylist.class); + AudioTrack track1 = mock(AudioTrack.class); + AudioTrack track2 = mock(AudioTrack.class); + AudioTrackInfo info1 = new AudioTrackInfo( + "Song 1", "Author", 1000, "identifier", false, + "https://www.youtube.com/watch?v=1"); + AudioTrackInfo info2 = new AudioTrackInfo( + "Song 2", "Author", 1000, "identifier", false, + "https://www.youtube.com/watch?v=2"); + + when(track1.getInfo()).thenReturn(info1); + when(track2.getInfo()).thenReturn(info2); + when(playlist.getTracks()).thenReturn(List.of(track1, track2)); + + handlerCaptor.getValue().playlistLoaded(playlist); + + @SuppressWarnings("unchecked") + ArgumentCaptor<List<Command.Choice>> choicesCaptor = + ArgumentCaptor.forClass((Class<List<Command.Choice>>) (Class<?>) List.class); + verify(fixture.getAutoCompleteEvent()).replyChoices(choicesCaptor.capture()); + List<Command.Choice> capturedChoices = choicesCaptor.getValue(); + assertEquals(2, capturedChoices.size()); + } + + @Test + void testOnAutoComplete_NoMatches_RepliesEmptyChoices() + { + // Given + String query = "nonexistent song"; + when(fixture.getFocusedOption().getValue()).thenReturn(query); + + ArgumentCaptor<AudioLoadResultHandler> handlerCaptor = + ArgumentCaptor.forClass(AudioLoadResultHandler.class); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered( + eq(fixture.getGuild()), eq("ytsearch:" + query), handlerCaptor.capture()); + + handlerCaptor.getValue().noMatches(); + verify(fixture.getAutoCompleteEvent()).replyChoices(); + } + + @Test + void testOnAutoComplete_LoadFailed_RepliesEmptyChoices() + { + // Given + String query = "test song"; + when(fixture.getFocusedOption().getValue()).thenReturn(query); + + ArgumentCaptor<AudioLoadResultHandler> handlerCaptor = + ArgumentCaptor.forClass(AudioLoadResultHandler.class); + + // When + command.onAutoComplete(fixture.getAutoCompleteEvent()); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered( + eq(fixture.getGuild()), eq("ytsearch:" + query), handlerCaptor.capture()); + + com.sedmelluq.discord.lavaplayer.tools.FriendlyException exception = + mock(com.sedmelluq.discord.lavaplayer.tools.FriendlyException.class); + handlerCaptor.getValue().loadFailed(exception); + verify(fixture.getAutoCompleteEvent()).replyChoices(); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/PlaylistsSlashCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/PlaylistsSlashCmdTest.java new file mode 100644 index 0000000..8753487 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/PlaylistsSlashCmdTest.java @@ -0,0 +1,216 @@ +/* + * 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.unit.commands.v2.music; + +import com.jagrosh.jmusicbot.commands.v2.music.PlaylistsSlashCmd; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.testutil.commands.SlashCommandTestFixture; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlaylistsSlashCmdTest +{ + private SlashCommandTestFixture fixture; + private PlaylistsSlashCmd command; + private MusicService musicService; + + @BeforeEach + void setUp() + { + fixture = SlashCommandTestFixture.create(); + command = new PlaylistsSlashCmd(fixture.getBot()); + musicService = fixture.getMusicService(); + } + + @Test + void doCommand_storageUnavailable_repliesEphemeralWarning() + { + when(musicService.getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.error("Playlists storage is unavailable.")); + + command.doCommand(fixture.getEvent()); + + verify(fixture.getEvent()).reply(any(String.class)); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + void doCommand_emptyPlaylists_repliesEphemeralWarning() + { + when(musicService.getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of())); + + command.doCommand(fixture.getEvent()); + + verify(fixture.getEvent()).reply(any(String.class)); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + void doCommand_hasPlaylists_repliesEmbedAndComponents() + { + when(musicService.getAvailablePlaylistNames()) + .thenReturn(MusicService.PlaylistNamesInfo.success(List.of("favorite", "night_drive"))); + + command.doCommand(fixture.getEvent()); + + ArgumentCaptor<MessageEmbed> embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getEvent()).replyEmbeds(embedCaptor.capture()); + assertEquals("Playlists", embedCaptor.getValue().getTitle()); + assertTrue(embedCaptor.getValue().getDescription().contains("favorite")); + + @SuppressWarnings("unchecked") + ArgumentCaptor<List<ActionRow>> componentsCaptor = ArgumentCaptor.forClass(List.class); + verify(fixture.getReplyAction()).setComponents(componentsCaptor.capture()); + assertFalse(componentsCaptor.getValue().isEmpty()); + assertEquals(2, componentsCaptor.getValue().size()); + } + + @Test + void paginationHelpers_computeExpectedValues() + { + assertEquals(10, PlaylistsSlashCmd.getPlaylistsOnPage(1, 23)); + assertEquals(3, PlaylistsSlashCmd.getPlaylistsOnPage(3, 23)); + assertEquals(3, PlaylistsSlashCmd.getTotalPages(23)); + } + + @Test + void buildPlaylistsComponents_sparseButtonsForPartialPage() + { + List<ActionRow> rows = PlaylistsSlashCmd.buildPlaylistsComponents(1, 1, 3, 0, 42L); + assertEquals(2, rows.size()); + ActionRow selectRow = rows.get(0); + assertEquals(3, selectRow.getComponents().size()); + assertEquals("1", ((Button) selectRow.getComponents().get(0)).getLabel()); + assertEquals("3", ((Button) selectRow.getComponents().get(2)).getLabel()); + } + + @Test + void buildPlaylistsComponents_pageTwoUsesAbsoluteLabels() + { + List<ActionRow> rows = PlaylistsSlashCmd.buildPlaylistsComponents(2, 3, 10, 0, 42L); + assertEquals(4, rows.size()); + ActionRow row1 = rows.get(0); + ActionRow row2 = rows.get(1); + assertEquals("11", ((Button) row1.getComponents().get(0)).getLabel()); + assertEquals("20", ((Button) row2.getComponents().get(4)).getLabel()); + } + + @Test + void buildPlaylistsEmbed_formatsFavoritesWithStars() + { + MessageEmbed embed = PlaylistsSlashCmd.buildPlaylistsEmbed( + List.of("favorites", "night_drive"), + 1, + 1, + 0, + null + ); + + String description = embed.getDescription(); + assertTrue(description.contains("⭐ favorites ⭐")); + assertTrue(description.contains("`night_drive`")); + } + + @Test + void buildPlaylistDetailsComponents_selectedMode_dirty_hasFinalRowWithUnselectSaveDiscard() + { + List<ActionRow> rows = PlaylistsSlashCmd.buildPlaylistDetailsComponents( + 2, 1, 1, 3, 10, 4, 42L, true, true); + + assertEquals(5, rows.size()); + ActionRow row1 = rows.get(0); + ActionRow row2 = rows.get(1); + ActionRow row3 = rows.get(2); + ActionRow row4 = rows.get(3); + ActionRow backRow = rows.get(4); + + assertEquals("Queue Track", ((Button) row1.getComponents().get(0)).getLabel()); + assertEquals("Play Next", ((Button) row1.getComponents().get(1)).getLabel()); + assertEquals("Play Now", ((Button) row1.getComponents().get(2)).getLabel()); + + assertEquals("Move top", ((Button) row2.getComponents().get(0)).getLabel()); + assertEquals("Up 1", ((Button) row2.getComponents().get(1)).getLabel()); + assertEquals("Move bottom", ((Button) row3.getComponents().get(0)).getLabel()); + assertEquals("Down 1", ((Button) row3.getComponents().get(1)).getLabel()); + + assertEquals("Move to", ((Button) row4.getComponents().get(0)).getLabel()); + assertEquals("Remove", ((Button) row4.getComponents().get(1)).getLabel()); + + assertEquals(3, backRow.getComponents().size()); + assertEquals("Unselect song", ((Button) backRow.getComponents().get(0)).getLabel()); + assertEquals("Save", ((Button) backRow.getComponents().get(1)).getLabel()); + assertEquals("Discard", ((Button) backRow.getComponents().get(2)).getLabel()); + } + + @Test + void buildPlaylistDetailsComponents_selectedMode_clean_hasOnlyUnselectOnFinalRow() + { + List<ActionRow> rows = PlaylistsSlashCmd.buildPlaylistDetailsComponents( + 2, 1, 1, 3, 10, 4, 42L, true, false); + assertEquals(5, rows.size()); + ActionRow backRow = rows.get(rows.size() - 1); + assertEquals(1, backRow.getComponents().size()); + assertEquals("Unselect song", ((Button) backRow.getComponents().get(0)).getLabel()); + } + + @Test + void buildPlaylistDetailsComponents_unselectedMode_dirty_hasBackSaveDiscardOnFinalRow() + { + List<ActionRow> rows = PlaylistsSlashCmd.buildPlaylistDetailsComponents( + 2, 1, 1, 3, 10, 0, 42L, true, true); + assertFalse(rows.isEmpty()); + ActionRow playlistRow = rows.get(rows.size() - 2); + assertEquals("Queue Playlist", ((Button) playlistRow.getComponents().get(0)).getLabel()); + assertEquals("Play Playlist", ((Button) playlistRow.getComponents().get(1)).getLabel()); + ActionRow backRow = rows.get(rows.size() - 1); + assertEquals(3, backRow.getComponents().size()); + assertEquals("Back", ((Button) backRow.getComponents().get(0)).getLabel()); + assertEquals("Save", ((Button) backRow.getComponents().get(1)).getLabel()); + assertEquals("Discard", ((Button) backRow.getComponents().get(2)).getLabel()); + } + + @Test + void buildPlaylistDetailsComponents_unselectedMode_clean_hasBackOnlyOnFinalRow() + { + List<ActionRow> rows = PlaylistsSlashCmd.buildPlaylistDetailsComponents( + 2, 1, 1, 3, 10, 0, 42L, true, false); + ActionRow backRow = rows.get(rows.size() - 1); + assertEquals(1, backRow.getComponents().size()); + assertEquals("Back", ((Button) backRow.getComponents().get(0)).getLabel()); + } + + @Test + void playlistTrackPaginationHelpers_computeExpectedValues() + { + assertEquals(10, PlaylistsSlashCmd.getPlaylistTracksOnPage(1, 23)); + assertEquals(3, PlaylistsSlashCmd.getPlaylistTracksOnPage(3, 23)); + assertEquals(3, PlaylistsSlashCmd.getPlaylistTrackTotalPages(23)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/QueueSlashCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/QueueSlashCmdTest.java new file mode 100644 index 0000000..dbd1dfe --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/QueueSlashCmdTest.java @@ -0,0 +1,359 @@ +/* + * 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.unit.commands.v2.music; + +import com.jagrosh.jmusicbot.commands.v2.music.QueueSlashCmd; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.testutil.commands.SlashCommandTestFixture; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link QueueSlashCmd}. + * Uses SlashCommandTestFixture for cleaner, more maintainable tests. + */ +public class QueueSlashCmdTest +{ + private SlashCommandTestFixture fixture; + private QueueSlashCmd command; + + @Mock + private OptionMapping pageOption; + @Mock + private MessageCreateData noMusicMsg; + @Mock + private MessageCreateData nowPlayingMsg; + @Mock + private MessageEmbed embed; + + @BeforeEach + void setUp() + { + MockitoAnnotations.openMocks(this); + fixture = SlashCommandTestFixture.create(); + fixture.withReplyQueueCallback().withRetrieveQueueCallback(); + command = new QueueSlashCmd(fixture.getBot()); + } + + @Test + void testDoCommand_EmptyQueue_ShowsNoMusicMessage() + { + // Given + when(fixture.getEvent().getOption("page")).thenReturn(null); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(null); + + when(noMusicMsg.getEmbeds()).thenReturn(Collections.singletonList(embed)); + MusicService.NowPlayingInfo npInfo = new MusicService.NowPlayingInfo(null, noMusicMsg, false); + when(fixture.getMusicService().getNowPlayingInfo(fixture.getGuild(), fixture.getJda())).thenReturn(npInfo); + when(fixture.getEvent().reply(any(MessageCreateData.class))).thenReturn(fixture.getReplyAction()); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply(any(MessageCreateData.class)); + } + + @Test + void testDoCommand_EmptyQueueWithPlaying_ShowsNowPlayingMessage() + { + // Given + when(fixture.getEvent().getOption("page")).thenReturn(null); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(null); + + when(nowPlayingMsg.getEmbeds()).thenReturn(Collections.singletonList(embed)); + MusicService.NowPlayingInfo npInfo = new MusicService.NowPlayingInfo(nowPlayingMsg, null, true); + when(fixture.getMusicService().getNowPlayingInfo(fixture.getGuild(), fixture.getJda())).thenReturn(npInfo); + when(fixture.getEvent().reply(any(MessageCreateData.class))).thenReturn(fixture.getReplyAction()); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply(any(MessageCreateData.class)); + verify(fixture.getNowPlayingHandler()).setLastNPMessage(fixture.getMessage()); + } + + @Test + void testDoCommand_EmptyQueueNoNowPlayingInfo_ShowsEphemeralWarning() + { + // Given + when(fixture.getEvent().getOption("page")).thenReturn(null); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(null); + when(fixture.getMusicService().getNowPlayingInfo(fixture.getGuild(), fixture.getJda())).thenReturn(null); + when(fixture.getReplyAction().setEphemeral(true)).thenReturn(fixture.getReplyAction()); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply(contains("There is no music in the queue")); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + void testDoCommand_WithQueue_ShowsFirstPageWithButtons() + { + // Given + when(fixture.getEvent().getOption("page")).thenReturn(null); + MusicService.QueueInfo queueInfo = new MusicService.QueueInfo( + new String[]{"Track 1", "Track 2", "Track 3"}, + 300000L, + "Now Playing", + "✅", + RepeatMode.OFF, + QueueType.LINEAR, + null, + null + ); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(queueInfo); + + // When + command.doCommand(fixture.getEvent()); + + // Then - verify replyEmbeds was called with embed + ArgumentCaptor<MessageEmbed> embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getEvent()).replyEmbeds(embedCaptor.capture()); + + MessageEmbed capturedEmbed = embedCaptor.getValue(); + assertEquals("Current Queue", capturedEmbed.getTitle()); + assertTrue(capturedEmbed.getDescription().contains("Now Playing")); + assertTrue(capturedEmbed.getDescription().contains("Track 1")); + + // Verify components were set (sparse selection rows + pagination/actions) + @SuppressWarnings("unchecked") + ArgumentCaptor<List<ActionRow>> componentsCaptor = ArgumentCaptor.forClass(List.class); + verify(fixture.getReplyAction()).setComponents(componentsCaptor.capture()); + List<ActionRow> components = componentsCaptor.getValue(); + assertEquals(2, components.size()); // 3 tracks => one select row + pagination row + } + + @Test + void testDoCommand_WithQueueAndPageNumber_ShowsSpecifiedPage() + { + // Given + int page = 2; + when(fixture.getEvent().getOption("page")).thenReturn(pageOption); + when(pageOption.getAsLong()).thenReturn((long) page); + + MusicService.QueueInfo queueInfo = new MusicService.QueueInfo( + new String[]{"Track 1", "Track 2", "Track 3", "Track 4", "Track 5", + "Track 6", "Track 7", "Track 8", "Track 9", "Track 10", + "Track 11", "Track 12"}, + 600000L, + "Now Playing", + "✅", + RepeatMode.OFF, + QueueType.LINEAR, + null, + null + ); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(queueInfo); + + // When + command.doCommand(fixture.getEvent()); + + // Then - verify embed footer contains page 2 + ArgumentCaptor<MessageEmbed> embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getEvent()).replyEmbeds(embedCaptor.capture()); + MessageEmbed capturedEmbed = embedCaptor.getValue(); + assertNotNull(capturedEmbed.getFooter()); + assertTrue(capturedEmbed.getFooter().getText().contains("Page 2")); + } + + @Test + void testDoCommand_PageNumberExceedsTotalPages_ShowsLastPage() + { + // Given + int page = 5; + when(fixture.getEvent().getOption("page")).thenReturn(pageOption); + when(pageOption.getAsLong()).thenReturn((long) page); + + MusicService.QueueInfo queueInfo = new MusicService.QueueInfo( + new String[]{"Track 1", "Track 2", "Track 3"}, + 300000L, + "Now Playing", + "✅", + RepeatMode.OFF, + QueueType.LINEAR, + null, + null + ); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(queueInfo); + + // When + command.doCommand(fixture.getEvent()); + + // Then - should clamp to last page (1) + ArgumentCaptor<MessageEmbed> embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getEvent()).replyEmbeds(embedCaptor.capture()); + MessageEmbed capturedEmbed = embedCaptor.getValue(); + assertNotNull(capturedEmbed.getFooter()); + assertTrue(capturedEmbed.getFooter().getText().contains("Page 1")); + } + + @Test + void testDoCommand_QueueWithManyTracks_PaginatesCorrectly() + { + // Given + when(fixture.getEvent().getOption("page")).thenReturn(null); + + String[] tracks = new String[25]; // 25 tracks = 3 pages + for (int i = 0; i < 25; i++) + { + tracks[i] = "Track " + (i + 1); + } + MusicService.QueueInfo queueInfo = new MusicService.QueueInfo( + tracks, + 1500000L, + "Now Playing", + "✅", + RepeatMode.OFF, + QueueType.LINEAR, + null, + null + ); + when(fixture.getMusicService().getQueueInfo(fixture.getGuild(), fixture.getJda())).thenReturn(queueInfo); + + // When + command.doCommand(fixture.getEvent()); + + // Then + ArgumentCaptor<MessageEmbed> embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getEvent()).replyEmbeds(embedCaptor.capture()); + MessageEmbed capturedEmbed = embedCaptor.getValue(); + + // Verify footer shows page 1 of 3 + assertNotNull(capturedEmbed.getFooter()); + assertTrue(capturedEmbed.getFooter().getText().contains("Page 1 of 3")); + + // Verify description contains first page tracks + assertTrue(capturedEmbed.getDescription().contains("Track 1")); + assertTrue(capturedEmbed.getDescription().contains("Track 10")); + } + + @Test + void testBuildQueueComponents_NoSelection_Returns3Rows() + { + // Given + int page = 1; + int totalPages = 2; + int tracksOnPage = 10; + int selectedTrack = 0; // No selection + long userId = 123456789L; + + // When + List<ActionRow> components = QueueSlashCmd.buildQueueComponents(page, totalPages, tracksOnPage, selectedTrack, userId); + + // Then + assertEquals(3, components.size()); // Track row 1-5, Track row 6-10, Pagination+Shuffle + } + + @Test + void testBuildQueueComponents_WithSelection_Returns4Rows() + { + // Given + int page = 1; + int totalPages = 2; + int tracksOnPage = 10; + int selectedTrack = 5; // Track 5 selected + long userId = 123456789L; + + // When + List<ActionRow> components = QueueSlashCmd.buildQueueComponents(page, totalPages, tracksOnPage, selectedTrack, userId); + + // Then + assertEquals(4, components.size()); // Track row 1-5, Track row 6-10, Pagination+Shuffle, Actions + } + + @Test + void testBuildQueueComponents_FewerTracks_UsesSparseButtons() + { + // Given + int page = 1; + int totalPages = 1; + int tracksOnPage = 3; // Only 3 tracks + int selectedTrack = 0; + long userId = 123456789L; + + // When + List<ActionRow> components = QueueSlashCmd.buildQueueComponents(page, totalPages, tracksOnPage, selectedTrack, userId); + + // Then + assertEquals(2, components.size()); + ActionRow firstRow = components.get(0); + assertEquals(3, firstRow.getComponents().size()); + assertEquals("1", ((Button) firstRow.getComponents().get(0)).getLabel()); + assertEquals("3", ((Button) firstRow.getComponents().get(2)).getLabel()); + ActionRow paginationAndActionsRow = components.get(1); + assertEquals(1, paginationAndActionsRow.getComponents().size()); + assertEquals("Shuffle", ((Button) paginationAndActionsRow.getComponents().get(0)).getLabel()); + } + + @Test + void testBuildQueueComponents_PageTwo_UsesAbsoluteButtonLabels() + { + List<ActionRow> components = QueueSlashCmd.buildQueueComponents(2, 3, 10, 0, 123456789L); + assertEquals(3, components.size()); + ActionRow row1 = components.get(0); + ActionRow row2 = components.get(1); + assertEquals("11", ((Button) row1.getComponents().get(0)).getLabel()); + assertEquals("15", ((Button) row1.getComponents().get(4)).getLabel()); + assertEquals("16", ((Button) row2.getComponents().get(0)).getLabel()); + assertEquals("20", ((Button) row2.getComponents().get(4)).getLabel()); + } + + @Test + void testGetTracksOnPage_FirstPage() + { + assertEquals(10, QueueSlashCmd.getTracksOnPage(1, 25)); + assertEquals(10, QueueSlashCmd.getTracksOnPage(1, 10)); + assertEquals(5, QueueSlashCmd.getTracksOnPage(1, 5)); + } + + @Test + void testGetTracksOnPage_LastPage() + { + assertEquals(5, QueueSlashCmd.getTracksOnPage(3, 25)); // Page 3 of 25 tracks = 5 remaining + assertEquals(2, QueueSlashCmd.getTracksOnPage(2, 12)); // Page 2 of 12 tracks = 2 remaining + } + + @Test + void testGetTotalPages() + { + assertEquals(1, QueueSlashCmd.getTotalPages(5)); + assertEquals(1, QueueSlashCmd.getTotalPages(10)); + assertEquals(2, QueueSlashCmd.getTotalPages(11)); + assertEquals(3, QueueSlashCmd.getTotalPages(25)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/SearchSlashCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/SearchSlashCmdTest.java new file mode 100644 index 0000000..2b507ef --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/SearchSlashCmdTest.java @@ -0,0 +1,283 @@ +/* + * 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.unit.commands.v2.music; + +import com.jagrosh.jmusicbot.commands.v2.music.SearchSlashCmd; +import com.jagrosh.jmusicbot.service.SearchService; +import com.jagrosh.jmusicbot.testutil.commands.SlashCommandTestFixture; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link SearchSlashCmd}. + * Uses SlashCommandTestFixture for cleaner, more maintainable tests. + */ +public class SearchSlashCmdTest +{ + private SlashCommandTestFixture fixture; + private SearchSlashCmd command; + + @Mock + private OptionMapping queryOption; + + @BeforeEach + void setUp() + { + MockitoAnnotations.openMocks(this); + fixture = SlashCommandTestFixture.create(); + fixture.withReplyQueueCallback(); + command = new SearchSlashCmd(fixture.getBot()); + } + + @Test + void testDoCommand_CallsSearchService() + { + // Given + String query = "test song"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("🔍 Searching YouTube for `" + query + "`..."); + verify(fixture.getSearchService()).search( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(query), + eq("ytsearch:"), + eq(fixture.getTextChannel()), + any(SearchService.SearchCallback.class)); + } + + @Test + void testDoCommand_OnTrackLoaded_EditsMessageWithSuccess() + { + // Given + String query = "test song"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + + ArgumentCaptor<SearchService.SearchCallback> callbackCaptor = + ArgumentCaptor.forClass(SearchService.SearchCallback.class); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getSearchService()).search(any(), any(), any(), any(), any(), callbackCaptor.capture()); + + AudioTrack track = mock(AudioTrack.class); + String formattedMessage = "Added to queue"; + callbackCaptor.getValue().onTrackLoaded(track, 1, formattedMessage); + + verify(fixture.getHook()).editOriginal("✅ " + formattedMessage); + } + + @Test + void testDoCommand_OnSearchResults_ShowsMenu() + { + // Given + String query = "test song"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + when(fixture.getHook().editOriginalEmbeds(any(MessageEmbed.class))).thenReturn(fixture.getEditAction()); + when(fixture.getEditAction().setContent(anyString())).thenReturn(fixture.getEditAction()); + when(fixture.getEditAction().setComponents(any(net.dv8tion.jda.api.components.MessageTopLevelComponent.class))) + .thenReturn(fixture.getEditAction()); + fixture.withEditQueueCallback(); + + ArgumentCaptor<SearchService.SearchCallback> callbackCaptor = + ArgumentCaptor.forClass(SearchService.SearchCallback.class); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getSearchService()).search(any(), any(), any(), any(), any(), callbackCaptor.capture()); + + AudioPlaylist playlist = mock(AudioPlaylist.class); + AudioTrack track1 = mock(AudioTrack.class); + AudioTrack track2 = mock(AudioTrack.class); + AudioTrackInfo info1 = new AudioTrackInfo( + "Song 1", "Author", 1000, "identifier", false, + "https://www.youtube.com/watch?v=1"); + AudioTrackInfo info2 = new AudioTrackInfo( + "Song 2", "Author", 1000, "identifier", false, + "https://www.youtube.com/watch?v=2"); + + when(track1.getInfo()).thenReturn(info1); + when(track2.getInfo()).thenReturn(info2); + when(track1.getDuration()).thenReturn(180000L); + when(track2.getDuration()).thenReturn(240000L); + when(playlist.getTracks()).thenReturn(List.of(track1, track2)); + + callbackCaptor.getValue().onSearchResults(playlist, new String[]{}); + + verify(fixture.getHook()).editOriginalEmbeds(any(MessageEmbed.class)); + verify(fixture.getEditAction()).setContent(""); + verify(fixture.getEditAction()).setComponents(any(net.dv8tion.jda.api.components.MessageTopLevelComponent.class)); + } + + @Test + void testDoCommand_OnSearchResultsEmpty_ShowsNoResults() + { + // Given + String query = "nonexistent"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + + ArgumentCaptor<SearchService.SearchCallback> callbackCaptor = + ArgumentCaptor.forClass(SearchService.SearchCallback.class); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getSearchService()).search(any(), any(), any(), any(), any(), callbackCaptor.capture()); + + AudioPlaylist playlist = mock(AudioPlaylist.class); + when(playlist.getTracks()).thenReturn(Collections.emptyList()); + + callbackCaptor.getValue().onSearchResults(playlist, new String[]{}); + + verify(fixture.getHook()).editOriginal("⚠️ No results found for `" + query + "`."); + } + + @Test + void testDoCommand_OnNoMatches_ShowsWarning() + { + // Given + String query = "nonexistent"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + + ArgumentCaptor<SearchService.SearchCallback> callbackCaptor = + ArgumentCaptor.forClass(SearchService.SearchCallback.class); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getSearchService()).search(any(), any(), any(), any(), any(), callbackCaptor.capture()); + + callbackCaptor.getValue().onNoMatches(query); + + verify(fixture.getHook()).editOriginal("⚠️ No results found for `" + query + "`."); + } + + @Test + void testDoCommand_OnLoadFailed_ShowsError() + { + // Given + String query = "test song"; + String errorMessage = "Failed to load"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + + ArgumentCaptor<SearchService.SearchCallback> callbackCaptor = + ArgumentCaptor.forClass(SearchService.SearchCallback.class); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getSearchService()).search(any(), any(), any(), any(), any(), callbackCaptor.capture()); + + callbackCaptor.getValue().onLoadFailed(errorMessage); + + verify(fixture.getHook()).editOriginal("❌ " + errorMessage); + } + + @Test + void testDoCommand_OnError_ShowsError() + { + // Given + String query = "test song"; + String errorMessage = "An error occurred"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + + ArgumentCaptor<SearchService.SearchCallback> callbackCaptor = + ArgumentCaptor.forClass(SearchService.SearchCallback.class); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getSearchService()).search(any(), any(), any(), any(), any(), callbackCaptor.capture()); + + callbackCaptor.getValue().onError(errorMessage); + + verify(fixture.getHook()).editOriginal("❌ " + errorMessage); + } + + @Test + void testDoCommand_OnSearchResultsWithSelection_AddsTrackToQueue() + { + // Given + String query = "test song"; + when(fixture.getEvent().getOption("query")).thenReturn(queryOption); + when(queryOption.getAsString()).thenReturn(query); + when(fixture.getHook().editOriginalEmbeds(any(MessageEmbed.class))).thenReturn(fixture.getEditAction()); + when(fixture.getEditAction().setContent(anyString())).thenReturn(fixture.getEditAction()); + when(fixture.getEditAction().setComponents(any(net.dv8tion.jda.api.components.MessageTopLevelComponent.class))) + .thenReturn(fixture.getEditAction()); + fixture.withEditQueueCallback(); + when(fixture.getMessage().getIdLong()).thenReturn(67890L); + + ArgumentCaptor<SearchService.SearchCallback> callbackCaptor = + ArgumentCaptor.forClass(SearchService.SearchCallback.class); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getSearchService()).search(any(), any(), any(), any(), any(), callbackCaptor.capture()); + + AudioPlaylist playlist = mock(AudioPlaylist.class); + AudioTrack track1 = mock(AudioTrack.class); + AudioTrackInfo info1 = new AudioTrackInfo( + "Song 1", "Author", 1000, "identifier", false, + "https://www.youtube.com/watch?v=1"); + + when(track1.getInfo()).thenReturn(info1); + when(track1.getDuration()).thenReturn(180000L); + when(playlist.getTracks()).thenReturn(List.of(track1)); + + callbackCaptor.getValue().onSearchResults(playlist, new String[]{}); + + // Verify waiter was set up + verify(fixture.getEventWaiter()).waitForEvent( + eq(StringSelectInteractionEvent.class), any(), any(), anyLong(), any(), any()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/diagnostics/ConfigDiagnosticsTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/diagnostics/ConfigDiagnosticsTest.java new file mode 100644 index 0000000..b2aa46c --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/diagnostics/ConfigDiagnosticsTest.java @@ -0,0 +1,413 @@ +/* + * 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.unit.config.diagnostics; + +import com.jagrosh.jmusicbot.config.diagnostics.ConfigDiagnostics; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigDiagnostics Unit Tests") +class ConfigDiagnosticsTest { + + @Nested + @DisplayName("Missing Required Keys") + class MissingRequiredKeysTests { + + @Test + @DisplayName("detects missing token") + void testDetectMissingKeys_token() { + Map<String, Object> userMap = new HashMap<>(); + // Missing token in user config (already in new format) + userMap.put("discord", Map.of("owner", 123456789L)); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + Config defaults = ConfigFactory.parseMap(defaultMap); + + // Merged will have owner but NO token + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.getMissingRequired().contains("discord.token")); + } + + @Test + @DisplayName("detects missing owner") + void testDetectMissingKeys_owner() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token")); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + Config defaults = ConfigFactory.parseMap(defaultMap); + + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.getMissingRequired().contains("discord.owner")); + } + + @Test + @DisplayName("no missing keys when all required present") + void testDetectMissingKeys_allPresent() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + Config defaults = ConfigFactory.parseMap(defaultMap); + + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.getMissingRequired().isEmpty()); + } + } + + @Nested + @DisplayName("Missing Optional Keys") + class MissingOptionalKeysTests { + + @Test + @DisplayName("detects missing optional keys from defaults") + void testDetectMissingOptionalKeys() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + + Map<String, Object> commands = new HashMap<>(); + commands.put("prefix", "@mention"); + defaultMap.put("commands", commands); + + Config defaults = ConfigFactory.parseMap(defaultMap); + // Merged must NOT have the optional key for it to be reported as missing + Config merged = migratedUserConfig; + + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // Should detect that commands.prefix is in defaults but not in merged config + assertTrue(report.getMissingOptional().contains("commands.prefix")); + } + } + + @Nested + @DisplayName("Deprecated Keys") + class DeprecatedKeysTests { + + @Test + @DisplayName("detects unknown top-level key") + void testDetectDeprecatedKeys_unknownKey() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + userMap.put("unknownKey", "value"); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + Config defaults = ConfigFactory.parseMap(defaultMap); + + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.getDeprecated().contains("unknownKey")); + } + + @Test + @DisplayName("ignores meta.configVersion") + void testDetectDeprecatedKeys_ignoresMetaVersion() { + Map<String, Object> userMap = new HashMap<>(); + Map<String, Object> meta = new HashMap<>(); + meta.put("configVersion", 1); + userMap.put("meta", meta); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + Config defaults = ConfigFactory.parseMap(defaultMap); + + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // meta.configVersion should not be flagged as deprecated + assertFalse(report.getDeprecated().contains("meta.configVersion")); + } + + @Test + @DisplayName("detects nested unknown key") + void testDetectDeprecatedKeys_nestedUnknown() { + Map<String, Object> userMap = new HashMap<>(); + Map<String, Object> commands = new HashMap<>(); + commands.put("unknownCommand", "value"); + userMap.put("commands", commands); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + + Map<String, Object> defaultCommands = new HashMap<>(); + defaultCommands.put("prefix", "@mention"); + defaultMap.put("commands", defaultCommands); + + Config defaults = ConfigFactory.parseMap(defaultMap); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.getDeprecated().contains("commands.unknownCommand")); + } + + @Test + @DisplayName("does not flag user-defined transform under playback.transforms as deprecated") + void testDetectDeprecatedKeys_userDefinedTransformNotDeprecated() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + + // User-defined transform with all required fields + Map<String, Object> spotifyTransform = new HashMap<>(); + spotifyTransform.put("regex", "https?://.*spotify.com/track/([A-Za-z0-9]+).*"); + spotifyTransform.put("replacement", "https://open.spotify.com/track/$1"); + spotifyTransform.put("selector", "title"); + spotifyTransform.put("format", "ytsearch:%s"); + + Map<String, Object> transforms = new HashMap<>(); + transforms.put("spotify", spotifyTransform); + + Map<String, Object> playback = new HashMap<>(); + playback.put("transforms", transforms); + userMap.put("playback", playback); + + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + // Defaults have empty transforms + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + + Map<String, Object> defaultPlayback = new HashMap<>(); + defaultPlayback.put("transforms", Map.of()); // empty transforms in defaults + defaultMap.put("playback", defaultPlayback); + + Config defaults = ConfigFactory.parseMap(defaultMap); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // Should NOT flag any paths under playback.transforms as deprecated + assertFalse(report.getDeprecated().contains("playback.transforms.spotify"), + "User-defined transform 'spotify' should not be flagged as deprecated"); + assertFalse(report.getDeprecated().contains("playback.transforms.spotify.regex"), + "Transform inner key 'regex' should not be flagged as deprecated"); + assertFalse(report.getDeprecated().contains("playback.transforms.spotify.replacement"), + "Transform inner key 'replacement' should not be flagged as deprecated"); + assertFalse(report.getDeprecated().contains("playback.transforms.spotify.selector"), + "Transform inner key 'selector' should not be flagged as deprecated"); + assertFalse(report.getDeprecated().contains("playback.transforms.spotify.format"), + "Transform inner key 'format' should not be flagged as deprecated"); + + // Verify no paths under playback.transforms are in deprecated set + for (String deprecatedPath : report.getDeprecated()) { + assertFalse(deprecatedPath.startsWith("playback.transforms."), + "No paths under playback.transforms should be deprecated, but found: " + deprecatedPath); + } + } + + @Test + @DisplayName("does not flag multiple user-defined transforms as deprecated") + void testDetectDeprecatedKeys_multipleTransformsNotDeprecated() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + + // Multiple user-defined transforms + Map<String, Object> spotifyTransform = Map.of( + "regex", "https?://.*spotify.com/track/.*", + "replacement", "https://open.spotify.com/track/$1", + "selector", "title", + "format", "ytsearch:%s" + ); + Map<String, Object> youtubeTransform = Map.of( + "regex", "https?://.*youtube.com/.*", + "replacement", "https://www.youtube.com/$1", + "selector", "title", + "format", "%s" + ); + + Map<String, Object> transforms = new HashMap<>(); + transforms.put("spotify", spotifyTransform); + transforms.put("youtube", youtubeTransform); + + Map<String, Object> playback = new HashMap<>(); + playback.put("transforms", transforms); + userMap.put("playback", playback); + + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + defaultMap.put("discord", Map.of("token", "", "owner", 0L)); + defaultMap.put("playback", Map.of("transforms", Map.of())); + + Config defaults = ConfigFactory.parseMap(defaultMap); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertFalse(report.getDeprecated().contains("playback.transforms.spotify")); + assertFalse(report.getDeprecated().contains("playback.transforms.youtube")); + } + + @Test + @DisplayName("still flags unknown keys under playback that are not transforms") + void testDetectDeprecatedKeys_unknownPlaybackKeyStillFlagged() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + + Map<String, Object> playback = new HashMap<>(); + playback.put("transforms", Map.of()); // valid empty transforms + playback.put("unknownSection", "some value"); // unknown key + userMap.put("playback", playback); + + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + defaultMap.put("discord", Map.of("token", "", "owner", 0L)); + defaultMap.put("playback", Map.of("transforms", Map.of())); + + Config defaults = ConfigFactory.parseMap(defaultMap); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // Unknown key should still be flagged + assertTrue(report.getDeprecated().contains("playback.unknownSection"), + "Unknown key under playback should be flagged as deprecated"); + } + } + + @Nested + @DisplayName("Report Methods") + class ReportMethodsTests { + + @Test + @DisplayName("hasIssues returns true when issues present") + void testHasIssues() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("unknownKey", "value"); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Config defaults = ConfigFactory.empty(); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.hasIssues()); + } + + @Test + @DisplayName("hasErrors returns true when required keys missing") + void testHasErrors() { + Map<String, Object> userMap = new HashMap<>(); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + Config defaults = ConfigFactory.parseMap(defaultMap); + + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.hasErrors()); + } + + @Test + @DisplayName("hasWarnings returns true when optional keys missing or deprecated keys present") + void testHasWarnings() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("discord", Map.of("token", "test_token", "owner", 123456789L)); + userMap.put("unknownKey", "value"); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Map<String, Object> defaultMap = new HashMap<>(); + Map<String, Object> discord = new HashMap<>(); + discord.put("token", ""); + discord.put("owner", 0L); + defaultMap.put("discord", discord); + Config defaults = ConfigFactory.parseMap(defaultMap); + + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + assertTrue(report.hasWarnings()); + } + + @Test + @DisplayName("generateMessage formats diagnostic message correctly") + void testGenerateMessage() { + Map<String, Object> userMap = new HashMap<>(); + userMap.put("unknownKey", "value"); + Config migratedUserConfig = ConfigFactory.parseMap(userMap); + + Config defaults = ConfigFactory.empty(); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + + ConfigDiagnostics.Report report = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + String message = report.generateMessage(); + + assertNotNull(message); + assertTrue(message.contains("WARN") || message.contains("ERROR")); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/io/ConfigIOTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/io/ConfigIOTest.java new file mode 100644 index 0000000..572c0e4 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/io/ConfigIOTest.java @@ -0,0 +1,321 @@ +/* + * 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.unit.config.io; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.config.io.ConfigIO; +import com.typesafe.config.Config; +import com.typesafe.config.parser.ConfigDocument; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigIO Unit Tests") +class ConfigIOTest extends BaseConfigTest { + + // ==================== File Operations Tests ==================== + + @Nested + @DisplayName("getConfigPath() Tests") + class GetConfigPathTests { + + @Test + @DisplayName("getConfigPath() returns default path when no system property set") + void getConfigPathReturnsDefaultWhenNoSystemProperty() { + System.clearProperty("config.file"); + System.clearProperty("config"); + + Path path = ConfigIO.getConfigPath(); + + assertNotNull(path); + assertTrue(path.toString().contains("config.txt") || path.toFile().exists()); + } + + @Test + @DisplayName("getConfigPath() uses config.file system property") + void getConfigPathUsesConfigFileProperty() throws IOException { + Path testFile = createTempConfigFile("token = test"); + setConfigFileProperty(testFile); + + Path path = ConfigIO.getConfigPath(); + + assertEquals(testFile.toAbsolutePath(), path.toAbsolutePath()); + } + + @Test + @DisplayName("getConfigPath() uses config system property as fallback") + void getConfigPathUsesConfigPropertyAsFallback() throws IOException { + Path testFile = createTempConfigFile("token = test"); + System.clearProperty("config.file"); + System.setProperty("config", testFile.toString()); + + Path path = ConfigIO.getConfigPath(); + + assertEquals(testFile.toAbsolutePath(), path.toAbsolutePath()); + } + + @Test + @DisplayName("getConfigPath() sets config.file property when file exists") + void getConfigPathSetsPropertyWhenFileExists() throws IOException { + Path testFile = createTempConfigFile("token = test"); + System.clearProperty("config.file"); + System.clearProperty("config"); + + // Create a config.txt in temp dir + Path configTxt = tempDir.resolve("config.txt"); + Files.write(configTxt, "token = test".getBytes()); + + // Set working directory to temp dir + System.setProperty("user.dir", tempDir.toString()); + + Path path = ConfigIO.getConfigPath(); + + // The property should be set if file exists + assertNotNull(path); + } + } + + @Nested + @DisplayName("writeConfigFile() Tests") + class WriteConfigFileTests { + + @Test + @DisplayName("writeConfigFile() writes content to file") + void writeConfigFileWritesContent() throws IOException { + Path testFile = tempDir.resolve("test-config.conf"); + String content = "token = test_token\nowner = 123456789"; + + ConfigIO.writeConfigFile(testFile, content); + + assertFileExists(testFile); + String writtenContent = readFileContent(testFile); + assertEquals(content, writtenContent); + } + + @Test + @DisplayName("writeConfigFile() overwrites existing file") + void writeConfigFileOverwritesExistingFile() throws IOException { + Path testFile = createTempConfigFile("old content"); + String newContent = "token = new_token\nowner = 987654321"; + + ConfigIO.writeConfigFile(testFile, newContent); + + String writtenContent = readFileContent(testFile); + assertEquals(newContent, writtenContent); + assertFalse(writtenContent.contains("old content")); + } + + @Test + @DisplayName("writeConfigFile() throws IOException for invalid path") + void writeConfigFileThrowsIOExceptionForInvalidPath() { + // Try to write to a directory (should fail) + Path invalidPath = tempDir; + + assertThrows(IOException.class, () -> { + ConfigIO.writeConfigFile(invalidPath, "content"); + }); + } + } + + @Nested + @DisplayName("appendToConfigFile() Tests") + class AppendToConfigFileTests { + + @Test + @DisplayName("appendToConfigFile() appends content to existing file") + void appendToConfigFileAppendsContent() throws IOException { + Path testFile = createTempConfigFile("original content\n"); + String appendContent = "new content\n"; + + ConfigIO.appendToConfigFile(testFile, appendContent); + + String fileContent = readFileContent(testFile); + assertTrue(fileContent.contains("original content")); + assertTrue(fileContent.contains("new content")); + } + + @Test + @DisplayName("appendToConfigFile() throws exception if file doesn't exist") + void appendToConfigFileThrowsExceptionIfFileNotExists() { + Path testFile = tempDir.resolve("new-config.conf"); + String content = "new content\n"; + + // appendToConfigFile uses APPEND option which requires file to exist + assertThrows(java.io.IOException.class, () -> { + ConfigIO.appendToConfigFile(testFile, content); + }); + } + + @Test + @DisplayName("appendToConfigFile() can append multiple times") + void appendToConfigFileCanAppendMultipleTimes() throws IOException { + Path testFile = createTempConfigFile("line1\n"); + + ConfigIO.appendToConfigFile(testFile, "line2\n"); + ConfigIO.appendToConfigFile(testFile, "line3\n"); + + String fileContent = readFileContent(testFile); + assertTrue(fileContent.contains("line1")); + assertTrue(fileContent.contains("line2")); + assertTrue(fileContent.contains("line3")); + } + } + + @Nested + @DisplayName("configFileExists() Tests") + class ConfigFileExistsTests { + + @Test + @DisplayName("configFileExists() returns true for existing file") + void configFileExistsReturnsTrueForExistingFile() throws IOException { + Path testFile = createTempConfigFile("content"); + + assertTrue(ConfigIO.configFileExists(testFile)); + } + + @Test + @DisplayName("configFileExists() returns false for non-existing file") + void configFileExistsReturnsFalseForNonExistingFile() { + Path testFile = tempDir.resolve("nonexistent.conf"); + + assertFalse(ConfigIO.configFileExists(testFile)); + } + + @Test + @DisplayName("configFileExists() returns true for directory (exists check)") + void configFileExistsReturnsTrueForDirectory() { + // configFileExists() checks if path exists, not if it's a file + // So it returns true for directories too + assertTrue(ConfigIO.configFileExists(tempDir)); + } + } + + // ==================== Resource Loading Tests ==================== + + @Nested + @DisplayName("loadDefaults() Tests") + class LoadDefaultsTests { + + @Test + @DisplayName("loadDefaults loads reference.conf from classpath") + void loadDefaultsLoadsReferenceConf() { + Config defaults = ConfigIO.loadDefaults(); + + assertNotNull(defaults); + // Should have meta.configVersion + assertTrue(defaults.hasPath("meta.configVersion")); + } + } + + @Nested + @DisplayName("loadDefaultConfig() Tests") + class LoadDefaultConfigTests { + + @Test + @DisplayName("loadDefaultConfig() loads from reference.conf") + void loadDefaultConfigLoadsFromReference() { + String config = ConfigIO.loadDefaultConfig(); + + assertNotNull(config); + assertFalse(config.isEmpty()); + // Should contain token and owner fields + assertTrue(config.contains("token") || config.contains("BOT_TOKEN_HERE")); + } + + @Test + @DisplayName("loadDefaultConfig() returns fallback when resource missing") + void loadDefaultConfigReturnsFallbackWhenResourceMissing() { + // This test verifies the fallback behavior exists + // The actual fallback would only trigger if resource is truly missing + String config = ConfigIO.loadDefaultConfig(); + + assertNotNull(config); + // Should either be from resource or fallback + assertTrue(config.contains("token") || config.contains("BOT_TOKEN_HERE")); + } + } + + @Nested + @DisplayName("loadReferenceConfigAsDocument() Tests") + class LoadReferenceConfigAsDocumentTests { + + @Test + @DisplayName("loadReferenceConfigAsDocument loads reference.conf as ConfigDocument") + void loadReferenceConfigAsDocumentLoadsDocument() { + ConfigDocument doc = ConfigIO.loadReferenceConfigAsDocument(); + + assertNotNull(doc, "Should load reference.conf as ConfigDocument"); + } + + @Test + @DisplayName("loadReferenceConfigAsDocument preserves comments") + void loadReferenceConfigAsDocumentPreservesComments() { + ConfigDocument doc = ConfigIO.loadReferenceConfigAsDocument(); + + assertNotNull(doc); + String rendered = doc.render(); + // reference.conf should have comments + assertTrue(rendered.contains("#") || rendered.contains("//"), + "ConfigDocument should preserve comments from reference.conf"); + } + + @Test + @DisplayName("loadReferenceConfigAsDocument preserves structure") + void loadReferenceConfigAsDocumentPreservesStructure() { + ConfigDocument doc = ConfigIO.loadReferenceConfigAsDocument(); + + assertNotNull(doc); + String rendered = doc.render(); + // Should have nested structure + assertTrue(rendered.contains("meta {") || rendered.contains("meta")); + assertTrue(rendered.contains("discord {") || rendered.contains("discord")); + } + + @Test + @DisplayName("loadReferenceConfigAsDocument can be parsed back to Config") + void loadReferenceConfigAsDocumentCanBeParsedToConfig() { + ConfigDocument doc = ConfigIO.loadReferenceConfigAsDocument(); + + assertNotNull(doc); + // Should be parseable as Config + Config config = com.typesafe.config.ConfigFactory.parseString(doc.render()); + assertNotNull(config); + assertTrue(config.hasPath("meta.configVersion")); + } + } + + @Nested + @DisplayName("loadReferenceConfigAsString() Tests") + class LoadReferenceConfigAsStringTests { + + @Test + @DisplayName("loadReferenceConfigAsString loads reference.conf content") + void loadReferenceConfigAsStringLoadsContent() { + String content = ConfigIO.loadReferenceConfigAsString(); + + assertNotNull(content); + assertFalse(content.isEmpty()); + // Should contain key config elements + assertTrue(content.contains("meta") || content.contains("discord")); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/loader/ConfigLoaderTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/loader/ConfigLoaderTest.java new file mode 100644 index 0000000..f372459 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/loader/ConfigLoaderTest.java @@ -0,0 +1,265 @@ +/* + * 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.unit.config.loader; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.config.loader.ConfigLoader; +import com.jagrosh.jmusicbot.config.migration.ConfigMigrationException; +import com.jagrosh.jmusicbot.testutil.config.LegacyConfigBuilder; +import com.jagrosh.jmusicbot.testutil.config.V1ConfigBuilder; +import com.typesafe.config.Config; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigLoader Unit Tests") +class ConfigLoaderTest extends BaseConfigTest { + + @Nested + @DisplayName("loadRawUserConfig() Tests") + class LoadRawUserConfigTests { + + @Test + @DisplayName("loadRawUserConfig() loads existing config file") + void loadRawUserConfigLoadsExistingFile() throws IOException { + // Test with legacy format - loadRawUserConfig returns raw config before migration + Path configFile = createTempConfigFile("token = test_token\nowner = 123456789"); + + Config config = ConfigLoader.loadRawUserConfig(configFile); + + assertNotNull(config); + // Raw config has flat keys + assertEquals("test_token", config.getString("token")); + assertEquals(123456789L, config.getLong("owner")); + } + + @Test + @DisplayName("loadRawUserConfig() returns empty config for non-existing file") + void loadRawUserConfigReturnsEmptyForNonExistingFile() { + Path nonExistentFile = tempDir.resolve("nonexistent.conf"); + + Config config = ConfigLoader.loadRawUserConfig(nonExistentFile); + + assertNotNull(config); + assertTrue(config.isEmpty()); + assertFalse(config.hasPath("token")); + } + + @Test + @DisplayName("loadRawUserConfig() loads complex config with nested structures") + void loadRawUserConfigLoadsComplexConfig() throws IOException { + String configContent = """ + token = test_token + owner = 123456789 + aliases { + play = [ p ] + skip = [ voteskip, vs ] + } + audiosources = [ youtube, soundcloud ] + """; + Path configFile = createTempConfigFile(configContent); + + Config config = ConfigLoader.loadRawUserConfig(configFile); + + assertNotNull(config); + assertTrue(config.hasPath("aliases.play")); + assertEquals(2, config.getStringList("aliases.skip").size()); + assertEquals(2, config.getStringList("audiosources").size()); + } + + @Test + @DisplayName("loadRawUserConfig() handles empty config file") + void loadRawUserConfigHandlesEmptyFile() throws IOException { + Path configFile = createTempConfigFile(""); + + Config config = ConfigLoader.loadRawUserConfig(configFile); + + assertNotNull(config); + assertTrue(config.isEmpty()); + } + } + + @Nested + @DisplayName("loadMergedConfig() Tests") + class LoadMergedConfigTests { + + @Test + @DisplayName("loadMergedConfig() uses defaults when user config is empty") + void loadMergedConfigUsesDefaultsWhenUserConfigEmpty() { + Path nonExistentFile = tempDir.resolve("nonexistent.conf"); + + Config merged = ConfigLoader.loadMergedConfig(nonExistentFile); + + assertNotNull(merged); + // Should have access to defaults from reference.conf if available + // The exact behavior depends on what's in reference.conf + } + + @Test + @DisplayName("loadMergedConfig() preserves user-specific values") + void loadMergedConfigPreservesUserValues() throws IOException { + // Legacy config gets migrated to nested format + String configContent = """ + token = custom_token + owner = 987654321 + prefix = custom_prefix + stayinchannel = true + """; + Path configFile = createTempConfigFile(configContent); + + Config merged = ConfigLoader.loadMergedConfig(configFile); + + assertNotNull(merged); + // After migration, check nested paths + assertEquals("custom_token", merged.getString("discord.token")); + assertEquals(987654321L, merged.getLong("discord.owner")); + assertEquals("custom_prefix", merged.getString("commands.prefix")); + assertTrue(merged.getBoolean("voice.stayInChannel")); + } + + } + + @Nested + @DisplayName("Migration Exception Propagation Tests") + class MigrationExceptionTests { + + @Test + @DisplayName("loadMigratedUserConfig() propagates ConfigMigrationException when migration path is missing") + void loadMigratedUserConfigPropagatesException() { + // Create a legacy config (version 0) + Config rawUserConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .build(); + + // Create defaults that claim version 3 (no migration path exists for 2->3) + // Migration will succeed for 0->1 and 1->2, then fail for 2->3 + Config defaults = V1ConfigBuilder.create() + .withMetaVersion(3) + .build(); + + // Should throw ConfigMigrationException, not swallow it + assertThrows(ConfigMigrationException.class, () -> { + ConfigLoader.loadMigratedUserConfig(rawUserConfig, defaults); + }); + } + } + + @Nested + @DisplayName("Fresh Install (Empty Config) Tests") + class FreshInstallTests { + + @Test + @DisplayName("loadMigratedUserConfig() skips migration for empty config (fresh install)") + void loadMigratedUserConfigSkipsMigrationForEmptyConfig() { + // Empty config simulates no config file exists (fresh install) + Config emptyConfig = com.typesafe.config.ConfigFactory.empty(); + Config defaults = V1ConfigBuilder.create() + .withMetaVersion(2) + .build(); + + // Should return empty config without triggering migration + Config result = ConfigLoader.loadMigratedUserConfig(emptyConfig, defaults); + + assertNotNull(result); + assertTrue(result.isEmpty(), "Empty config should remain empty - no migration should occur"); + } + + @Test + @DisplayName("loadMergedConfig() returns defaults for non-existent file without migration") + void loadMergedConfigReturnsDefaultsForNonExistentFile() { + Path nonExistentFile = tempDir.resolve("nonexistent.conf"); + + // Should not throw and should return merged config with defaults + Config merged = ConfigLoader.loadMergedConfig(nonExistentFile); + + assertNotNull(merged); + // The merged config should have values from defaults (reference.conf) + // Verify it has the expected structure from v1 format + assertTrue(merged.hasPath("meta.configVersion")); + assertEquals(2, merged.getInt("meta.configVersion")); + } + + @Test + @DisplayName("Empty config is not treated as version 0 legacy config") + void emptyConfigNotTreatedAsLegacy() { + // Create an empty config (simulating non-existent file) + Config emptyConfig = com.typesafe.config.ConfigFactory.empty(); + Config defaults = V1ConfigBuilder.create() + .withMetaVersion(2) + .build(); + + Config result = ConfigLoader.loadMigratedUserConfig(emptyConfig, defaults); + + // Result should be empty - not contain migrated meta.configVersion + // If migration occurred, it would add meta.configVersion = 1 + assertFalse(result.hasPath("meta.configVersion"), + "Empty config should not have meta.configVersion added by migration"); + assertTrue(result.isEmpty(), + "Empty config should remain empty after loadMigratedUserConfig"); + } + } + + @Nested + @DisplayName("Version Validation Tests") + class VersionValidationTests { + + @Test + @DisplayName("loadMigratedUserConfig() returns raw config when user version is higher than latest") + void loadMigratedUserConfigReturnsRawConfigWhenUserVersionHigher() { + // Create a config with version higher than latest (user manually set version 3) + Config rawUserConfig = V1ConfigBuilder.create() + .withDiscordToken("test_token") + .withMetaVersion(3) + .build(); + + // Defaults have version 2 (the actual latest) + Config defaults = V1ConfigBuilder.create() + .withMetaVersion(2) + .build(); + + // Should return raw config without modification (and log a warning) + Config result = ConfigLoader.loadMigratedUserConfig(rawUserConfig, defaults); + + // Config should be returned unchanged + assertNotNull(result); + assertEquals(3, result.getInt("meta.configVersion")); + assertEquals("test_token", result.getString("discord.token")); + } + + @Test + @DisplayName("loadMigratedUserConfig() does not throw when user version is higher than latest") + void loadMigratedUserConfigDoesNotThrowWhenUserVersionHigher() { + Config rawUserConfig = V1ConfigBuilder.create() + .withDiscordToken("test_token") + .withMetaVersion(99) // Absurdly high version + .build(); + + Config defaults = V1ConfigBuilder.create() + .withMetaVersion(2) + .build(); + + // Should not throw - just warn and return the config + assertDoesNotThrow(() -> { + ConfigLoader.loadMigratedUserConfig(rawUserConfig, defaults); + }); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/ConfigMigrationTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/ConfigMigrationTest.java new file mode 100644 index 0000000..3564fd5 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/ConfigMigrationTest.java @@ -0,0 +1,148 @@ +/* + * 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.unit.config.migration; + +import com.jagrosh.jmusicbot.config.migration.ConfigMigration; +import com.jagrosh.jmusicbot.config.migration.ConfigMigrationException; +import com.jagrosh.jmusicbot.testutil.config.LegacyConfigBuilder; +import com.jagrosh.jmusicbot.testutil.config.V1ConfigBuilder; +import com.typesafe.config.Config; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigMigration Unit Tests") +class ConfigMigrationTest { + + @Nested + @DisplayName("Version Detection") + class VersionDetectionTests { + + @Test + @DisplayName("detectVersion returns 0 for legacy config without meta.configVersion") + void testDetectVersion_legacyConfig_returns0() { + Config config = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .build(); + + int version = ConfigMigration.detectVersion(config); + + assertEquals(0, version); + } + + @Test + @DisplayName("detectVersion returns 1 for config with meta.configVersion = 1") + void testDetectVersion_newConfig_returns1() { + Config config = V1ConfigBuilder.create() + .withMetaVersion(1) + .build(); + + int version = ConfigMigration.detectVersion(config); + + assertEquals(1, version); + } + + @Test + @DisplayName("detectVersion returns 0 for empty config") + void testDetectVersion_emptyConfig_returns0() { + Config config = LegacyConfigBuilder.create().build(); + + int version = ConfigMigration.detectVersion(config); + + assertEquals(0, version); + } + } + + @Nested + @DisplayName("Latest Version Detection") + class LatestVersionTests { + + @Test + @DisplayName("getLatestVersion returns 2 from defaults with meta.configVersion = 2") + void testGetLatestVersion_returns2() { + Config defaults = V1ConfigBuilder.create() + .withMetaVersion(2) + .build(); + + int version = ConfigMigration.getLatestVersion(defaults); + + assertEquals(2, version); + } + + @Test + @DisplayName("getLatestVersion returns 2 for defaults without meta.configVersion") + void testGetLatestVersion_noVersion_returns2() { + Config defaults = LegacyConfigBuilder.create().build(); + + int version = ConfigMigration.getLatestVersion(defaults); + + assertEquals(2, version); + } + } + + @Nested + @DisplayName("Migration") + class MigrationTests { + + @Test + @DisplayName("migrate returns same config when fromVersion >= toVersion") + void testMigrate_noMigrationNeeded() { + Config config = V1ConfigBuilder.create() + .withMetaVersion(2) + .withDiscordToken("test_token") + .build(); + + Config result = ConfigMigration.migrate(config, 2, 2); + + assertEquals(config, result); + } + + @Test + @DisplayName("migrate applies 0->1 migration successfully") + void testMigrate_v0ToV1_success() { + Config legacyConfig = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .withPrefix("!!") + .build(); + + Config migrated = ConfigMigration.migrate(legacyConfig, 0, 1); + + assertNotNull(migrated); + assertTrue(migrated.hasPath("meta.configVersion")); + assertEquals(1, migrated.getInt("meta.configVersion")); + assertTrue(migrated.hasPath("discord.token")); + assertEquals("test_token", migrated.getString("discord.token")); + assertTrue(migrated.hasPath("discord.owner")); + assertEquals(123456789L, migrated.getLong("discord.owner")); + assertTrue(migrated.hasPath("commands.prefix")); + assertEquals("!!", migrated.getString("commands.prefix")); + } + + @Test + @DisplayName("migrate throws exception for unsupported version range") + void testMigrate_invalidVersionRange_throwsException() { + Config config = LegacyConfigBuilder.create().build(); + + assertThrows(ConfigMigrationException.class, () -> { + ConfigMigration.migrate(config, 0, 3); // No migration for 2->3 + }); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/versions/LegacyToV1Test.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/versions/LegacyToV1Test.java new file mode 100644 index 0000000..630a7b7 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/versions/LegacyToV1Test.java @@ -0,0 +1,285 @@ +/* + * 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.unit.config.migration.versions; + +import com.jagrosh.jmusicbot.config.migration.versions.LegacyToV1; +import com.jagrosh.jmusicbot.testutil.config.ConfigMigrationAssertions; +import com.jagrosh.jmusicbot.testutil.config.LegacyConfigBuilder; +import com.typesafe.config.Config; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("MigrationV0ToV1 Unit Tests") +class LegacyToV1Test { + + private final LegacyToV1 migration = new LegacyToV1(); + + @Test + @DisplayName("getFromVersion returns 0") + void testGetFromVersion() { + assertEquals(0, migration.getFromVersion()); + } + + @Test + @DisplayName("getToVersion returns 1") + void testGetToVersion() { + assertEquals(1, migration.getToVersion()); + } + + @Nested + @DisplayName("Simple Key Mappings") + class SimpleKeyMappings { + + @Test + @DisplayName("migrate maps token to discord.token") + void testMigrate_token() { + Config legacy = LegacyConfigBuilder.create() + .withToken("test_token_123") + .build(); + + Config migrated = migration.migrate(legacy); + + ConfigMigrationAssertions.assertKeyMigrated(legacy, migrated, "token", "discord.token"); + } + + @Test + @DisplayName("migrate maps owner to discord.owner") + void testMigrate_owner() { + Config legacy = LegacyConfigBuilder.create() + .withOwner(987654321L) + .build(); + + Config migrated = migration.migrate(legacy); + + ConfigMigrationAssertions.assertKeyMigratedLong(legacy, migrated, "owner", "discord.owner"); + } + + @Test + @DisplayName("migrate maps prefix to commands.prefix") + void testMigrate_prefix() { + Config legacy = LegacyConfigBuilder.create() + .withPrefix("!!") + .build(); + + Config migrated = migration.migrate(legacy); + + ConfigMigrationAssertions.assertKeyMigrated(legacy, migrated, "prefix", "commands.prefix"); + } + + @Test + @DisplayName("migrate maps altprefix NONE to commands.altPrefix NONE") + void testMigrate_altprefix_noneToNone() { + Config legacy = LegacyConfigBuilder.create() + .withAltPrefix("NONE") + .build(); + + Config migrated = migration.migrate(legacy); + + // Verify commands.altPrefix exists and is "NONE" (never null) + assertTrue(migrated.hasPath("commands.altPrefix")); + assertEquals("NONE", migrated.getString("commands.altPrefix")); + } + + @Test + @DisplayName("migrate maps altprefix value to commands.altPrefix") + void testMigrate_altprefix_value() { + Config legacy = LegacyConfigBuilder.create() + .withAltPrefix("??") + .build(); + + Config migrated = migration.migrate(legacy); + + ConfigMigrationAssertions.assertKeyMigrated(legacy, migrated, "altprefix", "commands.altPrefix"); + } + + @Test + @DisplayName("migrate adds meta.configVersion = 1") + void testMigrate_addsMetaVersion() { + Config legacy = LegacyConfigBuilder.create().build(); + + Config migrated = migration.migrate(legacy); + + assertTrue(migrated.hasPath("meta.configVersion")); + assertEquals(1, migrated.getInt("meta.configVersion")); + } + } + + @Nested + @DisplayName("Audio Sources Migration") + class AudioSourcesMigration { + + @Test + @DisplayName("migrate converts audiosources list to playback.audioSources booleans") + void testMigrate_audiosources_listToBooleans() { + Config legacy = LegacyConfigBuilder.create() + .withAudioSources("youtube", "soundcloud", "local") + .build(); + + Config migrated = migration.migrate(legacy); + + assertTrue(migrated.hasPath("playback.audioSources")); + Config audioSources = migrated.getConfig("playback.audioSources"); + assertTrue(audioSources.getBoolean("youtube")); + assertTrue(audioSources.getBoolean("soundcloud")); + assertTrue(audioSources.getBoolean("local")); + assertFalse(audioSources.getBoolean("bandcamp")); + } + + @Test + @DisplayName("migrate sets all audioSources to true when audiosources key is missing") + void testMigrate_audiosources_missing_defaultsToAll() { + Config legacy = LegacyConfigBuilder.create().build(); + + Config migrated = migration.migrate(legacy); + + // When audiosources is missing, migration should still create audioSources with all enabled + // (This is the default behavior - all sources enabled when key is missing) + assertTrue(migrated.hasPath("playback.audioSources")); + Config audioSources = migrated.getConfig("playback.audioSources"); + // All should be enabled by default + assertTrue(audioSources.getBoolean("youtube")); + assertTrue(audioSources.getBoolean("soundcloud")); + assertTrue(audioSources.getBoolean("local")); + } + + @Test + @DisplayName("migrate handles empty audiosources list") + void testMigrate_audiosources_emptyList() { + Config legacy = LegacyConfigBuilder.create() + .withAudioSources(List.of()) + .build(); + + Config migrated = migration.migrate(legacy); + + assertTrue(migrated.hasPath("playback.audioSources")); + Config audioSources = migrated.getConfig("playback.audioSources"); + // All should be false when list is empty + assertFalse(audioSources.getBoolean("youtube")); + assertFalse(audioSources.getBoolean("soundcloud")); + } + } + + @Nested + @DisplayName("Complex Configurations") + class ComplexConfigurations { + + @Test + @DisplayName("migrate preserves aliases nested config") + void testMigrate_aliases_preserved() { + Config legacy = LegacyConfigBuilder.create() + .withAliases(Map.of( + "play", List.of("p", "playmusic"), + "skip", List.of("voteskip") + )) + .build(); + + Config migrated = migration.migrate(legacy); + + assertTrue(migrated.hasPath("commands.aliases")); + Config aliasesConfig = migrated.getConfig("commands.aliases"); + assertEquals(List.of("p", "playmusic"), aliasesConfig.getStringList("play")); + assertEquals(List.of("voteskip"), aliasesConfig.getStringList("skip")); + } + + @Test + @DisplayName("migrate preserves transforms nested config") + void testMigrate_transforms_preserved() { + Config legacy = LegacyConfigBuilder.create() + .withTransforms(Map.of("test", "value")) + .build(); + + Config migrated = migration.migrate(legacy); + + assertTrue(migrated.hasPath("playback.transforms")); + Config transformsConfig = migrated.getConfig("playback.transforms"); + assertEquals("value", transformsConfig.getString("test")); + } + } + + @Test + @DisplayName("migrate handles complete legacy config") + void testMigrate_allLegacyKeys() { + Config legacy = LegacyConfigBuilder.create() + .withToken("test_token") + .withOwner(123456789L) + .withPrefix("!!") + .withAltPrefix("NONE") + .withHelp("help") + .withGame("DEFAULT") + .withStatus("ONLINE") + .withSongInStatus(true) + .withSuccess("✅") + .withStayInChannel(false) + .withMaxTime(3600L) + .withSkipRatio(0.75) + .withLogLevel("info") + .build(); + + Config migrated = migration.migrate(legacy); + + // Verify all keys migrated correctly + assertEquals("test_token", migrated.getString("discord.token")); + assertEquals(123456789L, migrated.getLong("discord.owner")); + assertEquals("!!", migrated.getString("commands.prefix")); + // altPrefix should be "NONE" (never null) + assertEquals("NONE", migrated.getString("commands.altPrefix")); + assertEquals("help", migrated.getString("commands.help")); + assertEquals("DEFAULT", migrated.getString("presence.game")); + assertEquals("ONLINE", migrated.getString("presence.status")); + assertTrue(migrated.getBoolean("presence.songInStatus")); + assertEquals("✅", migrated.getString("ui.emojis.success")); + assertFalse(migrated.getBoolean("voice.stayInChannel")); + assertEquals(3600L, migrated.getLong("playback.maxTrackSeconds")); + assertEquals(0.75, migrated.getDouble("playback.skipRatio")); + assertEquals("info", migrated.getString("logging.level")); + + // Verify legacy flat keys are NOT present in migrated config + ConfigMigrationAssertions.assertLegacyKeysRemoved(migrated, + "token", "owner", "prefix", "altprefix"); + } + + @Test + @DisplayName("migrate converts status to uppercase") + void testMigrate_statusUppercase() { + Config legacy = LegacyConfigBuilder.create() + .withStatus("idle") + .build(); + + Config migrated = migration.migrate(legacy); + + assertEquals("IDLE", migrated.getString("presence.status")); + } + + @Test + @DisplayName("migrate ignores lyrics keys (lyrics functionality removed)") + void testMigrate_lyricsDefault() { + Config legacy = LegacyConfigBuilder.create() + .withCustom("lyrics.default", "Genius") + .build(); + + Config migrated = migration.migrate(legacy); + + // Lyrics keys should NOT be migrated since lyrics functionality is being removed + assertFalse(migrated.hasPath("lyrics.default")); + assertFalse(migrated.hasPath("lyrics")); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/versions/V1ToV2Test.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/versions/V1ToV2Test.java new file mode 100644 index 0000000..edbba24 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/migration/versions/V1ToV2Test.java @@ -0,0 +1,45 @@ +/* + * 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.unit.config.migration.versions; + +import com.jagrosh.jmusicbot.config.migration.versions.V1ToV2; +import com.jagrosh.jmusicbot.testutil.config.V1ConfigBuilder; +import com.typesafe.config.Config; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("MigrationV1ToV2 Unit Tests") +class V1ToV2Test { + private final V1ToV2 migration = new V1ToV2(); + + @Test + @DisplayName("migrate renames updateProgressBar to showProgressBar and bumps version") + void migrate_renamesProgressBarKey_andBumpsVersion() { + Config source = V1ConfigBuilder.create() + .withMetaVersion(1) + .withCustom("nowPlaying", "updateProgressBar", true) + .build(); + + Config migrated = migration.migrate(source); + + assertEquals(2, migrated.getInt("meta.configVersion")); + assertFalse(migrated.hasPath("nowPlaying.updateProgressBar")); + assertTrue(migrated.hasPath("nowPlaying.showProgressBar")); + assertTrue(migrated.getBoolean("nowPlaying.showProgressBar")); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/model/ConfigOptionTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/model/ConfigOptionTest.java new file mode 100644 index 0000000..9f9daaf --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/model/ConfigOptionTest.java @@ -0,0 +1,241 @@ +/* + * 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.unit.config.model; + +import com.jagrosh.jmusicbot.config.model.ConfigOption; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; +import com.typesafe.config.ConfigFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigOption Unit Tests") +class ConfigOptionTest { + + @Nested + @DisplayName("Enum Properties") + class EnumPropertiesTests { + + @Test + @DisplayName("All enum values have valid properties") + void allEnumValuesHaveValidProperties() { + for (ConfigOption option : ConfigOption.values()) { + assertNotNull(option.getKey(), "Key should not be null for " + option); + assertFalse(option.getKey().isEmpty(), "Key should not be empty for " + option); + assertNotNull(option.getType(), "Type should not be null for " + option); + assertNotNull(option.getDescription(), "Description should not be null for " + option); + } + } + + @Test + @DisplayName("Required options are correctly marked") + void requiredOptionsAreCorrectlyMarked() { + assertTrue(ConfigOption.TOKEN.isRequired(), "TOKEN should be required"); + assertTrue(ConfigOption.OWNER.isRequired(), "OWNER should be required"); + + assertFalse(ConfigOption.PREFIX.isRequired(), "PREFIX should not be required"); + assertFalse(ConfigOption.GAME.isRequired(), "GAME should not be required"); + } + + @Test + @DisplayName("All keys are unique") + void allKeysAreUnique() { + Set<String> keys = ConfigOption.getAllKeys(); + assertEquals(ConfigOption.values().length, keys.size(), "All keys should be unique"); + } + } + + @Nested + @DisplayName("Type-Safe Getters") + class TypeSafeGetterTests { + + @Test + @DisplayName("getString() returns correct value") + void getStringReturnsCorrectValue() { + Config config = ConfigFactory.parseMap(Map.of("discord", Map.of("token", "test_token"))); + assertEquals("test_token", ConfigOption.TOKEN.getString(config)); + } + + @Test + @DisplayName("getLong() returns correct value") + void getLongReturnsCorrectValue() { + Config config = ConfigFactory.parseMap(Map.of("discord", Map.of("owner", 123456789L))); + assertEquals(123456789L, ConfigOption.OWNER.getLong(config)); + } + + @Test + @DisplayName("getInt() returns correct value") + void getIntReturnsCorrectValue() { + Config config = ConfigFactory.parseMap(Map.of("playback", Map.of("maxYouTubePlaylistPages", 15))); + assertEquals(15, ConfigOption.MAX_YT_PLAYLIST_PAGES.getInt(config)); + } + + @Test + @DisplayName("getDouble() returns correct value") + void getDoubleReturnsCorrectValue() { + Config config = ConfigFactory.parseMap(Map.of("playback", Map.of("skipRatio", 0.75))); + assertEquals(0.75, ConfigOption.SKIP_RATIO.getDouble(config)); + } + + @Test + @DisplayName("getBoolean() returns correct value") + void getBooleanReturnsCorrectValue() { + Config config = ConfigFactory.parseMap(Map.of("voice", Map.of("stayInChannel", true))); + assertTrue(ConfigOption.STAY_IN_CHANNEL.getBoolean(config)); + } + + @Test + @DisplayName("getConfig() returns nested config") + void getConfigReturnsNestedConfig() { + Map<String, Object> aliasesMap = Map.of("play", List.of("p")); + Config config = ConfigFactory.parseMap(Map.of("commands", Map.of("aliases", aliasesMap))); + Config aliases = ConfigOption.ALIASES.getConfig(config); + assertNotNull(aliases); + assertTrue(aliases.hasPath("play")); + } + + @Test + @DisplayName("getConfig() returns nested config for audioSources") + void getConfigReturnsNestedConfigForAudioSources() { + Config config = ConfigFactory.parseMap(Map.of("playback", Map.of("audioSources", Map.of("youtube", true)))); + Config audioSources = ConfigOption.AUDIO_SOURCES.getConfig(config); + assertNotNull(audioSources); + assertTrue(audioSources.getBoolean("youtube")); + } + + @Test + @DisplayName("getString() throws ConfigException.Missing for missing path") + void getStringThrowsMissingForMissingPath() { + Config config = ConfigFactory.empty(); + assertThrows(ConfigException.Missing.class, () -> ConfigOption.TOKEN.getString(config)); + } + + @Test + @DisplayName("getLong() throws ConfigException.Missing for missing path") + void getLongThrowsMissingForMissingPath() { + Config config = ConfigFactory.empty(); + assertThrows(ConfigException.Missing.class, () -> ConfigOption.OWNER.getLong(config)); + } + } + + @Nested + @DisplayName("hasValue() Method") + class HasValueTests { + + @Test + @DisplayName("hasValue() returns true when path exists") + void hasValueReturnsTrueWhenPathExists() { + Config config = ConfigFactory.parseMap(Map.of("discord", Map.of("token", "test_token"))); + assertTrue(ConfigOption.TOKEN.hasValue(config)); + } + + @Test + @DisplayName("hasValue() returns false when path does not exist") + void hasValueReturnsFalseWhenPathDoesNotExist() { + Config config = ConfigFactory.empty(); + assertFalse(ConfigOption.TOKEN.hasValue(config)); + } + } + + @Nested + @DisplayName("findByKey() Method") + class FindByKeyTests { + + @Test + @DisplayName("findByKey() finds existing key") + void findByKeyFindsExistingKey() { + var result = ConfigOption.findByKey("discord.token"); + assertTrue(result.isPresent()); + assertEquals(ConfigOption.TOKEN, result.get()); + } + + @Test + @DisplayName("findByKey() returns empty for non-existent key") + void findByKeyReturnsEmptyForNonExistentKey() { + var result = ConfigOption.findByKey("nonexistent"); + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("Utility Methods") + class UtilityMethodTests { + + @Test + @DisplayName("getAllKeys() returns all keys") + void getAllKeysReturnsAllKeys() { + Set<String> keys = ConfigOption.getAllKeys(); + assertTrue(keys.contains("discord.token")); + assertTrue(keys.contains("discord.owner")); + assertTrue(keys.contains("commands.prefix")); + assertEquals(ConfigOption.values().length, keys.size()); + } + + @Test + @DisplayName("getOptionalKeys() returns only optional keys") + void getOptionalKeysReturnsOnlyOptionalKeys() { + Set<String> optionalKeys = ConfigOption.getOptionalKeys(); + assertTrue(optionalKeys.contains("commands.prefix")); + assertFalse(optionalKeys.contains("discord.token")); + assertFalse(optionalKeys.contains("discord.owner")); + } + + @Test + @DisplayName("getOptionalKeys() count is correct") + void getOptionalKeysCountIsCorrect() { + Set<String> optionalKeys = ConfigOption.getOptionalKeys(); + long requiredCount = java.util.Arrays.stream(ConfigOption.values()) + .filter(ConfigOption::isRequired) + .count(); + assertEquals(ConfigOption.values().length - requiredCount, optionalKeys.size()); + } + } + + @Nested + @DisplayName("Parameterized Type Tests") + class ParameterizedTypeTests { + + @ParameterizedTest(name = "{0} should have type {1}") + @MethodSource("typeTestData") + @DisplayName("Config options have correct types") + void configOptionsHaveCorrectTypes(ConfigOption option, ConfigOption.ConfigType expectedType) { + assertEquals(expectedType, option.getType()); + } + + static Stream<Arguments> typeTestData() { + return Stream.of( + Arguments.of(ConfigOption.TOKEN, ConfigOption.ConfigType.STRING), + Arguments.of(ConfigOption.OWNER, ConfigOption.ConfigType.LONG), + Arguments.of(ConfigOption.MAX_YT_PLAYLIST_PAGES, ConfigOption.ConfigType.INT), + Arguments.of(ConfigOption.SKIP_RATIO, ConfigOption.ConfigType.DOUBLE), + Arguments.of(ConfigOption.STAY_IN_CHANNEL, ConfigOption.ConfigType.BOOLEAN), + Arguments.of(ConfigOption.ALIASES, ConfigOption.ConfigType.CONFIG), + Arguments.of(ConfigOption.AUDIO_SOURCES, ConfigOption.ConfigType.CONFIG) + ); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/model/ConfigUpdateTypeTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/model/ConfigUpdateTypeTest.java new file mode 100644 index 0000000..b0e1cf5 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/model/ConfigUpdateTypeTest.java @@ -0,0 +1,109 @@ +/* + * 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.unit.config.model; + +import com.jagrosh.jmusicbot.config.model.ConfigUpdateType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigUpdateType Unit Tests") +class ConfigUpdateTypeTest { + + @Nested + @DisplayName("determine() method") + class DetermineMethodTests { + + @Test + @DisplayName("Returns MIGRATION when original version is less than latest version") + void returnsMigrationWhenVersionIsLessThanLatest() { + // Version 0 -> 1 should be MIGRATION (legacy config) + ConfigUpdateType result = ConfigUpdateType.determine(0, 1, false); + assertEquals(ConfigUpdateType.MIGRATION, result, + "Should return MIGRATION when upgrading from version 0 to 1"); + + // Version 0 -> 2 should also be MIGRATION + result = ConfigUpdateType.determine(0, 2, false); + assertEquals(ConfigUpdateType.MIGRATION, result, + "Should return MIGRATION when upgrading from version 0 to 2"); + + // Version 1 -> 2 should also be MIGRATION + result = ConfigUpdateType.determine(1, 2, false); + assertEquals(ConfigUpdateType.MIGRATION, result, + "Should return MIGRATION when upgrading from version 1 to 2"); + } + + @Test + @DisplayName("Returns MIGRATION even when missing keys exist if version upgrade needed") + void returnsMigrationEvenWithMissingKeysWhenVersionUpgradeNeeded() { + // Version upgrade takes precedence over missing keys + ConfigUpdateType result = ConfigUpdateType.determine(0, 1, true); + assertEquals(ConfigUpdateType.MIGRATION, result, + "Should return MIGRATION when version upgrade needed, even with missing keys"); + } + + @Test + @DisplayName("Returns REPAIR when version is current but missing keys exist") + void returnsRepairWhenCurrentVersionWithMissingKeys() { + // Version 1 -> 1 with missing keys should be REPAIR + ConfigUpdateType result = ConfigUpdateType.determine(1, 1, true); + assertEquals(ConfigUpdateType.REPAIR, result, + "Should return REPAIR when at current version but missing keys"); + + // Version 2 -> 2 with missing keys should also be REPAIR + result = ConfigUpdateType.determine(2, 2, true); + assertEquals(ConfigUpdateType.REPAIR, result, + "Should return REPAIR when at current version but missing keys"); + } + + @Test + @DisplayName("Returns UPDATE when version is current and no missing keys") + void returnsUpdateWhenCurrentVersionNoMissingKeys() { + // Version 1 -> 1 with no missing keys should be UPDATE + ConfigUpdateType result = ConfigUpdateType.determine(1, 1, false); + assertEquals(ConfigUpdateType.UPDATE, result, + "Should return UPDATE when at current version with no missing keys"); + } + } + + @Nested + @DisplayName("getPastTenseVerb() method") + class GetPastTenseVerbTests { + + @Test + @DisplayName("MIGRATION returns 'migrated'") + void migrationReturnsMigrated() { + assertEquals("migrated", ConfigUpdateType.MIGRATION.getPastTenseVerb(), + "MIGRATION should return 'migrated'"); + } + + @Test + @DisplayName("REPAIR returns 'repaired'") + void repairReturnsRepaired() { + assertEquals("repaired", ConfigUpdateType.REPAIR.getPastTenseVerb(), + "REPAIR should return 'repaired'"); + } + + @Test + @DisplayName("UPDATE returns 'updated'") + void updateReturnsUpdated() { + assertEquals("updated", ConfigUpdateType.UPDATE.getPastTenseVerb(), + "UPDATE should return 'updated'"); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/render/ConfigRendererTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/render/ConfigRendererTest.java new file mode 100644 index 0000000..3ed2d13 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/render/ConfigRendererTest.java @@ -0,0 +1,669 @@ +/* + * 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.unit.config.render; + +import com.jagrosh.jmusicbot.config.diagnostics.ConfigDiagnostics; +import com.jagrosh.jmusicbot.config.model.ConfigUpdateType; +import com.jagrosh.jmusicbot.config.render.ConfigRenderer; +import com.jagrosh.jmusicbot.testutil.config.V1ConfigBuilder; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.parser.ConfigDocument; +import com.typesafe.config.parser.ConfigDocumentFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigRenderer Unit Tests") +class ConfigRendererTest { + + @org.junit.jupiter.api.BeforeEach + void setUp() { + // Invalidate caches to ensure test isolation + ConfigFactory.invalidateCaches(); + } + + @Nested + @DisplayName("ConfigDocument Usage") + class ConfigDocumentUsageTests { + + @Test + @DisplayName("generateConfigContent uses ConfigDocument when reference.conf is available") + void generateConfigContentUsesConfigDocument() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withCommandsPrefix("!!") + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + assertNotNull(content); + // Should contain header comments added by ConfigRenderer + assertTrue(content.contains("# This file was automatically migrated on")); + // Should be parseable as ConfigDocument + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + } + + @Test + @DisplayName("generateConfigContent preserves structure from reference.conf template") + void generateConfigContentPreservesStructure() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + // Should have nested structure matching reference.conf + assertTrue(content.contains("meta {") || content.contains("meta")); + assertTrue(content.contains("discord {") || content.contains("discord")); + } + + @Test + @DisplayName("generateConfigContent updates user values in template") + void generateConfigContentUpdatesUserValues() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("custom_token") + .withDiscordOwner(999999999L) + .withCommandsPrefix("custom_prefix") + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + // Parse the generated content to verify values + Config generated = ConfigFactory.parseString(content); + assertEquals("custom_token", generated.getString("discord.token")); + assertEquals(999999999L, generated.getLong("discord.owner")); + assertEquals("custom_prefix", generated.getString("commands.prefix")); + } + + @Test + @DisplayName("generateConfigContent includes diagnostic information in comments") + void generateConfigContentIncludesDiagnostics() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + Set<String> missingRequired = new HashSet<>(); + missingRequired.add("discord.token"); + Set<String> deprecated = new HashSet<>(); + deprecated.add("oldKey"); + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + missingRequired, new HashSet<>(), deprecated + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + assertTrue(content.contains("# Changes detected")); + assertTrue(content.contains("Missing required keys")); + assertTrue(content.contains("Deprecated keys removed")); + } + } + + @Nested + @DisplayName("Comment and Formatting Preservation") + class CommentAndFormattingPreservationTests { + + @Test + @DisplayName("generateConfigContent preserves comments from reference.conf template") + void generateConfigContentPreservesComments() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + // reference.conf should have comments, and ConfigDocument should preserve them + // The exact comments depend on reference.conf, but we should have some + assertNotNull(content); + + // Parse as ConfigDocument to verify it's valid + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + + // Verify that ConfigDocument was used (content should have structure from reference.conf) + // If ConfigDocument wasn't used, the fallback would have different formatting + String rendered = doc.render(); + assertFalse(rendered.isEmpty()); + + // Verify the content can be parsed back to Config with correct values + Config parsed = ConfigFactory.parseString(content); + assertEquals("test_token", parsed.getString("discord.token")); + } + + @Test + @DisplayName("generateConfigContent preserves nested structure formatting") + void generateConfigContentPreservesNestedStructure() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withCommandsPrefix("!!") + .withPlaybackMaxTrackSeconds(3600L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + // Parse and verify nested structure is preserved + Config parsed = ConfigFactory.parseString(content); + assertTrue(parsed.hasPath("meta.configVersion")); + assertTrue(parsed.hasPath("discord.token")); + assertTrue(parsed.hasPath("commands.prefix")); + assertTrue(parsed.hasPath("playback.maxTrackSeconds")); + } + + @Test + @DisplayName("generateConfigContent uses ConfigDocument to preserve reference.conf structure") + void generateConfigContentUsesConfigDocumentForStructure() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + // Verify ConfigDocument was used (not fallback) + // ConfigDocument preserves the structure from reference.conf template + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + + // The content should be parseable and have correct structure + Config parsed = ConfigFactory.parseString(content); + assertTrue(parsed.hasPath("meta.configVersion")); + assertEquals(2, parsed.getInt("meta.configVersion")); + assertEquals("test_token", parsed.getString("discord.token")); + } + + @Test + @DisplayName("generateConfigContent preserves comments when ConfigDocument is used") + void generateConfigContentPreservesCommentsViaConfigDocument() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + // Parse as ConfigDocument to verify it was used + ConfigDocument doc = ConfigDocumentFactory.parseString(content); + assertNotNull(doc); + + // ConfigDocument should preserve comments from reference.conf template + // The rendered document should maintain the structure + + // Verify the content is valid and has correct values + Config parsed = ConfigFactory.parseString(content); + assertEquals("test_token", parsed.getString("discord.token")); + assertEquals(123456789L, parsed.getLong("discord.owner")); + + // Verify structure is preserved (ConfigDocument maintains nested structure) + assertTrue(parsed.hasPath("meta.configVersion")); + assertTrue(parsed.hasPath("discord.token")); + } + } + + @Nested + @DisplayName("Nested Config Preservation") + class NestedConfigPreservationTests { + + @Test + @DisplayName("generateConfigContent preserves template values for missing nested config keys") + void generateConfigContentPreservesTemplateValuesForMissingNestedKeys() { + // Create a config with only some audioSources keys set (missing local and others) + Map<String, Boolean> partialAudioSources = new HashMap<>(); + partialAudioSources.put("youtube", false); + partialAudioSources.put("soundcloud", false); + // Intentionally missing: local, bandcamp, vimeo, twitch, etc. + + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withPlaybackAudioSources(partialAudioSources) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.REPAIR); + + // Parse the generated content + Config parsed = ConfigFactory.parseString(content); + assertTrue(parsed.hasPath("playback.audioSources")); + Config audioSources = parsed.getConfig("playback.audioSources"); + + // Verify user's values are preserved + assertFalse(audioSources.getBoolean("youtube"), + "User's youtube=false should be preserved"); + assertFalse(audioSources.getBoolean("soundcloud"), + "User's soundcloud=false should be preserved"); + + // Verify template defaults are preserved for missing keys + assertTrue(audioSources.getBoolean("local"), + "Template's local=true should be preserved (was missing from user config)"); + assertTrue(audioSources.getBoolean("bandcamp"), + "Template's bandcamp=true should be preserved (was missing from user config)"); + assertTrue(audioSources.getBoolean("vimeo"), + "Template's vimeo=true should be preserved (was missing from user config)"); + assertTrue(audioSources.getBoolean("twitch"), + "Template's twitch=true should be preserved (was missing from user config)"); + assertTrue(audioSources.getBoolean("beam"), + "Template's beam=true should be preserved (was missing from user config)"); + assertTrue(audioSources.getBoolean("getyarn"), + "Template's getyarn=true should be preserved (was missing from user config)"); + assertTrue(audioSources.getBoolean("nico"), + "Template's nico=true should be preserved (was missing from user config)"); + assertTrue(audioSources.getBoolean("http"), + "Template's http=true should be preserved (was missing from user config)"); + } + + @Test + @DisplayName("generateConfigContent preserves template values when user config has partial nested config") + void generateConfigContentPreservesTemplateValuesForPartialNestedConfig() { + // Create a config with only one audioSources key set + Map<String, Boolean> minimalAudioSources = new HashMap<>(); + minimalAudioSources.put("youtube", false); + // All other keys are missing + + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withPlaybackAudioSources(minimalAudioSources) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.REPAIR); + + // Parse the generated content + Config parsed = ConfigFactory.parseString(content); + Config audioSources = parsed.getConfig("playback.audioSources"); + + // Verify user's single value is preserved + assertFalse(audioSources.getBoolean("youtube"), + "User's youtube=false should be preserved"); + + // Verify all other template defaults are preserved + assertTrue(audioSources.getBoolean("soundcloud"), + "Template's soundcloud=true should be preserved"); + assertTrue(audioSources.getBoolean("local"), + "Template's local=true should be preserved"); + assertTrue(audioSources.getBoolean("bandcamp"), + "Template's bandcamp=true should be preserved"); + } + + @Test + @DisplayName("generateConfigContent updates individual nested keys without replacing entire object") + void generateConfigContentUpdatesIndividualNestedKeys() { + // Create a config with mixed audioSources values + Map<String, Boolean> mixedAudioSources = new HashMap<>(); + mixedAudioSources.put("youtube", true); + mixedAudioSources.put("soundcloud", false); + mixedAudioSources.put("local", false); + // Missing: bandcamp, vimeo, twitch, etc. + + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withPlaybackAudioSources(mixedAudioSources) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.REPAIR); + + // Parse the generated content + Config parsed = ConfigFactory.parseString(content); + Config audioSources = parsed.getConfig("playback.audioSources"); + + // Verify all user values are correctly applied + assertTrue(audioSources.getBoolean("youtube"), + "User's youtube=true should be preserved"); + assertFalse(audioSources.getBoolean("soundcloud"), + "User's soundcloud=false should be preserved"); + assertFalse(audioSources.getBoolean("local"), + "User's local=false should be preserved"); + + // Verify template defaults are preserved for missing keys + assertTrue(audioSources.getBoolean("bandcamp"), + "Template's bandcamp=true should be preserved"); + assertTrue(audioSources.getBoolean("vimeo"), + "Template's vimeo=true should be preserved"); + } + } + + @Nested + @DisplayName("Dynamic Nested Config Preservation (Transforms)") + class DynamicNestedConfigPreservationTests { + + @Test + @DisplayName("diagnostics does not flag user-defined transforms as deprecated") + void diagnosticsDoesNotFlagTransformsAsDeprecated() { + Map<String, Object> spotifyTransform = Map.of( + "regex", "https?://.*spotify.com/track/.*", + "replacement", "https://open.spotify.com/track/$1", + "selector", "title", + "format", "ytsearch:%s" + ); + + Map<String, Object> transforms = new HashMap<>(); + transforms.put("spotify", spotifyTransform); + + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withPlaybackTransforms(transforms) + .build(); + + // Run diagnostics as BotConfig would + Config defaults = ConfigFactory.load("reference.conf"); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // Verify no deprecated warnings for transforms + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.spotify"), + "spotify transform should not be flagged as deprecated"); + for (String deprecated : diagnostics.getDeprecated()) { + assertFalse(deprecated.startsWith("playback.transforms."), + "No paths under playback.transforms should be deprecated, but found: " + deprecated); + } + } + + @Test + @DisplayName("diagnostics does not flag nested transform keys as deprecated") + void diagnosticsDoesNotFlagNestedTransformKeysAsDeprecated() { + // Test that inner keys like regex, replacement, selector, format are not flagged + Map<String, Object> spotifyTransform = Map.of( + "regex", "pattern", + "replacement", "replacement", + "selector", "title", + "format", "format" + ); + + Map<String, Object> transforms = new HashMap<>(); + transforms.put("spotify", spotifyTransform); + + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withPlaybackTransforms(transforms) + .build(); + + Config defaults = ConfigFactory.load("reference.conf"); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // Verify none of the nested keys are flagged + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.spotify.regex")); + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.spotify.replacement")); + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.spotify.selector")); + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.spotify.format")); + } + + @Test + @DisplayName("diagnostics does not flag multiple transforms as deprecated") + void diagnosticsDoesNotFlagMultipleTransformsAsDeprecated() { + Map<String, Object> spotifyTransform = Map.of("regex", "pattern1", "replacement", "rep1", "selector", "title", "format", "fmt1"); + Map<String, Object> youtubeTransform = Map.of("regex", "pattern2", "replacement", "rep2", "selector", "title", "format", "fmt2"); + + Map<String, Object> transforms = new HashMap<>(); + transforms.put("spotify", spotifyTransform); + transforms.put("youtube", youtubeTransform); + + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withPlaybackTransforms(transforms) + .build(); + + Config defaults = ConfigFactory.load("reference.conf"); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // Verify neither transform is flagged as deprecated + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.spotify")); + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.youtube")); + } + + @Test + @DisplayName("generateConfigContent does not show deprecated warning for user-defined transforms") + void generateConfigContentNoDeprecatedWarningForTransforms() { + Map<String, Object> spotifyTransform = Map.of( + "regex", "https?://.*spotify.com/track/.*", + "replacement", "https://open.spotify.com/track/$1", + "selector", "title", + "format", "ytsearch:%s" + ); + + Map<String, Object> transforms = new HashMap<>(); + transforms.put("spotify", spotifyTransform); + + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .withPlaybackTransforms(transforms) + .build(); + + // Run diagnostics as BotConfig would + Config defaults = ConfigFactory.load("reference.conf"); + Config merged = migratedUserConfig.withFallback(defaults).resolve(); + ConfigDiagnostics.Report diagnostics = ConfigDiagnostics.analyze(migratedUserConfig, merged, defaults); + + // Verify no deprecated warnings for transforms + assertFalse(diagnostics.getDeprecated().contains("playback.transforms.spotify"), + "spotify transform should not be flagged as deprecated"); + for (String deprecated : diagnostics.getDeprecated()) { + assertFalse(deprecated.startsWith("playback.transforms."), + "No paths under playback.transforms should be deprecated, but found: " + deprecated); + } + + // Generate content and verify no "Deprecated keys removed" mentioning transforms + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + assertFalse(content.contains("playback.transforms.spotify") && content.contains("Deprecated keys removed"), + "Generated content should not mention transforms in deprecated keys section"); + } + } + + @Nested + @DisplayName("Fallback Behavior") + class FallbackBehaviorTests { + + @Test + @DisplayName("generateConfigContentFallback uses Config when ConfigDocument unavailable") + void generateConfigContentFallbackUsesConfig() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContentFallback(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION); + + assertNotNull(content); + // Should still be valid HOCON + Config parsed = ConfigFactory.parseString(content); + assertNotNull(parsed); + assertEquals("test_token", parsed.getString("discord.token")); + } + } + + @Nested + @DisplayName("ConfigUpdateType Wording") + class ConfigUpdateTypeWordingTests { + + private static final Clock EPOCH_CLOCK = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC); + private static final String EPOCH_TIMESTAMP = "1970-01-01 00:00:00"; + + @Test + @DisplayName("MIGRATION type produces 'migrated' wording in header comment with correct timestamp") + void migrationTypeProducesMigratedWording() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION, EPOCH_CLOCK); + + assertTrue(content.contains("# This file was automatically migrated on " + EPOCH_TIMESTAMP), + "Header should contain 'migrated on " + EPOCH_TIMESTAMP + "' for MIGRATION type"); + assertFalse(content.contains("repaired"), + "Header should NOT contain 'repaired' for MIGRATION type"); + } + + @Test + @DisplayName("REPAIR type produces 'repaired' wording in header comment with correct timestamp") + void repairTypeProducesRepairedWording() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + Set<String> missingOptional = new HashSet<>(); + missingOptional.add("some.missing.key"); + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), missingOptional, new HashSet<>() + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.REPAIR, EPOCH_CLOCK); + + assertTrue(content.contains("# This file was automatically repaired on " + EPOCH_TIMESTAMP), + "Header should contain 'repaired on " + EPOCH_TIMESTAMP + "' for REPAIR type"); + assertFalse(content.contains("migrated"), + "Header should NOT contain 'migrated' for REPAIR type"); + } + + @Test + @DisplayName("UPDATE type produces 'updated' wording in header comment with correct timestamp") + void updateTypeProducesUpdatedWording() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + Set<String> deprecated = new HashSet<>(); + deprecated.add("some.old.key"); + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), deprecated + ); + + String content = ConfigRenderer.generateConfigContent(migratedUserConfig, diagnostics, ConfigUpdateType.UPDATE, EPOCH_CLOCK); + + assertTrue(content.contains("# This file was automatically updated on " + EPOCH_TIMESTAMP), + "Header should contain 'updated on " + EPOCH_TIMESTAMP + "' for UPDATE type"); + assertFalse(content.contains("migrated"), + "Header should NOT contain 'migrated' for UPDATE type"); + assertFalse(content.contains("repaired"), + "Header should NOT contain 'repaired' for UPDATE type"); + } + + @Test + @DisplayName("Fallback method also respects ConfigUpdateType wording with correct timestamp") + void fallbackMethodRespectsUpdateType() { + Config migratedUserConfig = V1ConfigBuilder.create() + .withMetaVersion(1) + .withDiscordToken("test_token") + .withDiscordOwner(123456789L) + .build(); + + ConfigDiagnostics.Report diagnostics = new ConfigDiagnostics.Report( + new HashSet<>(), new HashSet<>(), new HashSet<>() + ); + + // Test REPAIR type in fallback + String repairContent = ConfigRenderer.generateConfigContentFallback( + migratedUserConfig, diagnostics, ConfigUpdateType.REPAIR, EPOCH_CLOCK); + assertTrue(repairContent.contains("# This file was automatically repaired on " + EPOCH_TIMESTAMP), + "Fallback should use 'repaired on " + EPOCH_TIMESTAMP + "' for REPAIR type"); + + // Test MIGRATION type in fallback + String migrationContent = ConfigRenderer.generateConfigContentFallback( + migratedUserConfig, diagnostics, ConfigUpdateType.MIGRATION, EPOCH_CLOCK); + assertTrue(migrationContent.contains("# This file was automatically migrated on " + EPOCH_TIMESTAMP), + "Fallback should use 'migrated on " + EPOCH_TIMESTAMP + "' for MIGRATION type"); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/config/validation/ConfigValidatorTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/config/validation/ConfigValidatorTest.java new file mode 100644 index 0000000..c536e26 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/config/validation/ConfigValidatorTest.java @@ -0,0 +1,221 @@ +/* + * 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.unit.config.validation; + +import com.jagrosh.jmusicbot.BaseConfigTest; +import com.jagrosh.jmusicbot.config.validation.ConfigValidator; +import com.jagrosh.jmusicbot.config.validation.ConfigValidator.ValidationResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ConfigValidator Unit Tests") +class ConfigValidatorTest extends BaseConfigTest { + + @Nested + @DisplayName("validateToken() Tests") + class ValidateTokenTests { + + @Test + @DisplayName("validateToken() accepts valid token") + void validateTokenAcceptsValidToken() { + Path configPath = tempDir.resolve("config.conf"); + String validToken = "valid_token_12345"; + + ValidationResult result = ConfigValidator.validateToken(validToken, mockUserInteraction, configPath); + + assertTrue(result.isValid()); + assertEquals(validToken, result.getValue()); + assertFalse(result.needsWrite()); + assertEquals(0, mockUserInteraction.getPromptCalls().size()); + } + + @Test + @DisplayName("validateToken() prompts for null token") + void validateTokenPromptsForNullToken() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptResponse("new_token_from_user"); + + ValidationResult result = ConfigValidator.validateToken(null, mockUserInteraction, configPath); + + assertTrue(result.isValid()); + assertEquals("new_token_from_user", result.getValue()); + assertTrue(result.needsWrite()); + assertEquals(1, mockUserInteraction.getPromptCalls().size()); + assertTrue(mockUserInteraction.getLastPrompt().contains("bot token")); + } + + @Test + @DisplayName("validateToken() prompts for empty token") + void validateTokenPromptsForEmptyToken() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptResponse("new_token_from_user"); + + ValidationResult result = ConfigValidator.validateToken("", mockUserInteraction, configPath); + + assertTrue(result.isValid()); + assertEquals("new_token_from_user", result.getValue()); + assertTrue(result.needsWrite()); + } + + @Test + @DisplayName("validateToken() shows alert with config location on cancel") + void validateTokenShowsAlertWithConfigLocationOnCancel() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptCancelled(); + + ConfigValidator.validateToken(null, mockUserInteraction, configPath); + + var alert = mockUserInteraction.getLastAlert(); + assertNotNull(alert); + assertTrue(alert.getMessage().contains("Config Location")); + assertTrue(alert.getMessage().contains(configPath.toString())); + } + } + + @Nested + @DisplayName("validateOwner() Tests") + class ValidateOwnerTests { + + @Test + @DisplayName("validateOwner() accepts valid owner ID") + void validateOwnerAcceptsValidOwnerId() { + Path configPath = tempDir.resolve("config.conf"); + Long validOwner = 123456789L; + + ValidationResult result = ConfigValidator.validateOwner(validOwner, mockUserInteraction, configPath); + + assertTrue(result.isValid()); + assertEquals(validOwner, result.getValue()); + assertFalse(result.needsWrite()); + assertEquals(0, mockUserInteraction.getPromptCalls().size()); + } + + @Test + @DisplayName("validateOwner() prompts for null owner") + void validateOwnerPromptsForNullOwner() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptResponse("987654321"); + + ValidationResult result = ConfigValidator.validateOwner(null, mockUserInteraction, configPath); + + assertTrue(result.isValid()); + assertEquals(987654321L, (Long) result.getValue()); + assertTrue(result.needsWrite()); + assertEquals(1, mockUserInteraction.getPromptCalls().size()); + } + + @Test + @DisplayName("validateOwner() prompts for negative owner") + void validateOwnerPromptsForNegativeOwner() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptResponse("987654321"); + + ValidationResult result = ConfigValidator.validateOwner(-1L, mockUserInteraction, configPath); + + assertTrue(result.isValid()); + assertEquals(987654321L, (Long) result.getValue()); + assertTrue(result.needsWrite()); + } + + @Test + @DisplayName("validateOwner() returns invalid for non-numeric input") + void validateOwnerReturnsInvalidForNonNumericInput() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptResponse("not_a_number"); + + ValidationResult result = ConfigValidator.validateOwner(null, mockUserInteraction, configPath); + + assertFalse(result.isValid()); + assertNull(result.getValue()); + assertFalse(result.needsWrite()); + assertEquals(1, mockUserInteraction.getAlertCalls().size()); + } + + @Test + @DisplayName("validateOwner() returns invalid for zero input") + void validateOwnerReturnsInvalidForZeroInput() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptResponse("0"); + + ValidationResult result = ConfigValidator.validateOwner(null, mockUserInteraction, configPath); + + assertFalse(result.isValid()); + assertNull(result.getValue()); + assertFalse(result.needsWrite()); + } + + @Test + @DisplayName("validateOwner() returns invalid when user cancels") + void validateOwnerReturnsInvalidWhenUserCancels() { + Path configPath = tempDir.resolve("config.conf"); + mockUserInteraction.setPromptCancelled(); + + ValidationResult result = ConfigValidator.validateOwner(null, mockUserInteraction, configPath); + + assertFalse(result.isValid()); + assertNull(result.getValue()); + assertFalse(result.needsWrite()); + } + + } + + @Nested + @DisplayName("ValidationResult Tests") + class ValidationResultTests { + + @Test + @DisplayName("ValidationResult stores value correctly") + void validationResultStoresValueCorrectly() { + ValidationResult result = new ValidationResult("test_value", true, false); + + assertEquals("test_value", result.getValue()); + assertTrue(result.isValid()); + assertFalse(result.needsWrite()); + } + + @Test + @DisplayName("ValidationResult handles different value types") + void validationResultHandlesDifferentValueTypes() { + ValidationResult stringResult = new ValidationResult("string", true, false); + ValidationResult longResult = new ValidationResult(123L, true, false); + + assertEquals("string", stringResult.getValue()); + assertEquals(123L, (Long) longResult.getValue()); + } + + @Test + @DisplayName("ValidationResult with needsWrite flag") + void validationResultWithNeedsWriteFlag() { + ValidationResult result = new ValidationResult("value", true, true); + + assertTrue(result.needsWrite()); + } + + @Test + @DisplayName("ValidationResult invalid result") + void validationResultInvalidResult() { + ValidationResult result = new ValidationResult(null, false, false); + + assertFalse(result.isValid()); + assertNull(result.getValue()); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/listener/interaction/ComponentIdParsersTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/listener/interaction/ComponentIdParsersTest.java new file mode 100644 index 0000000..545201f --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/listener/interaction/ComponentIdParsersTest.java @@ -0,0 +1,178 @@ +/* + * 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.unit.listener.interaction; + +import com.jagrosh.jmusicbot.listener.interaction.ComponentIdParsers; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ComponentIdParsersTest +{ + @Test + void parsePlaylistsButtonId_validId_returnsParsedValues() + { + var parsed = ComponentIdParsers.parsePlaylistsButtonId("playlists_queue_2_13_123456789"); + assertTrue(parsed.isPresent()); + assertEquals("queue", parsed.get().action()); + assertEquals(2, parsed.get().page()); + assertEquals(13, parsed.get().selectedTrack()); + assertEquals(123456789L, parsed.get().userId()); + } + + @Test + void parsePlaylistsButtonId_invalidId_returnsEmpty() + { + assertTrue(ComponentIdParsers.parsePlaylistsButtonId("playlists_bad").isEmpty()); + assertTrue(ComponentIdParsers.parsePlaylistsButtonId("history_queue_1_1_1").isEmpty()); + } + + @Test + void parsePlaylistDetailsButtonId_validId_returnsParsedValues() + { + var parsed = ComponentIdParsers.parsePlaylistDetailsButtonId("playlistdetails_4_2_select13_2_13_123456789"); + assertTrue(parsed.isPresent()); + assertEquals(4, parsed.get().playlistIndex()); + assertEquals(2, parsed.get().listPage()); + assertEquals("select13", parsed.get().action()); + assertEquals(2, parsed.get().detailsPage()); + assertEquals(13, parsed.get().selectedTrack()); + assertEquals(123456789L, parsed.get().userId()); + } + + @Test + void parsePlaylistDetailsButtonId_invalidId_returnsEmpty() + { + assertTrue(ComponentIdParsers.parsePlaylistDetailsButtonId("playlistdetails_back_1_2_3").isEmpty()); + assertTrue(ComponentIdParsers.parsePlaylistDetailsButtonId("playlistdetails_1_2_next_bad_0_3").isEmpty()); + assertTrue(ComponentIdParsers.parsePlaylistDetailsButtonId("playlists_1_2_next_1_0_3").isEmpty()); + } + + @Test + void parsePlaylistDetailsBackId_validId_returnsParsedValues() + { + var parsed = ComponentIdParsers.parsePlaylistDetailsBackId("playlistdetails_back_2_4_123456789"); + assertTrue(parsed.isPresent()); + assertEquals(2, parsed.get().listPage()); + assertEquals(4, parsed.get().listSelectedIndex()); + assertEquals(123456789L, parsed.get().userId()); + } + + @Test + void parsePlaylistDetailsBackId_invalidId_returnsEmpty() + { + assertTrue(ComponentIdParsers.parsePlaylistDetailsBackId("playlistdetails_back_2_x_123").isEmpty()); + assertTrue(ComponentIdParsers.parsePlaylistDetailsBackId("playlistdetails_1_2_next_1_0_3").isEmpty()); + } + + @Test + void parsePlaylistDetailsMoveSelectId_validId_returnsParsedValues() + { + var parsed = ComponentIdParsers.parsePlaylistDetailsMoveSelectId("playlistdetails_move_select_4_2_7_1_123456789"); + assertTrue(parsed.isPresent()); + assertEquals(4, parsed.get().playlistIndex()); + assertEquals(2, parsed.get().listPage()); + assertEquals(7, parsed.get().fromTrack()); + assertEquals(1, parsed.get().detailsPage()); + assertEquals(123456789L, parsed.get().userId()); + } + + @Test + void parsePlaylistDetailsMoveSelectId_invalidId_returnsEmpty() + { + assertTrue(ComponentIdParsers.parsePlaylistDetailsMoveSelectId("playlistdetails_move_select_4_2_7_x_123").isEmpty()); + assertTrue(ComponentIdParsers.parsePlaylistDetailsMoveSelectId("playlistdetails_4_2_move_1_7_123").isEmpty()); + } + + @Test + void parseSettingsButtonId_enumAction_returnsParsedValues() + { + var parsed = ComponentIdParsers.parseSettingsButtonId("settings_enum_layout_minimal_42"); + assertTrue(parsed.isPresent()); + assertEquals("enum", parsed.get().action()); + assertEquals("layout", parsed.get().key()); + assertEquals("minimal", parsed.get().value()); + assertEquals(42L, parsed.get().userId()); + } + + @Test + void parseSettingsButtonId_openAction_returnsParsedValues() + { + var parsed = ComponentIdParsers.parseSettingsButtonId("settings_open_settc_987"); + assertTrue(parsed.isPresent()); + assertEquals("open", parsed.get().action()); + assertEquals("settc", parsed.get().key()); + assertEquals(987L, parsed.get().userId()); + } + + @Test + void parseSettingsModalId_validId_returnsParsedValues() + { + var parsed = ComponentIdParsers.parseSettingsModalId("settings_modal_setskip_123"); + assertTrue(parsed.isPresent()); + assertEquals("setskip", parsed.get().key()); + assertEquals(123L, parsed.get().userId()); + } + + @Test + void parseSettingsEntitySelectId_validId_returnsParsedValues() + { + var parsed = ComponentIdParsers.parseSettingsEntitySelectId("settings_entity_setdj_77"); + assertTrue(parsed.isPresent()); + assertEquals("setdj", parsed.get().key()); + assertEquals(null, parsed.get().originalPanelMessageId()); + assertEquals(77L, parsed.get().userId()); + } + + @Test + void parseSettingsEntitySelectId_withPanelMessage_returnsParsedValues() + { + var parsed = ComponentIdParsers.parseSettingsEntitySelectId("settings_entity_setvc_5555_77"); + assertTrue(parsed.isPresent()); + assertEquals("setvc", parsed.get().key()); + assertEquals(5555L, parsed.get().originalPanelMessageId()); + assertEquals(77L, parsed.get().userId()); + } + + @Test + void parseSettingsIds_invalidIds_returnEmpty() + { + assertTrue(ComponentIdParsers.parseSettingsButtonId("settings_modal_setvc_1").isEmpty()); + assertTrue(ComponentIdParsers.parseSettingsButtonId("settings_enum_layout_minimal_bad").isEmpty()); + assertTrue(ComponentIdParsers.parseSettingsModalId("settings_modal_setvc_bad").isEmpty()); + assertTrue(ComponentIdParsers.parseSettingsModalId("settings_open_setvc_1").isEmpty()); + assertTrue(ComponentIdParsers.parseSettingsEntitySelectId("settings_entity_setvc_bad").isEmpty()); + assertTrue(ComponentIdParsers.parseSettingsEntitySelectId("settings_modal_setvc_1").isEmpty()); + assertTrue(ComponentIdParsers.parseSettingsEntitySelectId("settings_entity_setvc_1_bad").isEmpty()); + } + + @Test + void parseNowPlayingButtonId_validId_returnsParsedValues() + { + var parsed = ComponentIdParsers.parseNowPlayingButtonId("np_pause"); + assertTrue(parsed.isPresent()); + assertEquals("pause", parsed.get().action()); + } + + @Test + void parseNowPlayingButtonId_invalidId_returnsEmpty() + { + assertTrue(ComponentIdParsers.parseNowPlayingButtonId("pause").isEmpty()); + assertTrue(ComponentIdParsers.parseNowPlayingButtonId("np_").isEmpty()); + assertTrue(ComponentIdParsers.parseNowPlayingButtonId("np_pause_more").isEmpty()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/listener/interaction/PaginatedListComponentsTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/listener/interaction/PaginatedListComponentsTest.java new file mode 100644 index 0000000..d65a807 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/listener/interaction/PaginatedListComponentsTest.java @@ -0,0 +1,68 @@ +/* + * 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.unit.listener.interaction; + +import com.jagrosh.jmusicbot.listener.interaction.PaginatedListComponents; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PaginatedListComponentsTest +{ + @Test + void buildSelectRows_pageTwoUsesAbsoluteLabelsAndActions() + { + String baseId = PaginatedListComponents.baseId("queue", 2, 0, 42L); + List<ActionRow> rows = PaginatedListComponents.buildSelectRows(baseId, 2, 10, 10, 0); + assertEquals(2, rows.size()); + + Button first = (Button) rows.get(0).getComponents().get(0); + Button last = (Button) rows.get(1).getComponents().get(4); + assertEquals("11", first.getLabel()); + assertEquals("20", last.getLabel()); + } + + @Test + void buildSelectRows_partialPageUsesSparseRows() + { + String baseId = PaginatedListComponents.baseId("history", 1, 0, 42L); + List<ActionRow> rows = PaginatedListComponents.buildSelectRows(baseId, 1, 10, 3, 0); + assertEquals(1, rows.size()); + assertEquals(3, rows.get(0).getComponents().size()); + } + + @Test + void buildPaginationRow_singlePageWithoutExtras_returnsNull() + { + String baseId = PaginatedListComponents.baseId("history", 1, 0, 42L); + assertNull(PaginatedListComponents.buildPaginationRow(baseId, 1, 1)); + } + + @Test + void buildPaginationRow_singlePageWithExtras_returnsExtrasOnly() + { + String baseId = PaginatedListComponents.baseId("queue", 1, 0, 42L); + Button shuffle = Button.secondary(String.format(baseId, "shuffle"), "Shuffle"); + ActionRow row = PaginatedListComponents.buildPaginationRow(baseId, 1, 1, shuffle); + assertEquals(1, row.getComponents().size()); + assertEquals("Shuffle", ((Button) row.getComponents().get(0)).getLabel()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/playlist/PlaylistLoaderTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/playlist/PlaylistLoaderTest.java new file mode 100644 index 0000000..4db2d1b --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/playlist/PlaylistLoaderTest.java @@ -0,0 +1,317 @@ +/* + * 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.unit.playlist; + +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.playlist.PlaylistLoader; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("PlaylistLoader Tests") +class PlaylistLoaderTest +{ + @TempDir + Path tempDir; + + private PlaylistLoader loader; + private AudioPlayerManager manager; + + @BeforeEach + void setUp() + { + BotConfig config = mock(BotConfig.class); + when(config.getPlaylistsFolder()).thenReturn(tempDir.toString()); + when(config.isTooLong(any(AudioTrack.class))).thenReturn(false); + loader = new PlaylistLoader(config); + manager = mock(AudioPlayerManager.class); + } + + @Test + @DisplayName("loadTracks() invokes callback only after all items complete") + void loadTracks_invokesCallbackOnlyAfterAllItemsComplete() throws IOException + { + Files.writeString(tempDir.resolve("race.txt"), "url1\nurl2\nurl3\n"); + PlaylistLoader.Playlist playlist = loader.getPlaylist("race"); + assertNotNull(playlist); + + Map<String, AudioLoadResultHandler> handlersByItem = new HashMap<>(); + doAnswer(invocation -> + { + String item = invocation.getArgument(1); + AudioLoadResultHandler handler = invocation.getArgument(2); + handlersByItem.put(item, handler); + return null; + }).when(manager).loadItemOrdered(eq("race"), anyString(), any(AudioLoadResultHandler.class)); + + AtomicInteger callbackCount = new AtomicInteger(0); + List<AudioTrack> consumedTracks = new ArrayList<>(); + playlist.loadTracks(manager, consumedTracks::add, callbackCount::incrementAndGet); + + assertEquals(3, handlersByItem.size()); + + AudioTrack track3 = mock(AudioTrack.class); + handlersByItem.get("url3").trackLoaded(track3); + assertEquals(0, callbackCount.get()); + + AudioTrack track1 = mock(AudioTrack.class); + handlersByItem.get("url1").trackLoaded(track1); + assertEquals(0, callbackCount.get()); + + handlersByItem.get("url2").noMatches(); + assertEquals(1, callbackCount.get()); + assertEquals(2, consumedTracks.size()); + } + + @Test + @DisplayName("loadTracks() invokes callback immediately when playlist has no items") + void loadTracks_invokesCallbackImmediately_whenPlaylistHasNoItems() throws IOException + { + Files.writeString(tempDir.resolve("empty.txt"), "\n"); + PlaylistLoader.Playlist playlist = loader.getPlaylist("empty"); + assertNotNull(playlist); + + AtomicInteger callbackCount = new AtomicInteger(0); + playlist.loadTracks(manager, track -> {}, callbackCount::incrementAndGet); + + assertEquals(1, callbackCount.get()); + verify(manager, never()).loadItemOrdered(anyString(), anyString(), any(AudioLoadResultHandler.class)); + } + + @Test + @DisplayName("ensureStorageReady() creates nested directories") + void ensureStorageReady_createsNestedDirectories() + { + BotConfig nestedConfig = mock(BotConfig.class); + when(nestedConfig.getPlaylistsFolder()).thenReturn(tempDir.resolve("a").resolve("b").toString()); + PlaylistLoader nestedLoader = new PlaylistLoader(nestedConfig); + + PlaylistLoader.PlaylistResult<Path> result = nestedLoader.ensureStorageReady(); + + assertTrue(result.isSuccess()); + assertTrue(Files.isDirectory(result.getValue())); + } + + @Test + @DisplayName("ensureStorageReady() fails for blank path") + void ensureStorageReady_failsForBlankPath() + { + BotConfig invalidConfig = mock(BotConfig.class); + when(invalidConfig.getPlaylistsFolder()).thenReturn(" "); + PlaylistLoader invalidLoader = new PlaylistLoader(invalidConfig); + + PlaylistLoader.PlaylistResult<Path> result = invalidLoader.ensureStorageReady(); + + assertFalse(result.isSuccess()); + assertEquals(PlaylistLoader.PlaylistErrorType.INVALID_CONFIG, result.getError().getType()); + } + + @Test + @DisplayName("ensureStorageReady() fails when parent path is file") + void ensureStorageReady_failsWhenStorageInaccessible() throws IOException + { + Path parentFile = tempDir.resolve("not-a-directory"); + Files.writeString(parentFile, "x"); + + BotConfig inaccessibleConfig = mock(BotConfig.class); + when(inaccessibleConfig.getPlaylistsFolder()).thenReturn(parentFile.resolve("child").toString()); + PlaylistLoader inaccessibleLoader = new PlaylistLoader(inaccessibleConfig); + + PlaylistLoader.PlaylistResult<Path> result = inaccessibleLoader.ensureStorageReady(); + + assertFalse(result.isSuccess()); + assertEquals(PlaylistLoader.PlaylistErrorType.STORAGE_UNAVAILABLE, result.getError().getType()); + } + + @Test + @DisplayName("getPlaylistNamesResult() places favorites at the top when present") + void getPlaylistNamesResult_placesFavoritesFirst() throws IOException + { + Files.writeString(tempDir.resolve("chill.txt"), "https://example.com/chill\n"); + Files.writeString(tempDir.resolve("favorites.txt"), "https://example.com/fav\n"); + Files.writeString(tempDir.resolve("workout.txt"), "https://example.com/workout\n"); + + PlaylistLoader.PlaylistResult<List<String>> result = loader.getPlaylistNamesResult(); + + assertTrue(result.isSuccess()); + assertFalse(result.getValue().isEmpty()); + assertEquals("favorites", result.getValue().get(0)); + assertTrue(result.getValue().containsAll(List.of("favorites", "chill", "workout"))); + } + + @Test + @DisplayName("appendItemIfAbsentResult() creates favorites file and appends first item") + void appendItemIfAbsentResult_createsFavoritesAndAppends() + { + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> result = + loader.appendItemIfAbsentResult("favorites", "https://example.com/first"); + + assertTrue(result.isSuccess()); + assertEquals(PlaylistLoader.AppendIfAbsentStatus.APPENDED, result.getValue().getStatus()); + assertTrue(Files.exists(tempDir.resolve("favorites.txt"))); + } + + @Test + @DisplayName("appendItemIfAbsentResult() returns already present when duplicate exists") + void appendItemIfAbsentResult_returnsAlreadyPresentForDuplicate() throws IOException + { + Files.writeString(tempDir.resolve("favorites.txt"), "https://example.com/track\n"); + + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> result = + loader.appendItemIfAbsentResult("favorites", "https://example.com/track"); + + assertTrue(result.isSuccess()); + assertEquals(PlaylistLoader.AppendIfAbsentStatus.ALREADY_PRESENT, result.getValue().getStatus()); + List<String> lines = Files.readAllLines(tempDir.resolve("favorites.txt")); + assertEquals(1, lines.stream().filter(l -> l.trim().equals("https://example.com/track")).count()); + } + + @Test + @DisplayName("appendItemIfAbsentResult() refreshes stale favorites cache after external file changes") + void appendItemIfAbsentResult_refreshesCacheWhenFavoritesFileChanges() throws IOException + { + loader.appendItemIfAbsentResult("favorites", "https://example.com/a"); + Files.writeString(tempDir.resolve("favorites.txt"), "https://example.com/a\nhttps://example.com/b\n"); + + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> result = + loader.appendItemIfAbsentResult("favorites", "https://example.com/b"); + + assertTrue(result.isSuccess()); + assertEquals(PlaylistLoader.AppendIfAbsentStatus.ALREADY_PRESENT, result.getValue().getStatus()); + } + + @Test + @DisplayName("appendItemIfAbsentResult() uses warm favorites cache for non-duplicate append") + void appendItemIfAbsentResult_warmCacheAppendsNewEntry() throws IOException + { + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> first = + loader.appendItemIfAbsentResult("favorites", "https://example.com/a"); + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> second = + loader.appendItemIfAbsentResult("favorites", "https://example.com/b"); + + assertTrue(first.isSuccess()); + assertEquals(PlaylistLoader.AppendIfAbsentStatus.APPENDED, first.getValue().getStatus()); + assertTrue(second.isSuccess()); + assertEquals(PlaylistLoader.AppendIfAbsentStatus.APPENDED, second.getValue().getStatus()); + + List<String> lines = Files.readAllLines(tempDir.resolve("favorites.txt")); + assertEquals(List.of("https://example.com/a", "https://example.com/b"), lines); + } + + @Test + @DisplayName("appendItemIfAbsentResult() uses warm favorites cache for duplicate detection") + void appendItemIfAbsentResult_warmCacheDetectsDuplicate() throws IOException + { + loader.appendItemIfAbsentResult("favorites", "https://example.com/a"); + + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> duplicate = + loader.appendItemIfAbsentResult("favorites", "https://example.com/a"); + + assertTrue(duplicate.isSuccess()); + assertEquals(PlaylistLoader.AppendIfAbsentStatus.ALREADY_PRESENT, duplicate.getValue().getStatus()); + + List<String> lines = Files.readAllLines(tempDir.resolve("favorites.txt")); + assertEquals(1, lines.stream().filter(l -> l.equals("https://example.com/a")).count()); + } + + @Test + @DisplayName("appendItemIfAbsentResult() reloads stale cache and preserves external additions") + void appendItemIfAbsentResult_staleCacheAppendsAfterExternalChange() throws IOException + { + loader.appendItemIfAbsentResult("favorites", "https://example.com/a"); + Files.writeString(tempDir.resolve("favorites.txt"), "https://example.com/a\nhttps://example.com/external\n"); + + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> appended = + loader.appendItemIfAbsentResult("favorites", "https://example.com/b"); + + assertTrue(appended.isSuccess()); + assertEquals(PlaylistLoader.AppendIfAbsentStatus.APPENDED, appended.getValue().getStatus()); + + List<String> lines = Files.readAllLines(tempDir.resolve("favorites.txt")); + assertEquals(List.of("https://example.com/a", "https://example.com/external", "https://example.com/b"), lines); + } + + @Test + @DisplayName("appendItemIfAbsentResult() is lock-safe under concurrent duplicate appends") + void appendItemIfAbsentResult_isLockSafeForConcurrentDuplicateAppends() throws Exception + { + int workers = 8; + ExecutorService pool = Executors.newFixedThreadPool(workers); + CountDownLatch ready = new CountDownLatch(workers); + CountDownLatch start = new CountDownLatch(1); + try + { + List<Future<PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult>>> futures = new ArrayList<>(); + for(int i = 0; i < workers; i++) + { + futures.add(pool.submit(() -> + { + ready.countDown(); + start.await(); + return loader.appendItemIfAbsentResult("favorites", "https://example.com/race"); + })); + } + + ready.await(); + start.countDown(); + + int appendedCount = 0; + int duplicateCount = 0; + for(Future<PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult>> future : futures) + { + PlaylistLoader.PlaylistResult<PlaylistLoader.AppendIfAbsentResult> result = future.get(); + assertTrue(result.isSuccess()); + if(result.getValue().getStatus() == PlaylistLoader.AppendIfAbsentStatus.APPENDED) + appendedCount++; + else + duplicateCount++; + } + + assertEquals(1, appendedCount); + assertEquals(workers - 1, duplicateCount); + } + finally + { + pool.shutdownNow(); + } + + List<String> lines = Files.readAllLines(tempDir.resolve("favorites.txt")); + assertEquals(1, lines.stream().filter(l -> l.trim().equals("https://example.com/race")).count()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/queue/FairQueueTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/queue/FairQueueTest.java new file mode 100644 index 0000000..2a82002 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/queue/FairQueueTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2018 John Grosh <john.a.grosh@gmail.com>. + * + * 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.unit.queue; + +import com.jagrosh.jmusicbot.queue.FairQueue; +import com.jagrosh.jmusicbot.queue.Queueable; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * + * @author John Grosh (john.a.grosh@gmail.com) + */ +public class FairQueueTest +{ + private static final int DEFAULT_HISTORY_SIZE = 10; + + @Test + public void differentIdentifierSize() + { + FairQueue<Q> queue = new FairQueue<>(null, DEFAULT_HISTORY_SIZE); + int size = 100; + for(int i=0; i<size; i++) + queue.add(new Q(i)); + assertEquals(queue.size(), size); + } + + @Test + public void sameIdentifierSize() + { + FairQueue<Q> queue = new FairQueue<>(null, DEFAULT_HISTORY_SIZE); + int size = 100; + for(int i=0; i<size; i++) + queue.add(new Q(0)); + assertEquals(queue.size(), size); + } + + private class Q implements Queueable + { + private final long identifier; + + private Q(long identifier) + { + this.identifier = identifier; + } + + @Override + public long getIdentifier() + { + return identifier; + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/queue/LinearQueueTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/queue/LinearQueueTest.java new file mode 100644 index 0000000..57422b6 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/queue/LinearQueueTest.java @@ -0,0 +1,95 @@ +package com.jagrosh.jmusicbot.unit.queue; + +import com.jagrosh.jmusicbot.queue.LinearQueue; +import com.jagrosh.jmusicbot.queue.Queueable; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class LinearQueueTest { + + private static final int DEFAULT_HISTORY_SIZE = 10; + + @Test + public void testAddAndPull() { + LinearQueue<Q> queue = new LinearQueue<>(null, DEFAULT_HISTORY_SIZE); + Q q1 = new Q(1); + Q q2 = new Q(2); + + assertEquals(0, queue.add(q1)); + assertEquals(1, queue.add(q2)); + assertEquals(2, queue.size()); + + assertEquals(q1, queue.pull()); + assertEquals(1, queue.size()); + assertEquals(q2, queue.pull()); + assertTrue(queue.isEmpty()); + } + + @Test + public void testRewind() { + LinearQueue<Q> queue = new LinearQueue<>(null, DEFAULT_HISTORY_SIZE); + Q q1 = new Q(1); + Q q2 = new Q(2); + + queue.addToHistory(q1); + + Q rewinded = queue.rewind(q2); + assertEquals(q1, rewinded); + assertEquals(1, queue.size()); + assertEquals(q2, queue.get(0)); + } + + @Test + public void testMoveItem() { + LinearQueue<Q> queue = new LinearQueue<>(null, DEFAULT_HISTORY_SIZE); + Q q1 = new Q(1); + Q q2 = new Q(2); + Q q3 = new Q(3); + + queue.add(q1); + queue.add(q2); + queue.add(q3); + + queue.moveItem(0, 2); // Move q1 to the end + assertEquals(q2, queue.get(0)); + assertEquals(q3, queue.get(1)); + assertEquals(q1, queue.get(2)); + } + + @Test + public void testRemoveAll() { + LinearQueue<Q> queue = new LinearQueue<>(null, DEFAULT_HISTORY_SIZE); + queue.add(new Q(1)); + queue.add(new Q(2)); + queue.add(new Q(1)); + queue.add(new Q(3)); + + int removed = queue.removeAll(1); + assertEquals(2, removed); + assertEquals(2, queue.size()); + assertEquals(2L, queue.get(0).getIdentifier()); + assertEquals(3L, queue.get(1).getIdentifier()); + } + + @Test + public void testSwitchQueueAppliesNewHistoryMaxSize() { + LinearQueue<Q> original = new LinearQueue<>(null, DEFAULT_HISTORY_SIZE); + original.addToHistory(new Q(1)); + original.addToHistory(new Q(2)); + assertEquals(2, original.getHistory().size()); + + LinearQueue<Q> disabledHistory = new LinearQueue<>(original, 0); + assertEquals(0, disabledHistory.getHistory().getMaxSize()); + assertTrue(disabledHistory.getHistory().isEmpty()); + + disabledHistory.getHistory().setMaxSize(2); + disabledHistory.addToHistory(new Q(3)); + assertEquals(1, disabledHistory.getHistory().size()); + } + + private static class Q implements Queueable { + private final long id; + Q(long id) { this.id = id; } + @Override public long getIdentifier() { return id; } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/queue/PlaybackHistoryTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/queue/PlaybackHistoryTest.java new file mode 100644 index 0000000..f03e0a7 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/queue/PlaybackHistoryTest.java @@ -0,0 +1,224 @@ +package com.jagrosh.jmusicbot.unit.queue; + +import com.jagrosh.jmusicbot.queue.PlaybackHistory; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; + +public class PlaybackHistoryTest { + + @Test + public void testAddAndSize() { + PlaybackHistory<String> history = new PlaybackHistory<>(3); + + history.add("one"); + assertEquals(1, history.size()); + assertEquals("one", history.get(0)); + + history.add("two"); + assertEquals(2, history.size()); + assertEquals("two", history.get(0)); + assertEquals("one", history.get(1)); + } + + @Test + public void testMaxSize() { + PlaybackHistory<String> history = new PlaybackHistory<>(2); + + history.add("one"); + history.add("two"); + history.add("three"); + + assertEquals(2, history.size()); + assertEquals("three", history.get(0)); + assertEquals("two", history.get(1)); + + // Ensure "one" was removed (it was the oldest) + List<String> list = history.getList(); + assertFalse(list.contains("one")); + } + + @Test + public void testRemoveFirst() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + history.add("one"); + history.add("two"); + + assertEquals("two", history.removeFirst()); + assertEquals(1, history.size()); + assertEquals("one", history.get(0)); + + assertEquals("one", history.removeFirst()); + assertTrue(history.isEmpty()); + assertNull(history.removeFirst()); + } + + @Test + public void testSetMaxSizeShrink() { + PlaybackHistory<String> history = new PlaybackHistory<>(5); + history.add("1"); + history.add("2"); + history.add("3"); + history.add("4"); + history.add("5"); + + history.setMaxSize(2); + assertEquals(2, history.size()); + assertEquals("5", history.get(0)); + assertEquals("4", history.get(1)); + } + + @Test + public void testSetNegativeMaxSize() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + assertThrows(IllegalArgumentException.class, () -> history.setMaxSize(-1)); + } + + @Test + public void testSetZeroMaxSize() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + history.add("one"); + history.add("two"); + history.setMaxSize(0); + + assertEquals(0, history.getMaxSize()); + assertTrue(history.isEmpty()); + assertEquals(0, history.size()); + } + + @Test + public void testClear() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + history.add("one"); + history.clear(); + assertTrue(history.isEmpty()); + assertEquals(0, history.size()); + } + + @Test + public void testAddNullThrows() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + assertThrows(NullPointerException.class, () -> history.add(null)); + } + + @Test + public void testConstructorRejectsNegativeSize() { + PlaybackHistory<String> history = new PlaybackHistory<>(0); + assertEquals(0, history.getMaxSize()); + assertTrue(history.isEmpty()); + + assertThrows(IllegalArgumentException.class, () -> new PlaybackHistory<String>(-1)); + } + + @Test + public void testAddIsNoopWhenHistoryDisabled() { + PlaybackHistory<String> history = new PlaybackHistory<>(0); + history.add("a"); + history.add("b"); + + assertEquals(0, history.getMaxSize()); + assertEquals(0, history.size()); + assertTrue(history.getList().isEmpty()); + } + + @Test + public void testTransitionFromDisabledToEnabledCollectsEntries() { + PlaybackHistory<String> history = new PlaybackHistory<>(0); + history.add("ignored"); + history.setMaxSize(2); + history.add("a"); + history.add("b"); + + assertEquals(2, history.getMaxSize()); + assertEquals(2, history.size()); + assertEquals("b", history.get(0)); + assertEquals("a", history.get(1)); + } + + @Test + public void testTransitionPositiveToDisabledClearsAndDedupStateResets() { + PlaybackHistory<String> history = new PlaybackHistory<>(3, s -> s); + history.add("a"); + history.add("b"); + history.setMaxSize(0); + history.add("c"); + + assertEquals(0, history.getMaxSize()); + assertTrue(history.isEmpty()); + + history.setMaxSize(2); + history.add("x"); + history.add("x"); + assertEquals(1, history.size()); + assertEquals("x", history.get(0)); + } + + @Test + public void testRemoveAt() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + history.add("a"); + history.add("b"); + history.add("c"); + + assertEquals("c", history.removeAt(0)); + assertEquals(2, history.size()); + assertEquals("b", history.get(0)); + + assertEquals("a", history.removeAt(1)); + assertEquals(1, history.size()); + assertEquals("b", history.get(0)); + + assertNull(history.removeAt(5)); + assertNull(history.removeAt(-1)); + } + + @Test + public void testRemoveFirstMatching() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + history.add("x"); + history.add("y"); + history.add("z"); + + assertTrue(history.removeFirstMatching("y"::equals)); + assertEquals(2, history.size()); + assertEquals("z", history.get(0)); + assertEquals("x", history.get(1)); + + assertFalse(history.removeFirstMatching("y"::equals)); + assertTrue(history.removeFirstMatching("z"::equals)); + assertEquals(1, history.size()); + assertEquals("x", history.get(0)); + + assertFalse(history.removeFirstMatching(null)); + } + + @Test + public void testKeyExtractorDuplicateKeyOnlyOneEntry() { + PlaybackHistory<String> history = new PlaybackHistory<>(10, s -> s); + history.add("a"); + history.add("a"); + assertEquals(1, history.size()); + assertEquals("a", history.get(0)); + } + + @Test + public void testKeyExtractorSameKeyMovesToFront() { + PlaybackHistory<String> history = new PlaybackHistory<>(10, s -> s); + history.add("a"); + history.add("b"); + history.add("a"); + assertEquals(2, history.size()); + assertEquals("a", history.get(0)); + assertEquals("b", history.get(1)); + } + + @Test + public void testKeyExtractorNullPreservesExistingBehavior() { + PlaybackHistory<String> history = new PlaybackHistory<>(10); + history.add("a"); + history.add("a"); + assertEquals(2, history.size()); + assertEquals("a", history.get(0)); + assertEquals("a", history.get(1)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/service/MusicServiceTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/service/MusicServiceTest.java new file mode 100644 index 0000000..792b585 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/service/MusicServiceTest.java @@ -0,0 +1,1814 @@ +/* + * 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.unit.service; + +import com.jagrosh.jmusicbot.audio.QueuedTrack; +import com.jagrosh.jmusicbot.audio.RequestMetadata; +import com.jagrosh.jmusicbot.queue.PlaybackHistory; +import com.jagrosh.jmusicbot.service.MusicService; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.testutil.service.MusicServiceScenarioBuilder; +import com.jagrosh.jmusicbot.testutil.service.OutputAdapterSpy; +import com.jagrosh.jmusicbot.testutil.service.ServiceTestFixture; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.jagrosh.jmusicbot.testutil.TestConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Comprehensive tests for MusicService player operations. + * Uses the test fixtures and scenario builders for maintainable tests. + */ +@DisplayName("MusicService Tests") +public class MusicServiceTest +{ + private ServiceTestFixture fixture; + private MusicService musicService; + private OutputAdapterSpy output; + + @BeforeEach + void setUp() + { + fixture = ServiceTestFixture.create(); + musicService = new MusicService(fixture.getBot()); + output = new OutputAdapterSpy(); + } + + // ==================== Play Operation Tests ==================== + + @Nested + @DisplayName("Play Operation") + class PlayOperationTests + { + @Test + @DisplayName("play() with query loads track via PlayerManager") + void playWithQuery_loadsTrackViaPlayerManager() + { + // Given + String query = "test song"; + + // When + musicService.play(fixture.getGuild(), fixture.getMember(), query, + fixture.getTextChannel(), output); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered(eq(fixture.getGuild()), + eq(query), any()); + } + + @Test + @DisplayName("play() with URL in angle brackets strips brackets") + void playWithAngleBrackets_stripsBrackets() + { + // Given + String url = "https://example.com/track"; + + // When + musicService.play(fixture.getGuild(), fixture.getMember(), + "<" + url + ">", fixture.getTextChannel(), output); + + // Note: The stripping happens in playNext, not play - checking actual behavior + verify(fixture.getPlayerManager()).loadItemOrdered(eq(fixture.getGuild()), + eq("<" + url + ">"), any()); + } + + @Test + @DisplayName("play() with empty args when paused resumes playback for DJ") + void playEmptyArgs_whenPaused_resumesForDJ() + { + // Given + fixture.withDJPermission() + .withPausedTrack(); + + // When + musicService.play(fixture.getGuild(), fixture.getMember(), null, + fixture.getTextChannel(), output); + + // Then + verify(fixture.getAudioPlayer()).setPaused(false); + output.assertSuccessMessageContains("Resumed"); + } + + @Test + @DisplayName("play() with empty args when paused fails for non-DJ") + void playEmptyArgs_whenPaused_failsForNonDJ() + { + // Given + fixture.withoutDJPermission() + .withPausedTrack(); + + // When + musicService.play(fixture.getGuild(), fixture.getMember(), null, + fixture.getTextChannel(), output); + + // Then + verify(fixture.getAudioPlayer(), never()).setPaused(anyBoolean()); + output.assertErrorMessageContains("Only DJs can unpause"); + } + + @Test + @DisplayName("play() with empty args when not playing shows help") + void playEmptyArgs_whenNotPlaying_showsHelp() + { + // Given + fixture.withNoTrack(); + + // When + musicService.play(fixture.getGuild(), fixture.getMember(), null, + fixture.getTextChannel(), output); + + // Then + output.assertHelpShown(); + } + + @Test + @DisplayName("play() with quoted query strips quotes") + void playWithQuotes_stripsQuotes() + { + // Given + String query = "test song"; + + // When + musicService.play(fixture.getGuild(), fixture.getMember(), + "\"" + query + "\"", fixture.getTextChannel(), output); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered(eq(fixture.getGuild()), + eq(query), any()); + } + } + + // ==================== PlayNext Operation Tests ==================== + + @Nested + @DisplayName("PlayNext Operation") + class PlayNextOperationTests + { + @Test + @DisplayName("playNext() with query loads track via PlayerManager") + void playNextWithQuery_loadsTrack() + { + // Given + String query = "test song"; + + // When + musicService.playNext(fixture.getGuild(), fixture.getMember(), query, + fixture.getTextChannel(), output); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered(eq(fixture.getGuild()), + eq(query), any()); + } + + @Test + @DisplayName("playNext() with empty query shows warning") + void playNextEmptyQuery_showsWarning() + { + // When + musicService.playNext(fixture.getGuild(), fixture.getMember(), "", + fixture.getTextChannel(), output); + + // Then + output.assertWarningMessageContains("include a song title or URL"); + } + + @Test + @DisplayName("playNext() with null query shows warning") + void playNextNullQuery_showsWarning() + { + // When + musicService.playNext(fixture.getGuild(), fixture.getMember(), null, + fixture.getTextChannel(), output); + + // Then + output.assertWarningMessageContains("include a song title or URL"); + } + + @Test + @DisplayName("playNext() strips angle brackets from URL") + void playNextStripsAngleBrackets() + { + // Given + String url = "https://example.com/track"; + + // When + musicService.playNext(fixture.getGuild(), fixture.getMember(), + "<" + url + ">", fixture.getTextChannel(), output); + + // Then + verify(fixture.getPlayerManager()).loadItemOrdered(eq(fixture.getGuild()), + eq(url), any()); + } + } + + // ==================== Previous Operation Tests ==================== + + @Nested + @DisplayName("Previous Operation") + class PreviousOperationTests + { + @Test + @DisplayName("previous() restarts track when position > 5 seconds") + void previous_restartsTrack_whenPositionOver5Seconds() + { + // Given + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getCurrentTrack().getPosition()).thenReturn(6000L); + + // Setup request metadata + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(USER_ID); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + + // When + musicService.previous(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getCurrentTrack()).setPosition(0); + output.assertSuccessMessageContains("Restarted"); + } + + @Test + @DisplayName("previous() goes to previous track when position < 5 seconds") + void previous_goesToPreviousTrack_whenPositionUnder5Seconds() + { + // Given + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getCurrentTrack().getPosition()).thenReturn(3000L); + + // Setup request metadata + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(USER_ID); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + when(fixture.getCurrentTrack().getIdentifier()).thenReturn("current-id"); + + // Setup history where current track appears at head, followed by an actual previous track. + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.isEmpty()).thenReturn(false); + when(fixture.getQueue().getHistory()).thenReturn(history); + QueuedTrack currentHistoryEntry = mock(QueuedTrack.class); + AudioTrack currentHistoryTrack = mock(AudioTrack.class); + when(currentHistoryEntry.getTrack()).thenReturn(currentHistoryTrack); + when(currentHistoryTrack.getIdentifier()).thenReturn("current-id"); + when(history.get(0)).thenReturn(currentHistoryEntry); + + QueuedTrack previousTrack = mock(QueuedTrack.class); + AudioTrack prevAudioTrack = mock(AudioTrack.class); + AudioTrackInfo prevInfo = new AudioTrackInfo("Previous Song", "Artist", 180000, "id", false, "url"); + when(prevAudioTrack.getInfo()).thenReturn(prevInfo); + when(previousTrack.getTrack()).thenReturn(prevAudioTrack); + when(fixture.getQueue().rewind(any())).thenReturn(previousTrack); + when(fixture.getCurrentTrack().makeClone()).thenReturn(fixture.getCurrentTrack()); + + // When + musicService.previous(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getQueue()).removeFromHistoryAt(0); + verify(fixture.getAudioPlayer()).playTrack(prevAudioTrack); + output.assertSuccessMessageContains("Went back to"); + } + + @Test + @DisplayName("previous() while paused restarts current track when position > 5 seconds") + void previous_whenPausedAndOver5Seconds_restartsCurrentTrack() + { + // Given + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getCurrentTrack().getPosition()).thenReturn(6000L); + when(fixture.getAudioPlayer().isPaused()).thenReturn(true); + + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(USER_ID); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + + // When + musicService.previous(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getCurrentTrack()).setPosition(0); + verify(fixture.getQueue(), never()).rewind(any()); + output.assertSuccessMessageContains("Restarted"); + } + + @Test + @DisplayName("previous() fails when only current track exists in history under 5 seconds") + void previous_failsWhenOnlyCurrentTrackInHistory_under5Seconds() + { + // Given + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getCurrentTrack().getPosition()).thenReturn(3000L); + + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(USER_ID); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + when(fixture.getCurrentTrack().getIdentifier()).thenReturn("current-id"); + + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.isEmpty()).thenReturn(false, true); + when(fixture.getQueue().getHistory()).thenReturn(history); + QueuedTrack currentHistoryEntry = mock(QueuedTrack.class); + AudioTrack currentHistoryTrack = mock(AudioTrack.class); + when(currentHistoryEntry.getTrack()).thenReturn(currentHistoryTrack); + when(currentHistoryTrack.getIdentifier()).thenReturn("current-id"); + when(history.get(0)).thenReturn(currentHistoryEntry); + when(fixture.getCurrentTrack().makeClone()).thenReturn(fixture.getCurrentTrack()); + + // When + musicService.previous(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getQueue()).removeFromHistoryAt(0); + verify(fixture.getQueue(), never()).rewind(any()); + output.assertErrorMessageContains("no previous tracks"); + } + + @Test + @DisplayName("previous() fails when no history") + void previous_failsWhenNoHistory() + { + // Given + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getCurrentTrack().getPosition()).thenReturn(1000L); + + // Setup request metadata + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(USER_ID); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + + // Empty history + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.isEmpty()).thenReturn(true); + when(fixture.getQueue().getHistory()).thenReturn(history); + + // When + musicService.previous(fixture.getGuild(), fixture.getMember(), output); + + // Then + output.assertErrorMessageContains("no previous tracks"); + } + + @Test + @DisplayName("previous() fails for non-DJ who doesn't own the track") + void previous_failsForNonDJNonOwner() + { + // Given + fixture.withoutDJPermission() + .withPlayingTrack(); + + // Track owned by different user + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(999999L); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + + // When + musicService.previous(fixture.getGuild(), fixture.getMember(), output); + + // Then + output.assertErrorMessageContains("DJ or the requester"); + } + } + + // ==================== Pause Operation Tests ==================== + + @Nested + @DisplayName("Pause Operation") + class PauseOperationTests + { + @Test + @DisplayName("pause() toggles pause state for DJ") + void pause_togglesPauseState_forDJ() + { + // Given + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getAudioPlayer().isPaused()).thenReturn(false); + + // When + musicService.pause(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getAudioPlayer()).setPaused(true); + output.assertNowPlayingEdited(); + } + + @Test + @DisplayName("pause() fails for non-DJ") + void pause_failsForNonDJ() + { + // Given + fixture.withoutDJPermission(); + + // When + musicService.pause(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getAudioPlayer(), never()).setPaused(anyBoolean()); + output.assertErrorMessageContains("need to be a DJ"); + } + + @Test + @DisplayName("isPaused() returns correct state") + void isPaused_returnsCorrectState() + { + // Given + when(fixture.getAudioPlayer().isPaused()).thenReturn(true); + + // When/Then + assertTrue(musicService.isPaused(fixture.getGuild())); + + // Given + when(fixture.getAudioPlayer().isPaused()).thenReturn(false); + + // When/Then + assertFalse(musicService.isPaused(fixture.getGuild())); + } + + @Test + @DisplayName("setPaused() sets pause state and returns track title") + void setPaused_setsPauseState_returnsTrackTitle() + { + // Given + fixture.withPlayingTrack("My Song", "Artist", 180000); + + // When + String title = musicService.setPaused(fixture.getGuild(), true); + + // Then + verify(fixture.getAudioPlayer()).setPaused(true); + assertEquals("My Song", title); + } + } + + // ==================== Stop Operation Tests ==================== + + @Nested + @DisplayName("Stop Operation") + class StopOperationTests + { + @Test + @DisplayName("stop() stops playback and closes connection for DJ") + void stop_stopsAndCloses_forDJ() + { + // Given + fixture.withDJPermission(); + + // When + musicService.stop(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getAudioHandler()).stopAndClearQueuePreserveHistory(); + verify(fixture.getAudioManager()).closeAudioConnection(); + output.assertNoMusicEdited(); + } + + @Test + @DisplayName("stop() fails for non-DJ") + void stop_failsForNonDJ() + { + // Given + fixture.withoutDJPermission(); + + // When + musicService.stop(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getAudioHandler(), never()).stopAndClearQueuePreserveHistory(); + output.assertErrorMessageContains("need to be a DJ"); + } + + @Test + @DisplayName("stopAndClear() stops playback without permission check") + void stopAndClear_stopsWithoutPermissionCheck() + { + // When + musicService.stopAndClear(fixture.getGuild()); + + // Then + verify(fixture.getAudioHandler()).stopAndClearQueuePreserveHistory(); + verify(fixture.getAudioManager()).closeAudioConnection(); + } + } + + // ==================== Skip Operation Tests ==================== + + @Nested + @DisplayName("Skip Operation") + class SkipOperationTests + { + @Test + @DisplayName("skip() for DJ skips current track") + void skip_forDJ_skipsTrack() + { + // Given + fixture.withDJPermission() + .withPlayingTrack(); + + RequestMetadata metadata = mock(RequestMetadata.class); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + + // When + musicService.skip(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getAudioPlayer()).stopTrack(); + output.assertSuccessMessageContains("Skipped"); + } + + @Test + @DisplayName("skip() fails for non-DJ who doesn't own track") + void skip_failsForNonDJNonOwner() + { + // Given + fixture.withoutDJPermission() + .withPlayingTrack(); + + RequestMetadata metadata = mock(RequestMetadata.class); + when(metadata.getOwner()).thenReturn(999999L); + when(fixture.getAudioHandler().getRequestMetadata()).thenReturn(metadata); + + // When + musicService.skip(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getAudioPlayer(), never()).stopTrack(); + output.assertErrorMessageContains("DJ or the requester"); + } + } + + // ==================== Shuffle Operation Tests ==================== + + @Nested + @DisplayName("Shuffle Operation") + class ShuffleOperationTests + { + @Test + @DisplayName("shuffle() shuffles queue for DJ") + void shuffle_shufflesQueue_forDJ() + { + // Given - use scenario builder for queue management setup + MusicServiceScenarioBuilder.with(fixture).queueManagement(); + when(fixture.getQueue().shuffle(0)).thenReturn(10); + + // When + musicService.shuffle(fixture.getGuild(), fixture.getMember(), 0, output); + + // Then + verify(fixture.getQueue()).shuffle(0); + output.assertSuccessMessageContains("Shuffled 10 tracks"); + } + + @Test + @DisplayName("shuffle() fails for non-DJ") + void shuffle_failsForNonDJ() + { + // Given - use scenario builder for non-DJ scenario + MusicServiceScenarioBuilder.with(fixture).noDJPermission(); + + // When + musicService.shuffle(fixture.getGuild(), fixture.getMember(), 0, output); + + // Then + verify(fixture.getQueue(), never()).shuffle(anyInt()); + output.assertErrorMessageContains("need to be a DJ"); + } + + @Test + @DisplayName("shuffleUserTracks() shuffles only user's tracks") + void shuffleUserTracks_shufflesOnlyUserTracks() + { + // Given + when(fixture.getQueue().shuffle(USER_ID)).thenReturn(5); + + // When + int count = musicService.shuffleUserTracks(fixture.getGuild(), USER_ID); + + // Then + assertEquals(5, count); + verify(fixture.getQueue()).shuffle(USER_ID); + } + } + + // ==================== Repeat Mode Tests ==================== + + @Nested + @DisplayName("Repeat Mode Operation") + class RepeatModeTests + { + @Test + @DisplayName("cycleRepeatMode() cycles OFF -> ALL for DJ") + void cycleRepeatMode_offToAll() + { + // Given + MusicServiceScenarioBuilder.with(fixture) + .withDJ() + .withRepeat(RepeatMode.OFF); + + // When + musicService.cycleRepeatMode(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getSettings()).setRepeatMode(RepeatMode.ALL); + output.assertNowPlayingEdited(); + } + + @Test + @DisplayName("cycleRepeatMode() cycles ALL -> SINGLE") + void cycleRepeatMode_allToSingle() + { + // Given - use scenario builder for repeat test setup + MusicServiceScenarioBuilder.with(fixture) + .withRepeat(); // Sets up DJ + playing + RepeatMode.ALL + + // When + musicService.cycleRepeatMode(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getSettings()).setRepeatMode(RepeatMode.SINGLE); + } + + @Test + @DisplayName("cycleRepeatMode() cycles SINGLE -> OFF") + void cycleRepeatMode_singleToOff() + { + // Given + MusicServiceScenarioBuilder.with(fixture) + .withDJ() + .withRepeat(RepeatMode.SINGLE); + + // When + musicService.cycleRepeatMode(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getSettings()).setRepeatMode(RepeatMode.OFF); + } + + @Test + @DisplayName("cycleRepeatMode() fails for non-DJ") + void cycleRepeatMode_failsForNonDJ() + { + // Given + MusicServiceScenarioBuilder.with(fixture).noDJPermission(); + + // When + musicService.cycleRepeatMode(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getSettings(), never()).setRepeatMode(any()); + output.assertErrorMessageContains("need to be a DJ"); + } + + @Test + @DisplayName("getRepeatMode() returns current mode") + void getRepeatMode_returnsCurrentMode() + { + // Given + fixture.withRepeatMode(RepeatMode.ALL); + + // When/Then + assertEquals(RepeatMode.ALL, musicService.getRepeatMode(fixture.getGuild())); + } + + @Test + @DisplayName("setRepeatMode() sets the mode") + void setRepeatMode_setsMode() + { + // When + musicService.setRepeatMode(fixture.getGuild(), RepeatMode.SINGLE); + + // Then + verify(fixture.getSettings()).setRepeatMode(RepeatMode.SINGLE); + } + } + + // ==================== Volume Operation Tests ==================== + + @Nested + @DisplayName("Volume Operation") + class VolumeOperationTests + { + @Test + @DisplayName("adjustVolume() increases volume for DJ") + void adjustVolume_increases_forDJ() + { + // Given - use scenario builder for volume test setup + MusicServiceScenarioBuilder.with(fixture).volumeTest(); + + // When + musicService.adjustVolume(fixture.getGuild(), fixture.getMember(), 10, output); + + // Then + verify(fixture.getAudioPlayer()).setVolume(60); + verify(fixture.getSettings()).setVolume(60); + output.assertNowPlayingEdited(); + } + + @Test + @DisplayName("adjustVolume() clamps to max 150") + void adjustVolume_clampsToMax() + { + // Given + fixture.withDJPermission() + .withVolume(145); + + // When + musicService.adjustVolume(fixture.getGuild(), fixture.getMember(), 10, output); + + // Then + verify(fixture.getAudioPlayer()).setVolume(150); + } + + @Test + @DisplayName("adjustVolume() clamps to min 0") + void adjustVolume_clampsToMin() + { + // Given + fixture.withDJPermission() + .withVolume(5); + + // When + musicService.adjustVolume(fixture.getGuild(), fixture.getMember(), -10, output); + + // Then + verify(fixture.getAudioPlayer()).setVolume(0); + } + + @Test + @DisplayName("adjustVolume() fails for non-DJ") + void adjustVolume_failsForNonDJ() + { + // Given + fixture.withoutDJPermission(); + + // When + musicService.adjustVolume(fixture.getGuild(), fixture.getMember(), 10, output); + + // Then + verify(fixture.getAudioPlayer(), never()).setVolume(anyInt()); + output.assertErrorMessageContains("need to be a DJ"); + } + + @Test + @DisplayName("setVolume() sets absolute volume") + void setVolume_setsAbsoluteVolume() + { + // Given + fixture.withVolume(50); + + // When + MusicService.VolumeResult result = musicService.setVolume(fixture.getGuild(), 75); + + // Then + verify(fixture.getAudioPlayer()).setVolume(75); + verify(fixture.getSettings()).setVolume(75); + assertNotNull(result); + assertEquals(50, result.oldVolume); + assertEquals(75, result.newVolume); + } + + @Test + @DisplayName("setVolume() returns null for invalid volume") + void setVolume_returnsNullForInvalidVolume() + { + // When + MusicService.VolumeResult resultLow = musicService.setVolume(fixture.getGuild(), -1); + MusicService.VolumeResult resultHigh = musicService.setVolume(fixture.getGuild(), 151); + + // Then + assertNull(resultLow); + assertNull(resultHigh); + verify(fixture.getAudioPlayer(), never()).setVolume(anyInt()); + } + + @Test + @DisplayName("getVolume() returns current volume") + void getVolume_returnsCurrentVolume() + { + // Given + when(fixture.getAudioPlayer().getVolume()).thenReturn(75); + + // When/Then + assertEquals(75, musicService.getVolume(fixture.getGuild())); + } + } + + // ==================== Queue Management Tests ==================== + + @Nested + @DisplayName("Queue Management Operations") + class QueueManagementTests + { + @Test + @DisplayName("removeTrack() removes user's own track") + void removeTrack_removesOwnTrack() + { + // Given + fixture.withQueueSize(5); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Test Song", "Artist", 180000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(qt.getIdentifier()).thenReturn(USER_ID); + when(fixture.getQueue().get(1)).thenReturn(qt); + + // When + musicService.removeTrack(fixture.getGuild(), fixture.getMember(), 2, output); + + // Then + verify(fixture.getQueue()).remove(1); + output.assertSuccessMessageContains("Removed"); + } + + @Test + @DisplayName("removeTrack() DJ removes other user's track") + void removeTrack_djRemovesOthersTrack() + { + // Given + fixture.withDJPermission() + .withQueueSize(5); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Test Song", "Artist", 180000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(qt.getIdentifier()).thenReturn(999999L); // Different user + when(fixture.getQueue().get(1)).thenReturn(qt); + + // When + musicService.removeTrack(fixture.getGuild(), fixture.getMember(), 2, output); + + // Then + verify(fixture.getQueue()).remove(1); + output.assertSuccessMessageContains("Removed"); + } + + @Test + @DisplayName("removeTrack() non-DJ cannot remove other's track") + void removeTrack_nonDJCannotRemoveOthersTrack() + { + // Given + fixture.withoutDJPermission() + .withQueueSize(5); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Test Song", "Artist", 180000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(qt.getIdentifier()).thenReturn(999999L); // Different user + when(fixture.getQueue().get(1)).thenReturn(qt); + + // When + musicService.removeTrack(fixture.getGuild(), fixture.getMember(), 2, output); + + // Then + verify(fixture.getQueue(), never()).remove(anyInt()); + output.assertErrorMessageContains("didn't add it"); + } + + @Test + @DisplayName("removeTrack() fails on empty queue") + void removeTrack_failsOnEmptyQueue() + { + // Given + fixture.withEmptyQueue(); + + // When + musicService.removeTrack(fixture.getGuild(), fixture.getMember(), 1, output); + + // Then + output.assertErrorMessage("There is nothing in the queue!"); + } + + @Test + @DisplayName("removeAllTracks() removes user's tracks") + void removeAllTracks_removesUserTracks() + { + // Given + fixture.withQueueSize(5); + when(fixture.getQueue().removeAll(USER_ID)).thenReturn(3); + + // When + musicService.removeAllTracks(fixture.getGuild(), fixture.getMember(), output); + + // Then + verify(fixture.getQueue()).removeAll(USER_ID); + output.assertSuccessMessageContains("3 entries"); + } + + @Test + @DisplayName("removeAllTracks() shows warning when user has no tracks") + void removeAllTracks_warnsWhenNoTracks() + { + // Given + fixture.withQueueSize(5); + when(fixture.getQueue().removeAll(USER_ID)).thenReturn(0); + + // When + musicService.removeAllTracks(fixture.getGuild(), fixture.getMember(), output); + + // Then + output.assertWarningMessageContains("don't have any songs"); + } + + @Test + @DisplayName("removeAllTracksByUser() removes tracks for specific user") + void removeAllTracksByUser_removesTracksForUser() + { + // Given + when(fixture.getQueue().removeAll(999999L)).thenReturn(5); + + // When + int count = musicService.removeAllTracksByUser(fixture.getGuild(), 999999L); + + // Then + assertEquals(5, count); + verify(fixture.getQueue()).removeAll(999999L); + } + + @Test + @DisplayName("moveTrack() moves track for DJ") + void moveTrack_movesTrackForDJ() + { + // Given + fixture.withDJPermission() + .withQueueSize(10); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Test Song", "Artist", 180000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(fixture.getQueue().moveItem(1, 4)).thenReturn(qt); + + // When + musicService.moveTrack(fixture.getGuild(), fixture.getMember(), 2, 5, output); + + // Then + verify(fixture.getQueue()).moveItem(1, 4); + output.assertSuccessMessageContains("Moved"); + } + + @Test + @DisplayName("moveTrack() fails for non-DJ") + void moveTrack_failsForNonDJ() + { + // Given + fixture.withoutDJPermission(); + + // When + musicService.moveTrack(fixture.getGuild(), fixture.getMember(), 2, 5, output); + + // Then + verify(fixture.getQueue(), never()).moveItem(anyInt(), anyInt()); + output.assertErrorMessageContains("need to be a DJ"); + } + + @Test + @DisplayName("moveTrack() fails for same position") + void moveTrack_failsForSamePosition() + { + // Given + fixture.withDJPermission(); + + // When + musicService.moveTrack(fixture.getGuild(), fixture.getMember(), 2, 2, output); + + // Then + verify(fixture.getQueue(), never()).moveItem(anyInt(), anyInt()); + output.assertErrorMessageContains("same position"); + } + + @Test + @DisplayName("moveTrackPosition() moves track without permission check") + void moveTrackPosition_movesWithoutPermCheck() + { + // Given + fixture.withQueueSize(10); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Test Song", "Artist", 180000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(fixture.getQueue().moveItem(1, 4)).thenReturn(qt); + + // When + String title = musicService.moveTrackPosition(fixture.getGuild(), 2, 5); + + // Then + assertEquals("Test Song", title); + verify(fixture.getQueue()).moveItem(1, 4); + } + + @Test + @DisplayName("skipTo() skips to position for DJ") + void skipTo_skipsToPositionForDJ() + { + // Given + fixture.withDJPermission() + .withQueueSize(10); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Target Song", "Artist", 180000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(fixture.getQueue().get(0)).thenReturn(qt); + + // When + musicService.skipTo(fixture.getGuild(), fixture.getMember(), 5, output); + + // Then + verify(fixture.getQueue()).skip(4); + verify(fixture.getAudioPlayer()).stopTrack(); + output.assertSuccessMessageContains("Skipped to"); + } + + @Test + @DisplayName("skipTo() fails for non-DJ") + void skipTo_failsForNonDJ() + { + // Given + fixture.withoutDJPermission(); + + // When + musicService.skipTo(fixture.getGuild(), fixture.getMember(), 5, output); + + // Then + verify(fixture.getQueue(), never()).skip(anyInt()); + output.assertErrorMessageContains("need to be a DJ"); + } + + @Test + @DisplayName("skipToPosition() skips without permission check") + void skipToPosition_skipsWithoutPermCheck() + { + // Given + fixture.withQueueSize(10); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Target Song", "Artist", 180000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(fixture.getQueue().get(0)).thenReturn(qt); + + // When + String title = musicService.skipToPosition(fixture.getGuild(), 5); + + // Then + assertEquals("Target Song", title); + verify(fixture.getQueue()).skip(4); + } + + @Test + @DisplayName("skipToPosition() returns null for invalid position") + void skipToPosition_returnsNullForInvalidPosition() + { + // Given + fixture.withQueueSize(5); + + // When + String title = musicService.skipToPosition(fixture.getGuild(), 10); + + // Then + assertNull(title); + verify(fixture.getQueue(), never()).skip(anyInt()); + } + + @Test + @DisplayName("isQueueEmpty() returns correct state") + void isQueueEmpty_returnsCorrectState() + { + // Given + when(fixture.getQueue().isEmpty()).thenReturn(true); + assertTrue(musicService.isQueueEmpty(fixture.getGuild())); + + when(fixture.getQueue().isEmpty()).thenReturn(false); + assertFalse(musicService.isQueueEmpty(fixture.getGuild())); + } + + @Test + @DisplayName("getQueueSize() returns correct size") + void getQueueSize_returnsCorrectSize() + { + // Given + when(fixture.getQueue().size()).thenReturn(15); + + // When/Then + assertEquals(15, musicService.getQueueSize(fixture.getGuild())); + } + + @Test + @DisplayName("isValidQueuePosition() validates positions correctly") + void isValidQueuePosition_validatesCorrectly() + { + // Given + fixture.withQueueSize(10); + + // When/Then + assertTrue(musicService.isValidQueuePosition(fixture.getGuild(), 1)); + assertTrue(musicService.isValidQueuePosition(fixture.getGuild(), 10)); + assertFalse(musicService.isValidQueuePosition(fixture.getGuild(), 0)); + assertFalse(musicService.isValidQueuePosition(fixture.getGuild(), 11)); + } + } + + // ==================== Track Utility Tests ==================== + + @Nested + @DisplayName("Track Utility Methods") + class TrackUtilityTests + { + @Test + @DisplayName("isTooLong() delegates to config") + void isTooLong_delegatesToConfig() + { + // Given + AudioTrack track = mock(AudioTrack.class); + when(fixture.getConfig().isTooLong(track)).thenReturn(true); + + // When/Then + assertTrue(musicService.isTooLong(track)); + + when(fixture.getConfig().isTooLong(track)).thenReturn(false); + assertFalse(musicService.isTooLong(track)); + } + + @Test + @DisplayName("formatTooLongError() formats message correctly") + void formatTooLongError_formatsCorrectly() + { + // Given + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Long Song", "Artist", 600000, "id", false, "url"); + when(track.getInfo()).thenReturn(info); + when(track.getDuration()).thenReturn(600000L); + when(fixture.getConfig().getMaxTime()).thenReturn("300"); + + // When + String error = musicService.formatTooLongError(track); + + // Then + assertTrue(error.contains("Long Song")); + assertTrue(error.contains("longer than the allowed maximum")); + } + + @Test + @DisplayName("formatTrackAddedMessage() formats position 0 as now playing") + void formatTrackAddedMessage_position0_nowPlaying() + { + // When + String message = musicService.formatTrackAddedMessage("Test Song", 180000, 0); + + // Then + assertTrue(message.contains("Test Song")); + assertTrue(message.contains("begin playing")); + } + + @Test + @DisplayName("formatTrackAddedMessage() formats queue position correctly") + void formatTrackAddedMessage_queuePosition() + { + // When + String message = musicService.formatTrackAddedMessage("Test Song", 180000, 5); + + // Then + assertTrue(message.contains("Test Song")); + assertTrue(message.contains("position 5")); + } + } + + // ==================== Playlist Operation Tests ==================== + + @Nested + @DisplayName("Playlist Operations") + class PlaylistOperationTests + { + @Test + @DisplayName("getAvailablePlaylistNames() returns explicit storage error") + void getAvailablePlaylistNames_returnsStorageError() + { + com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistError error = + com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistError.of( + com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistErrorType.STORAGE_UNAVAILABLE, + "Access denied", "Playlists", null); + when(fixture.getPlaylistLoader().getPlaylistNamesResult()) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.failure(error)); + + MusicService.PlaylistNamesInfo info = musicService.getAvailablePlaylistNames(); + + assertTrue(info.hasError()); + assertTrue(info.errorMessage.contains("unavailable")); + } + + @Test + @DisplayName("queuePlaylist() refreshes now playing when tracks were loaded and player is active") + void queuePlaylist_refreshesNowPlaying_whenTracksLoadedAndPlayerActive() + { + com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist playlist = mock(com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist.class); + when(fixture.getPlaylistLoader().getPlaylistResult("favorite")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success(playlist)); + when(playlist.getName()).thenReturn("favorite"); + when(playlist.getTracks()).thenReturn(List.of(mock(AudioTrack.class), mock(AudioTrack.class))); + when(playlist.getErrors()).thenReturn(List.of()); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(mock(AudioTrack.class)); + + doAnswer(invocation -> + { + Runnable callback = invocation.getArgument(2); + callback.run(); + return null; + }).when(playlist).loadTracks(eq(fixture.getPlayerManager()), any(), any()); + + musicService.queuePlaylist(fixture.getGuild(), fixture.getMember(), "favorite", + fixture.getTextChannel(), output); + + verify(fixture.getNowPlayingHandler()).requestReconcile(eq(fixture.getGuild().getIdLong()), anyString()); + output.assertSuccessMessageContains("Queued **2** track(s)"); + } + + @Test + @DisplayName("queuePlaylist() skips now playing refresh when tracks loaded but player still inactive") + void queuePlaylist_skipsRefresh_whenTracksLoadedButPlayerInactive() + { + com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist playlist = mock(com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist.class); + when(fixture.getPlaylistLoader().getPlaylistResult("favorite")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success(playlist)); + when(playlist.getName()).thenReturn("favorite"); + when(playlist.getTracks()).thenReturn(List.of(mock(AudioTrack.class), mock(AudioTrack.class))); + when(playlist.getErrors()).thenReturn(List.of()); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(null); + + doAnswer(invocation -> + { + Runnable callback = invocation.getArgument(2); + callback.run(); + return null; + }).when(playlist).loadTracks(eq(fixture.getPlayerManager()), any(), any()); + + musicService.queuePlaylist(fixture.getGuild(), fixture.getMember(), "favorite", + fixture.getTextChannel(), output); + + verify(fixture.getNowPlayingHandler(), never()).requestReconcile(anyLong(), anyString()); + output.assertSuccessMessageContains("Queued **2** track(s)"); + } + + @Test + @DisplayName("queuePlaylist() does not refresh now playing when no tracks loaded") + void queuePlaylist_doesNotRefreshNowPlaying_whenNoTracksLoaded() + { + com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist playlist = mock(com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist.class); + when(fixture.getPlaylistLoader().getPlaylistResult("empty")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success(playlist)); + when(playlist.getName()).thenReturn("empty"); + when(playlist.getTracks()).thenReturn(List.of()); + when(playlist.getErrors()).thenReturn(List.of(mock(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistLoadError.class))); + + doAnswer(invocation -> + { + Runnable callback = invocation.getArgument(2); + callback.run(); + return null; + }).when(playlist).loadTracks(eq(fixture.getPlayerManager()), any(), any()); + + musicService.queuePlaylist(fixture.getGuild(), fixture.getMember(), "empty", + fixture.getTextChannel(), output); + + verify(fixture.getNowPlayingHandler(), never()).requestReconcile(anyLong(), anyString()); + output.assertWarningMessageContains("No tracks were loaded"); + } + + @Test + @DisplayName("playPlaylistNow() refreshes now playing after queue replacement") + void playPlaylistNow_refreshesNowPlaying_afterQueueReplacement() + { + com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist playlist = mock(com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist.class); + when(fixture.getPlaylistLoader().getPlaylistResult("mix")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success(playlist)); + when(playlist.getName()).thenReturn("mix"); + when(playlist.getTracks()).thenReturn(List.of(mock(AudioTrack.class))); + when(playlist.getErrors()).thenReturn(List.of()); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(mock(AudioTrack.class)); + + doAnswer(invocation -> + { + Runnable callback = invocation.getArgument(2); + callback.run(); + return null; + }).when(playlist).loadTracks(eq(fixture.getPlayerManager()), any(), any()); + + musicService.playPlaylistNow(fixture.getGuild(), fixture.getMember(), "mix", + fixture.getTextChannel(), output); + + verify(fixture.getAudioHandler()).stopAndClearQueuePreserveHistory(); + verify(fixture.getNowPlayingHandler()).requestReconcile(eq(fixture.getGuild().getIdLong()), anyString()); + output.assertSuccessMessageContains("Now playing playlist `mix` (queue replaced)"); + } + + } + + @Nested + @DisplayName("Favorites Playlist Operation") + class FavoritesPlaylistOperationTests + { + @Test + @DisplayName("addCurrentTrackToFavorites() fails for non-DJ") + void addCurrentTrackToFavorites_failsForNonDJ() + { + fixture.withoutDJPermission() + .withPlayingTrack(); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + output.assertErrorMessageContains("need to be a DJ"); + verify(fixture.getPlaylistLoader(), never()).appendItemIfAbsentResult(anyString(), anyString()); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() fails when no track is playing") + void addCurrentTrackToFavorites_failsWhenNoTrackPlaying() + { + fixture.withDJPermission() + .withNoTrack(); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + output.assertErrorMessageContains("no track currently playing"); + verify(fixture.getPlaylistLoader(), never()).appendItemIfAbsentResult(anyString(), anyString()); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() fails for unsupported favorite entry") + void addCurrentTrackToFavorites_failsForUnsupportedEntry() + { + fixture.withDJPermission() + .withPlayingTrack(); + AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 180000L, "id", false, "relative/path.mp3"); + when(fixture.getCurrentTrack().getInfo()).thenReturn(info); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + output.assertErrorMessageContains("Only URI references or absolute file paths for this OS"); + verify(fixture.getPlaylistLoader(), never()).appendItemIfAbsentResult(anyString(), anyString()); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() accepts OS-native absolute path") + void addCurrentTrackToFavorites_acceptsOsNativeAbsolutePath() + { + fixture.withDJPermission() + .withPlayingTrack(); + String absolutePath = sampleNativeAbsolutePath(); + AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 180000L, "id", false, absolutePath); + when(fixture.getCurrentTrack().getInfo()).thenReturn(info); + when(fixture.getPlaylistLoader().appendItemIfAbsentResult("favorites", absolutePath)) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success( + com.jagrosh.jmusicbot.playlist.PlaylistLoader.AppendIfAbsentResult.appended())); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + verify(fixture.getPlaylistLoader()).appendItemIfAbsentResult("favorites", absolutePath); + verify(fixture.getAudioHandler()).markCurrentTrackFavorited(absolutePath); + output.assertNowPlayingEdited(); + output.assertSuccessMessage("Added Title to Favorites"); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() rejects non-native absolute path format") + void addCurrentTrackToFavorites_rejectsNonNativeAbsolutePathFormat() + { + fixture.withDJPermission() + .withPlayingTrack(); + String nonNativePath = sampleNonNativeAbsolutePath(); + AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 180000L, "id", false, nonNativePath); + when(fixture.getCurrentTrack().getInfo()).thenReturn(info); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + output.assertErrorMessageContains("Only URI references or absolute file paths for this OS"); + verify(fixture.getPlaylistLoader(), never()).appendItemIfAbsentResult(anyString(), anyString()); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() creates favorites playlist when missing and writes URI") + void addCurrentTrackToFavorites_createsMissingPlaylistAndWrites() + { + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getPlaylistLoader().appendItemIfAbsentResult("favorites", "https://example.com/track")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success( + com.jagrosh.jmusicbot.playlist.PlaylistLoader.AppendIfAbsentResult.appended())); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + verify(fixture.getPlaylistLoader()).appendItemIfAbsentResult("favorites", "https://example.com/track"); + verify(fixture.getAudioHandler()).markCurrentTrackFavorited("https://example.com/track"); + output.assertNowPlayingEdited(); + output.assertSuccessMessage("Added Test Track to Favorites"); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() appends to existing favorites playlist") + void addCurrentTrackToFavorites_appendsToExistingPlaylist() + { + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getPlaylistLoader().appendItemIfAbsentResult("favorites", "https://example.com/track")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success( + com.jagrosh.jmusicbot.playlist.PlaylistLoader.AppendIfAbsentResult.appended())); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + verify(fixture.getPlaylistLoader()).appendItemIfAbsentResult("favorites", "https://example.com/track"); + verify(fixture.getAudioHandler()).markCurrentTrackFavorited("https://example.com/track"); + output.assertNowPlayingEdited(); + output.assertSuccessMessage("Added Test Track to Favorites"); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() warns and skips write when already favorited") + void addCurrentTrackToFavorites_warnsAndSkipsWriteWhenAlreadyFavorited() + { + fixture.withDJPermission() + .withPlayingTrack(); + when(fixture.getPlaylistLoader().appendItemIfAbsentResult("favorites", "https://example.com/track")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success( + com.jagrosh.jmusicbot.playlist.PlaylistLoader.AppendIfAbsentResult.alreadyPresent())); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + verify(fixture.getPlaylistLoader()).appendItemIfAbsentResult("favorites", "https://example.com/track"); + verify(fixture.getAudioHandler()).markCurrentTrackFavorited("https://example.com/track"); + output.assertNowPlayingEdited(); + output.assertWarningMessage("This track is already favorited"); + } + + @Test + @DisplayName("addCurrentTrackToFavorites() surfaces fail-fast storage configuration errors") + void addCurrentTrackToFavorites_surfacesStorageConfigurationErrors() + { + fixture.withDJPermission() + .withPlayingTrack(); + var configError = com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistError.of( + com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistErrorType.INVALID_CONFIG, + "Playlists folder is not configured.", null, null); + when(fixture.getPlaylistLoader().appendItemIfAbsentResult("favorites", "https://example.com/track")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.failure(configError)); + + musicService.addCurrentTrackToFavorites(fixture.getGuild(), fixture.getMember(), output); + + output.assertErrorMessageContains("misconfigured"); + output.assertErrorMessageContains("(not configured)"); + } + } + + // ==================== History Operation Tests ==================== + + @Nested + @DisplayName("History Operations") + class HistoryOperationTests + { + @Test + @DisplayName("getHistoryInfo() passes non-ASCII track titles through verbatim") + void getHistoryInfo_passesNonAsciiTitlesThroughVerbatim() + { + // Title decoding is handled at the source-manager level; the app no longer + // transforms titles, so they must appear in history exactly as provided. + String cyrillicTitle = "Привет мир"; + + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo(cyrillicTitle, "Artist", 180000L, "id-1", false, "https://example.com/1"); + when(track.getInfo()).thenReturn(info); + when(track.getDuration()).thenReturn(180000L); + when(track.isSeekable()).thenReturn(false); + when(track.getUserData(RequestMetadata.class)).thenReturn(RequestMetadata.EMPTY); + + QueuedTrack queuedTrack = new QueuedTrack(track, RequestMetadata.EMPTY); + when(fixture.getAudioHandler().getPreviousTracks()).thenReturn(List.of(queuedTrack)); + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(fixture.getQueue().getHistory()).thenReturn(history); + when(history.getMaxSize()).thenReturn(40); + + MusicService.HistoryInfo historyInfo = musicService.getHistoryInfo(fixture.getGuild(), fixture.getJda()); + + assertNotNull(historyInfo); + assertEquals(1, historyInfo.tracks.length); + assertTrue(historyInfo.tracks[0].contains(cyrillicTitle)); + } + + @Test + @DisplayName("getQueueInfo() keeps ASCII titles unchanged") + void getQueueInfo_keepsAsciiTitlesUnchanged() + { + String asciiTitle = "Rick Astley - Never Gonna Give You Up"; + + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo(asciiTitle, "Artist", 180000L, "id-2", false, "https://example.com/2"); + when(track.getInfo()).thenReturn(info); + when(track.getDuration()).thenReturn(180000L); + when(track.isSeekable()).thenReturn(false); + when(track.getUserData(RequestMetadata.class)).thenReturn(RequestMetadata.EMPTY); + + QueuedTrack queuedTrack = new QueuedTrack(track, RequestMetadata.EMPTY); + when(fixture.getQueue().getList()).thenReturn(List.of(queuedTrack)); + + MusicService.QueueInfo queueInfo = musicService.getQueueInfo(fixture.getGuild(), fixture.getJda()); + + assertNotNull(queueInfo); + assertEquals(1, queueInfo.tracks.length); + assertTrue(queueInfo.tracks[0].contains(asciiTitle)); + } + + @Test + @DisplayName("getHistoryInfo() returns null when no handler") + void getHistoryInfo_returnsNullWhenNoHandler() + { + when(fixture.getAudioManager().getSendingHandler()).thenReturn(null); + + MusicService.HistoryInfo info = musicService.getHistoryInfo(fixture.getGuild(), fixture.getJda()); + + assertNull(info); + } + + @Test + @DisplayName("queueFromHistory() replies error when no handler") + void queueFromHistory_repliesErrorWhenNoHandler() + { + when(fixture.getAudioManager().getSendingHandler()).thenReturn(null); + + musicService.queueFromHistory(fixture.getGuild(), fixture.getMember(), 1, + fixture.getTextChannel(), output); + + output.assertErrorMessageContains("no player"); + } + + @Test + @DisplayName("queueFromHistory() replies error when history empty") + void queueFromHistory_repliesErrorWhenHistoryEmpty() + { + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(40); + when(history.isEmpty()).thenReturn(true); + when(fixture.getQueue().getHistory()).thenReturn(history); + when(fixture.getAudioHandler().getQueue()).thenReturn(fixture.getQueue()); + + musicService.queueFromHistory(fixture.getGuild(), fixture.getMember(), 1, + fixture.getTextChannel(), output); + + output.assertErrorMessageContains("empty"); + } + + @Test + @DisplayName("queueFromHistory() replies error when position out of range") + void queueFromHistory_repliesErrorWhenPositionOutOfRange() + { + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(40); + when(history.isEmpty()).thenReturn(false); + when(history.size()).thenReturn(3); + when(fixture.getQueue().getHistory()).thenReturn(history); + when(fixture.getAudioHandler().getQueue()).thenReturn(fixture.getQueue()); + + musicService.queueFromHistory(fixture.getGuild(), fixture.getMember(), 10, + fixture.getTextChannel(), output); + + output.assertErrorMessageContains("1 and 3"); + } + + @Test + @DisplayName("queueAllFromHistory() requests reconcile when tracks added and player active") + void queueAllFromHistory_requestsReconcile_whenTracksAddedAndPlayerActive() + { + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(40); + when(history.isEmpty()).thenReturn(false); + when(fixture.getQueue().getHistory()).thenReturn(history); + + QueuedTrack qtOne = mock(QueuedTrack.class); + QueuedTrack qtTwo = mock(QueuedTrack.class); + AudioTrack originalTrackOne = mock(AudioTrack.class); + AudioTrack originalTrackTwo = mock(AudioTrack.class); + AudioTrack clonedTrackOne = mock(AudioTrack.class); + AudioTrack clonedTrackTwo = mock(AudioTrack.class); + AudioTrackInfo infoOne = new AudioTrackInfo("History Song 1", "Artist", 180000, "id-1", false, "https://example.com/1"); + AudioTrackInfo infoTwo = new AudioTrackInfo("History Song 2", "Artist", 180000, "id-2", false, "https://example.com/2"); + when(qtOne.getTrack()).thenReturn(originalTrackOne); + when(qtTwo.getTrack()).thenReturn(originalTrackTwo); + when(originalTrackOne.makeClone()).thenReturn(clonedTrackOne); + when(originalTrackTwo.makeClone()).thenReturn(clonedTrackTwo); + when(originalTrackOne.getInfo()).thenReturn(infoOne); + when(originalTrackTwo.getInfo()).thenReturn(infoTwo); + when(originalTrackOne.getIdentifier()).thenReturn("id-1"); + when(originalTrackTwo.getIdentifier()).thenReturn("id-2"); + when(history.getList()).thenReturn(List.of(qtOne, qtTwo)); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(mock(AudioTrack.class)); + + musicService.queueAllFromHistory(fixture.getGuild(), fixture.getMember(), fixture.getTextChannel(), output); + + verify(fixture.getAudioHandler(), times(2)).addTrack(any(QueuedTrack.class)); + verify(fixture.getQueue()).clearHistory(); + verify(fixture.getQueue(), never()).removeFromHistoryAt(anyInt()); + verify(fixture.getQueue(), never()).removeFromHistoryFirstMatch(any()); + verify(fixture.getNowPlayingHandler()).requestReconcile(eq(fixture.getGuild().getIdLong()), eq("history-queueall-loaded")); + output.assertSuccessMessage("Added **2** track(s) to the queue."); + } + + @Test + @DisplayName("queueAllFromHistory() preserves first replayed track in history after idle queue-all and skip progression") + void queueAllFromHistory_preservesFirstReplayedTrackInHistory_afterIdleQueueAllAndSkipProgression() + { + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(40); + when(fixture.getQueue().getHistory()).thenReturn(history); + + QueuedTrack qtOne = mock(QueuedTrack.class); + QueuedTrack qtTwo = mock(QueuedTrack.class); + QueuedTrack qtThree = mock(QueuedTrack.class); + AudioTrack originalTrackOne = mock(AudioTrack.class); + AudioTrack originalTrackTwo = mock(AudioTrack.class); + AudioTrack originalTrackThree = mock(AudioTrack.class); + AudioTrack clonedTrackOne = mock(AudioTrack.class); + AudioTrack clonedTrackTwo = mock(AudioTrack.class); + AudioTrack clonedTrackThree = mock(AudioTrack.class); + AudioTrackInfo infoOne = new AudioTrackInfo("History Song 1", "Artist", 180000, "id-1", false, "https://example.com/1"); + AudioTrackInfo infoTwo = new AudioTrackInfo("History Song 2", "Artist", 180000, "id-2", false, "https://example.com/2"); + AudioTrackInfo infoThree = new AudioTrackInfo("History Song 3", "Artist", 180000, "id-3", false, "https://example.com/3"); + when(qtOne.getTrack()).thenReturn(originalTrackOne); + when(qtTwo.getTrack()).thenReturn(originalTrackTwo); + when(qtThree.getTrack()).thenReturn(originalTrackThree); + when(originalTrackOne.makeClone()).thenReturn(clonedTrackOne); + when(originalTrackTwo.makeClone()).thenReturn(clonedTrackTwo); + when(originalTrackThree.makeClone()).thenReturn(clonedTrackThree); + when(originalTrackOne.getInfo()).thenReturn(infoOne); + when(originalTrackTwo.getInfo()).thenReturn(infoTwo); + when(originalTrackThree.getInfo()).thenReturn(infoThree); + when(originalTrackOne.getIdentifier()).thenReturn("id-1"); + when(originalTrackTwo.getIdentifier()).thenReturn("id-2"); + when(originalTrackThree.getIdentifier()).thenReturn("id-3"); + + List<QueuedTrack> liveHistory = new ArrayList<>(List.of(qtOne, qtTwo, qtThree)); + when(history.isEmpty()).thenAnswer(invocation -> liveHistory.isEmpty()); + when(history.getList()).thenAnswer(invocation -> List.copyOf(liveHistory)); + doAnswer(invocation -> + { + liveHistory.clear(); + return null; + }).when(fixture.getQueue()).clearHistory(); + + AtomicInteger addCalls = new AtomicInteger(); + doAnswer(invocation -> + { + // Simulate idle addTrack starting playback and onTrackStart re-adding first entry to history. + if (addCalls.getAndIncrement() == 0) + { + liveHistory.add(0, qtOne); + return -1; + } + return addCalls.get(); + }).when(fixture.getAudioHandler()).addTrack(any(QueuedTrack.class)); + when(fixture.getAudioPlayer().getPlayingTrack()).thenReturn(null); + + musicService.queueAllFromHistory(fixture.getGuild(), fixture.getMember(), fixture.getTextChannel(), output); + + verify(fixture.getNowPlayingHandler(), never()).requestReconcile(anyLong(), anyString()); + verify(fixture.getAudioHandler(), times(3)).addTrack(any(QueuedTrack.class)); + verify(fixture.getQueue()).clearHistory(); + verify(fixture.getQueue(), never()).removeFromHistoryAt(anyInt()); + verify(fixture.getQueue(), never()).removeFromHistoryFirstMatch(any()); + output.assertSuccessMessage("Added **3** track(s) to the queue."); + assertEquals(1, liveHistory.size(), "Expected currently playing replayed track to remain in history"); + assertSame(qtOne, liveHistory.get(0), "Expected first replayed track at history front"); + + // Simulate skipping through the remaining queued tracks; each started track should appear in history. + liveHistory.add(0, qtTwo); + liveHistory.add(0, qtThree); + assertEquals(3, liveHistory.size(), "Expected all replayed tracks to be retained in history"); + } + + @Test + @DisplayName("playFromHistoryNow() replies error when history empty") + void playFromHistoryNow_repliesErrorWhenHistoryEmpty() + { + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(40); + when(history.isEmpty()).thenReturn(true); + when(fixture.getQueue().getHistory()).thenReturn(history); + when(fixture.getAudioHandler().getQueue()).thenReturn(fixture.getQueue()); + + musicService.playFromHistoryNow(fixture.getGuild(), fixture.getMember(), 1, + fixture.getTextChannel(), output); + + output.assertErrorMessageContains("empty"); + } + + @Test + @DisplayName("queueFromHistory() replies disabled when history feature disabled") + void queueFromHistory_repliesDisabledWhenHistoryDisabled() + { + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(0); + when(fixture.getQueue().getHistory()).thenReturn(history); + when(fixture.getAudioHandler().getQueue()).thenReturn(fixture.getQueue()); + + musicService.queueFromHistory(fixture.getGuild(), fixture.getMember(), 1, + fixture.getTextChannel(), output); + + output.assertErrorMessageContains("disabled by config"); + } + + @Test + @DisplayName("getHistoryInfo() marks disabled when max history size is zero") + void getHistoryInfo_marksDisabledWhenMaxSizeZero() + { + when(fixture.getAudioHandler().getPreviousTracks()).thenReturn(List.of()); + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(0); + when(fixture.getQueue().getHistory()).thenReturn(history); + + MusicService.HistoryInfo info = musicService.getHistoryInfo(fixture.getGuild(), fixture.getJda()); + + assertNotNull(info); + assertTrue(info.isDisabled()); + assertEquals(0, info.maxSize); + assertTrue(info.isEmpty()); + } + + @Test + @DisplayName("saveHistoryAsPlaylist() replies error when not DJ and not owner") + void saveHistoryAsPlaylist_repliesErrorWhenNotAuthorized() + { + fixture.withoutDJPermission(); + when(fixture.getConfig().getOwnerId()).thenReturn(999999L); + + musicService.saveHistoryAsPlaylist(fixture.getGuild(), fixture.getMember(), "myplaylist", output); + + output.assertErrorMessageContains("DJ or the bot owner"); + } + + @Test + @DisplayName("saveHistoryAsPlaylist() replies error when playlist already exists") + void saveHistoryAsPlaylist_repliesErrorWhenPlaylistExists() + { + fixture.withDJPermission(); + PlaybackHistory<QueuedTrack> history = mock(PlaybackHistory.class); + when(history.getMaxSize()).thenReturn(40); + when(fixture.getQueue().getHistory()).thenReturn(history); + QueuedTrack qt = mock(QueuedTrack.class); + AudioTrack track = mock(AudioTrack.class); + AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 180000, "id", false, "https://example.com/1"); + when(track.getInfo()).thenReturn(info); + when(qt.getTrack()).thenReturn(track); + when(fixture.getAudioHandler().getPreviousTracks()).thenReturn(List.of(qt)); + when(fixture.getBot().getPlaylistLoader().getPlaylistResult("existing")) + .thenReturn(com.jagrosh.jmusicbot.playlist.PlaylistLoader.PlaylistResult.success( + mock(com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist.class))); + + musicService.saveHistoryAsPlaylist(fixture.getGuild(), fixture.getMember(), "existing", output); + + output.assertErrorMessageContains("already exists"); + } + } + + private static boolean isWindowsRuntime() + { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + } + + private static String sampleNativeAbsolutePath() + { + return isWindowsRuntime() ? "C:\\music\\track.mp3" : "/var/music/track.mp3"; + } + + private static String sampleNonNativeAbsolutePath() + { + return isWindowsRuntime() ? "/var/music/track.mp3" : "C:\\music\\track.mp3"; + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/service/PlayerServiceTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/service/PlayerServiceTest.java new file mode 100644 index 0000000..01e2c75 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/service/PlayerServiceTest.java @@ -0,0 +1,55 @@ +package com.jagrosh.jmusicbot.unit.service; + +import com.jagrosh.jmusicbot.TestBase; +import com.jagrosh.jmusicbot.service.MusicService; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class PlayerServiceTest extends TestBase { + + private MusicService.OutputAdapter output = mock(MusicService.OutputAdapter.class); + + private MusicService musicService; + + @BeforeEach + @Override + public void setUp() { + super.setUp(); + musicService = new MusicService(bot); + } + + @Test + public void testPlayResumeWhenPaused() { + when(audioPlayer.getPlayingTrack()).thenReturn(audioTrack); + when(audioPlayer.isPaused()).thenReturn(true); + AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 1000, "identifier", true, "uri"); + when(audioTrack.getInfo()).thenReturn(info); + + musicService.play(guild, member, null, textChannel, output); + + verify(audioPlayer).setPaused(false); + verify(output).replySuccess(anyString()); + } + + @Test + public void testPlayWithArgsCallsLoadItem() { + String args = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; // this is the best song ever + + musicService.play(guild, member, args, textChannel, output); + + verify(playerManager).loadItemOrdered(eq(guild), eq(args), any()); + } + + @Test + public void testPlayEmptyArgsShowsHelpWhenNotPaused() { + when(audioPlayer.getPlayingTrack()).thenReturn(null); + + musicService.play(guild, member, null, textChannel, output); + + verify(output).onShowHelp(); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsManagerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsManagerTest.java new file mode 100644 index 0000000..be778aa --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsManagerTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2018 John Grosh <john.a.grosh@gmail.com>. + * + * 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.unit.settings; + +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for RepeatMode and QueueType enum parsing, specifically testing + * graceful handling of invalid enum values via the valueOfOrDefault methods. + */ +public class SettingsManagerTest +{ + // RepeatMode tests + + @Test + void testValidRepeatModeValues() + { + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault("OFF", RepeatMode.OFF)); + assertEquals(RepeatMode.ALL, RepeatMode.valueOfOrDefault("ALL", RepeatMode.OFF)); + assertEquals(RepeatMode.SINGLE, RepeatMode.valueOfOrDefault("SINGLE", RepeatMode.OFF)); + } + + @Test + void testInvalidRepeatModeReturnsDefault() + { + // Invalid values should return the default + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault("INVALID_MODE", RepeatMode.OFF)); + assertEquals(RepeatMode.ALL, RepeatMode.valueOfOrDefault("GARBAGE", RepeatMode.ALL)); + assertEquals(RepeatMode.SINGLE, RepeatMode.valueOfOrDefault("CORRUPTED", RepeatMode.SINGLE)); + } + + @Test + void testNullRepeatModeReturnsDefault() + { + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault(null, RepeatMode.OFF)); + assertEquals(RepeatMode.ALL, RepeatMode.valueOfOrDefault(null, RepeatMode.ALL)); + } + + @Test + void testEmptyRepeatModeReturnsDefault() + { + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault("", RepeatMode.OFF)); + assertEquals(RepeatMode.ALL, RepeatMode.valueOfOrDefault("", RepeatMode.ALL)); + } + + @Test + void testRepeatModeCaseSensitive() + { + // Enum values are case-sensitive - lowercase should be treated as invalid + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault("all", RepeatMode.OFF)); + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault("All", RepeatMode.OFF)); + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault("single", RepeatMode.OFF)); + } + + @Test + void testRepeatModeWhitespaceReturnsDefault() + { + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault(" ", RepeatMode.OFF)); + assertEquals(RepeatMode.OFF, RepeatMode.valueOfOrDefault(" ALL ", RepeatMode.OFF)); + } + + @Test + void testRepeatModeDoesNotThrow() + { + assertDoesNotThrow(() -> RepeatMode.valueOfOrDefault("TOTALLY_INVALID", RepeatMode.OFF)); + assertDoesNotThrow(() -> RepeatMode.valueOfOrDefault(null, RepeatMode.OFF)); + assertDoesNotThrow(() -> RepeatMode.valueOfOrDefault("", RepeatMode.OFF)); + } + + // QueueType tests + + @Test + void testValidQueueTypeValues() + { + assertEquals(QueueType.LINEAR, QueueType.valueOfOrDefault("LINEAR", QueueType.FAIR)); + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault("FAIR", QueueType.LINEAR)); + } + + @Test + void testInvalidQueueTypeReturnsDefault() + { + // Invalid values should return the default + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault("INVALID_QUEUE", QueueType.FAIR)); + assertEquals(QueueType.LINEAR, QueueType.valueOfOrDefault("NONSENSE", QueueType.LINEAR)); + } + + @Test + void testNullQueueTypeReturnsDefault() + { + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault(null, QueueType.FAIR)); + assertEquals(QueueType.LINEAR, QueueType.valueOfOrDefault(null, QueueType.LINEAR)); + } + + @Test + void testEmptyQueueTypeReturnsDefault() + { + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault("", QueueType.FAIR)); + assertEquals(QueueType.LINEAR, QueueType.valueOfOrDefault("", QueueType.LINEAR)); + } + + @Test + void testQueueTypeCaseSensitive() + { + // Enum values are case-sensitive - lowercase should be treated as invalid + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault("linear", QueueType.FAIR)); + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault("Linear", QueueType.FAIR)); + } + + @Test + void testQueueTypeWhitespaceReturnsDefault() + { + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault("\t", QueueType.FAIR)); + assertEquals(QueueType.FAIR, QueueType.valueOfOrDefault(" LINEAR ", QueueType.FAIR)); + } + + @Test + void testQueueTypeDoesNotThrow() + { + assertDoesNotThrow(() -> QueueType.valueOfOrDefault("COMPLETELY_WRONG", QueueType.FAIR)); + assertDoesNotThrow(() -> QueueType.valueOfOrDefault(null, QueueType.FAIR)); + assertDoesNotThrow(() -> QueueType.valueOfOrDefault("", QueueType.FAIR)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java new file mode 100644 index 0000000..a54e04a --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java @@ -0,0 +1,171 @@ +package com.jagrosh.jmusicbot.unit.settings; + +import com.jagrosh.jmusicbot.settings.QueueType; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.settings.NowPlayingButtonsMode; +import com.jagrosh.jmusicbot.settings.NowPlayingLayoutMode; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.settings.SettingsManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class SettingsTest { + + private SettingsManager manager; + private Settings settings; + + @BeforeEach + public void setUp() { + // Mock manager to prevent NPE when setters call writeSettings() + manager = mock(SettingsManager.class); + settings = new Settings( + manager, + 0, + 0, + 0, + 100, + null, + RepeatMode.OFF, + null, + -1, + QueueType.FAIR, + NowPlayingLayoutMode.INHERIT, + NowPlayingButtonsMode.INHERIT + ); + } + + @Test + public void testDefaultValues() { + assertEquals(100, settings.getVolume()); + assertEquals(RepeatMode.OFF, settings.getRepeatMode()); + assertEquals(QueueType.FAIR, settings.getQueueType()); + assertNull(settings.getPrefix()); + assertNull(settings.getDefaultPlaylist()); + assertEquals(-1, settings.getSkipRatio()); + assertTrue(settings.getPrefixes().isEmpty()); + } + + @Test + public void testSetVolume() { + settings.setVolume(50); + assertEquals(50, settings.getVolume()); + + settings.setVolume(0); + assertEquals(0, settings.getVolume()); + + settings.setVolume(150); + assertEquals(150, settings.getVolume()); + } + + @Test + public void testSetRepeatMode() { + settings.setRepeatMode(RepeatMode.ALL); + assertEquals(RepeatMode.ALL, settings.getRepeatMode()); + + settings.setRepeatMode(RepeatMode.SINGLE); + assertEquals(RepeatMode.SINGLE, settings.getRepeatMode()); + + settings.setRepeatMode(RepeatMode.OFF); + assertEquals(RepeatMode.OFF, settings.getRepeatMode()); + } + + @Test + public void testSetQueueType() { + settings.setQueueType(QueueType.LINEAR); + assertEquals(QueueType.LINEAR, settings.getQueueType()); + + settings.setQueueType(QueueType.FAIR); + assertEquals(QueueType.FAIR, settings.getQueueType()); + } + + @Test + public void testSetPrefix() { + settings.setPrefix("!"); + assertEquals("!", settings.getPrefix()); + assertTrue(settings.getPrefixes().contains("!")); + assertEquals(1, settings.getPrefixes().size()); + + settings.setPrefix("!!"); + assertEquals("!!", settings.getPrefix()); + assertTrue(settings.getPrefixes().contains("!!")); + } + + @Test + public void testSetPrefixNull() { + settings.setPrefix("!"); + settings.setPrefix(null); + assertNull(settings.getPrefix()); + assertTrue(settings.getPrefixes().isEmpty()); + } + + @Test + public void testSetDefaultPlaylist() { + settings.setDefaultPlaylist("my_playlist"); + assertEquals("my_playlist", settings.getDefaultPlaylist()); + + settings.setDefaultPlaylist(null); + assertNull(settings.getDefaultPlaylist()); + } + + @Test + public void testSetSkipRatio() { + settings.setSkipRatio(0.5); + assertEquals(0.5, settings.getSkipRatio()); + + settings.setSkipRatio(0.0); + assertEquals(0.0, settings.getSkipRatio()); + + settings.setSkipRatio(1.0); + assertEquals(1.0, settings.getSkipRatio()); + } + + @Test + public void testConstructorWithStringIds() { + Settings s = new Settings( + manager, + "123", + "456", + "789", + 75, + "playlist", + RepeatMode.ALL, + "?", + 0.6, + QueueType.LINEAR, + NowPlayingLayoutMode.INHERIT, + NowPlayingButtonsMode.INHERIT + ); + + assertEquals(75, s.getVolume()); + assertEquals("playlist", s.getDefaultPlaylist()); + assertEquals(RepeatMode.ALL, s.getRepeatMode()); + assertEquals("?", s.getPrefix()); + assertEquals(0.6, s.getSkipRatio()); + assertEquals(QueueType.LINEAR, s.getQueueType()); + } + + @Test + public void testConstructorWithInvalidStringIds() { + // Invalid IDs should default to 0 without throwing + Settings s = new Settings( + manager, + "invalid", + "also_invalid", + "nope", + 100, + null, + RepeatMode.OFF, + null, + -1, + QueueType.FAIR, + NowPlayingLayoutMode.INHERIT, + NowPlayingButtonsMode.INHERIT + ); + + assertEquals(100, s.getVolume()); + assertEquals(RepeatMode.OFF, s.getRepeatMode()); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/FormatUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/FormatUtilTest.java new file mode 100644 index 0000000..a454947 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/FormatUtilTest.java @@ -0,0 +1,30 @@ +package com.jagrosh.jmusicbot.unit.utils; + +import com.jagrosh.jmusicbot.utils.FormatUtil; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class FormatUtilTest { + + @Test + public void testFormatUsername() { + assertEquals("User#1234", FormatUtil.formatUsername("User", "1234")); + assertEquals("User", FormatUtil.formatUsername("User", "0000")); + assertEquals("User", FormatUtil.formatUsername("User", null)); + } + + @Test + public void testVolumeIcon() { + assertEquals("\uD83D\uDD07", FormatUtil.volumeIcon(0)); + assertEquals("\uD83D\uDD08", FormatUtil.volumeIcon(20)); + assertEquals("\uD83D\uDD09", FormatUtil.volumeIcon(50)); + assertEquals("\uD83D\uDD0A", FormatUtil.volumeIcon(80)); + } + + @Test + public void testFilter() { + assertEquals("safe", FormatUtil.filter("safe")); + assertEquals("@\u0435veryone", FormatUtil.filter("@everyone")); + assertEquals("@h\u0435re", FormatUtil.filter("@here")); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/MessageFormatterTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/MessageFormatterTest.java new file mode 100644 index 0000000..a16c1ae --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/MessageFormatterTest.java @@ -0,0 +1,417 @@ +package com.jagrosh.jmusicbot.unit.utils; + +import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.audio.AudioHandler; +import com.jagrosh.jmusicbot.audio.NowPlayingInfo; +import com.jagrosh.jmusicbot.audio.RequestMetadata; +import com.jagrosh.jmusicbot.settings.RepeatMode; +import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.settings.SettingsManager; +import com.jagrosh.jmusicbot.utils.MessageFormatter; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import net.dv8tion.jda.api.components.buttons.ButtonStyle; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("MessageFormatter Tests") +class MessageFormatterTest +{ + @Test + @DisplayName("buildNowPlayingMessage() passes author text through verbatim in full embed") + void buildNowPlayingMessage_passesAuthorTextThroughVerbatimInFullEmbed() + { + String author = "МР. CREDO [Этой]"; + + MessageCreateData message = buildNowPlayingMessage("Test Title", author, false, false, "", + RepeatMode.OFF, 0, false, 0L, false, false, "id-1"); + MessageEmbed embed = getSingleEmbed(message); + MessageEmbed.Field authorField = getField(embed, "Author"); + assertNotNull(authorField); + assertEquals(author, authorField.getValue()); + } + + @Test + @DisplayName("buildNowPlayingMessage() minimal layout without progress bar still shows elapsed and total time") + void buildNowPlayingMessage_minimalWithoutProgressBar_stillShowsElapsedAndTotalTime() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", true, false, "", + RepeatMode.OFF, 0, false, 0L, false, false, "id-1"); + MessageEmbed embed = getSingleEmbed(message); + assertNotNull(embed.getDescription()); + assertTrue(embed.getDescription().contains("`[")); + assertFalse(embed.getDescription().contains("▬")); + } + + @Test + @DisplayName("buildNowPlayingMessage() minimal layout shows progress bar when enabled") + void buildNowPlayingMessage_minimalShowsProgressBarWhenEnabled() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", true, true, "", + RepeatMode.OFF, 0, false, 0L, false, false, "id-1"); + MessageEmbed embed = getSingleEmbed(message); + assertNotNull(embed.getDescription()); + assertTrue(embed.getDescription().contains("▬")); + assertTrue(embed.getDescription().contains("`[")); + } + + @Test + @DisplayName("buildNowPlayingMessage() minimal layout surfaces source/queue/repeat in description and footerInfo in footer") + void buildNowPlayingMessage_minimalLayout_surfacesMetadataInDescriptionAndFooterInfoInFooter() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", true, false, "Playing next song.", + RepeatMode.ALL, 2, false, 0L, false, false, "id-1"); + MessageEmbed embed = getSingleEmbed(message); + assertNotNull(embed.getDescription()); + assertTrue(embed.getDescription().contains("**Source:** youtube • 2 songs queued\n**Volume:** 50% • **Repeat:** All")); + assertNull(embed.getFooter()); + } + + @Test + @DisplayName("buildNoMusicPlayingMessage() full and minimal obey progress bar toggle") + void buildNoMusicPlayingMessage_obeysProgressBarToggle() + { + String fullDisabled = noMusicDescription(false, false); + String fullEnabled = noMusicDescription(false, true); + String minimalDisabled = noMusicDescription(true, false); + String minimalEnabled = noMusicDescription(true, true); + + assertFalse(fullDisabled.contains("▬")); + assertTrue(fullEnabled.contains("▬")); + assertFalse(minimalDisabled.contains("▬")); + assertTrue(minimalEnabled.contains("▬")); + assertTrue(fullDisabled.contains(AudioHandler.STOP_EMOJI)); + } + + @Test + @DisplayName("buildNowPlayingMessage() uses namespaced now-playing button IDs") + void buildNowPlayingMessage_usesNamespacedNowPlayingButtonIds() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", false, false, "", + RepeatMode.OFF, 0, false, 0L, true); + List<String> buttonIds = message.getComponents().stream() + .flatMap(row -> row.asActionRow().getButtons().stream()) + .map(net.dv8tion.jda.api.components.buttons.Button::getCustomId) + .toList(); + assertTrue(buttonIds.contains("np_previous")); + assertTrue(buttonIds.contains("np_pause")); + assertTrue(buttonIds.contains("np_skip")); + assertTrue(buttonIds.contains("np_stop")); + assertTrue(buttonIds.contains("np_shuffle")); + assertTrue(buttonIds.contains("np_repeat")); + assertTrue(buttonIds.contains("np_favorite")); + assertTrue(buttonIds.contains("np_voldown")); + assertTrue(buttonIds.contains("np_volup")); + } + + @Test + @DisplayName("buildNowPlayingMessage() full layout includes thumbnail when images enabled") + void buildNowPlayingMessage_fullLayout_includesThumbnailWhenImagesEnabled() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.OFF, 2, false, 120_000L, false, true, "id-1"); + MessageEmbed embed = getSingleEmbed(message); + assertEquals("https://img.youtube.com/vi/id-1/mqdefault.jpg", embed.getThumbnail().getUrl()); + assertNotNull(embed.getDescription()); + assertTrue(embed.getDescription().contains("**Source:** youtube")); + assertFalse(embed.getDescription().contains("songs queued")); + assertFalse(embed.getDescription().contains("**Volume:**")); + assertFalse(embed.getDescription().contains("**Repeat:**")); + MessageEmbed.Field authorField = getField(embed, "Author"); + assertNotNull(authorField); + assertEquals("Test Author", authorField.getValue()); + } + + @Test + @DisplayName("buildNowPlayingMessage() full layout omits thumbnail when images disabled") + void buildNowPlayingMessage_fullLayout_omitsThumbnailWhenImagesDisabled() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.OFF, 2, false, 120_000L, false, false, "id-1"); + MessageEmbed embed = getSingleEmbed(message); + assertNull(embed.getThumbnail()); + } + + @Test + @DisplayName("buildNowPlayingMessage() full layout uses original duration/queue/volume fields") + void buildNowPlayingMessage_fullLayout_usesOriginalStatsFields() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", false, false, "", + RepeatMode.OFF, 3, false, 120_000L, false); + MessageEmbed embed = getSingleEmbed(message); + assertNull(getField(embed, "Info")); + MessageEmbed.Field durationField = getField(embed, "Duration"); + MessageEmbed.Field queueField = getField(embed, "Queue"); + MessageEmbed.Field volumeField = getField(embed, "Volume"); + assertNotNull(durationField); + assertNotNull(queueField); + assertNotNull(volumeField); + assertEquals("04:03", durationField.getValue()); + assertEquals("3", queueField.getValue()); + assertEquals("50%", volumeField.getValue()); + } + + @Test + @DisplayName("buildNowPlayingMessage() full layout progress bar follows toggle") + void buildNowPlayingMessage_fullLayout_progressBarFollowsToggle() + { + MessageCreateData noProgress = buildNowPlayingMessage("Test Title", "Test Author", false, false, "", + RepeatMode.OFF, 0, false, 20_000L, false); + MessageCreateData withProgress = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.OFF, 0, false, 20_000L, false); + + String noProgressDescription = getSingleEmbed(noProgress).getDescription(); + String withProgressDescription = getSingleEmbed(withProgress).getDescription(); + assertNotNull(noProgressDescription); + assertNotNull(withProgressDescription); + assertTrue(noProgressDescription.contains("`[")); + assertFalse(noProgressDescription.contains("▬")); + assertTrue(withProgressDescription.contains("`[")); + assertTrue(withProgressDescription.contains("▬")); + } + + @Test + @DisplayName("buildNowPlayingMessage() now-playing buttons expose clearer labels and styles") + void buildNowPlayingMessage_buttons_useClearLabelsAndStyles() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.OFF, 5, false, 20_000L, true); + Button pause = getButton(message, "np_pause"); + Button stop = getButton(message, "np_stop"); + Button repeat = getButton(message, "np_repeat"); + assertNotNull(pause); + assertNotNull(stop); + assertNotNull(repeat); + assertNotNull(getButton(message, "np_favorite")); + assertEquals("Pause", pause.getLabel()); + assertEquals(ButtonStyle.PRIMARY, pause.getStyle()); + assertEquals("Stop", stop.getLabel()); + assertEquals(ButtonStyle.DANGER, stop.getStyle()); + assertEquals("Repeat", repeat.getLabel()); + } + + @Test + @DisplayName("buildNowPlayingMessage() favorite button style reflects favorited state") + void buildNowPlayingMessage_favoriteButtonStyleReflectsFavoritedState() + { + MessageCreateData notFavorited = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.OFF, 1, false, 2_000L, true, false, "id-1", 0, 50, false); + assertEquals(ButtonStyle.SECONDARY, getButton(notFavorited, "np_favorite").getStyle()); + + MessageCreateData favorited = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.OFF, 1, false, 2_000L, true, false, "id-1", 0, 50, true); + assertEquals(ButtonStyle.SUCCESS, getButton(favorited, "np_favorite").getStyle()); + } + + @Test + @DisplayName("buildNowPlayingMessage() now-playing buttons disable boundary actions") + void buildNowPlayingMessage_buttons_disableBoundaryActions() + { + MessageCreateData message = buildNowPlayingMessage("Test Title", "Test Author", true, true, "", + RepeatMode.OFF, 1, false, 3_000L, true, false, "id-1", 0); + assertTrue(getButton(message, "np_previous").isDisabled()); + assertTrue(getButton(message, "np_shuffle").isDisabled()); + assertFalse(getButton(message, "np_voldown").isDisabled()); + assertFalse(getButton(message, "np_volup").isDisabled()); + } + + @Test + @DisplayName("buildNowPlayingMessage() now-playing buttons enable previous with history and disable volume edges") + void buildNowPlayingMessage_buttons_enablePreviousWithHistoryAndDisableVolumeEdges() + { + MessageCreateData minVolMessage = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.ALL, 3, false, 2_000L, true, false, "id-1", 2, 0); + assertFalse(getButton(minVolMessage, "np_previous").isDisabled()); + assertTrue(getButton(minVolMessage, "np_voldown").isDisabled()); + assertFalse(getButton(minVolMessage, "np_volup").isDisabled()); + assertEquals("Repeat All", getButton(minVolMessage, "np_repeat").getLabel()); + + MessageCreateData maxVolMessage = buildNowPlayingMessage("Test Title", "Test Author", false, true, "", + RepeatMode.SINGLE, 3, true, 2_000L, true, false, "id-1", 2, 150); + assertFalse(getButton(maxVolMessage, "np_previous").isDisabled()); + assertFalse(getButton(maxVolMessage, "np_voldown").isDisabled()); + assertTrue(getButton(maxVolMessage, "np_volup").isDisabled()); + assertEquals("Resume", getButton(maxVolMessage, "np_pause").getLabel()); + assertEquals("Repeat One", getButton(maxVolMessage, "np_repeat").getLabel()); + } + + private static MessageCreateData buildNowPlayingMessage( + String title, + String author, + boolean minimalMessage, + boolean showProgressBar, + String footerInfo, + RepeatMode repeatMode, + int queueSize, + boolean paused, + long positionMs, + boolean showButtons) + { + return buildNowPlayingMessage(title, author, minimalMessage, showProgressBar, footerInfo, repeatMode, + queueSize, paused, positionMs, showButtons, false, "id-1", 0, 50); + } + + private static MessageCreateData buildNowPlayingMessage( + String title, + String author, + boolean minimalMessage, + boolean showProgressBar, + String footerInfo, + RepeatMode repeatMode, + int queueSize, + boolean paused, + long positionMs, + boolean showButtons, + boolean useNpImages, + String trackIdentifier) + { + return buildNowPlayingMessage(title, author, minimalMessage, showProgressBar, footerInfo, repeatMode, + queueSize, paused, positionMs, showButtons, useNpImages, trackIdentifier, 0, 50); + } + + private static MessageCreateData buildNowPlayingMessage( + String title, + String author, + boolean minimalMessage, + boolean showProgressBar, + String footerInfo, + RepeatMode repeatMode, + int queueSize, + boolean paused, + long positionMs, + boolean showButtons, + boolean useNpImages, + String trackIdentifier, + int previousTrackCount) + { + return buildNowPlayingMessage(title, author, minimalMessage, showProgressBar, footerInfo, repeatMode, + queueSize, paused, positionMs, showButtons, useNpImages, trackIdentifier, previousTrackCount, 50); + } + + private static MessageCreateData buildNowPlayingMessage( + String title, + String author, + boolean minimalMessage, + boolean showProgressBar, + String footerInfo, + RepeatMode repeatMode, + int queueSize, + boolean paused, + long positionMs, + boolean showButtons, + boolean useNpImages, + String trackIdentifier, + int previousTrackCount, + int volume) + { + return buildNowPlayingMessage(title, author, minimalMessage, showProgressBar, footerInfo, repeatMode, + queueSize, paused, positionMs, showButtons, useNpImages, trackIdentifier, previousTrackCount, volume, false); + } + + private static MessageCreateData buildNowPlayingMessage( + String title, + String author, + boolean minimalMessage, + boolean showProgressBar, + String footerInfo, + RepeatMode repeatMode, + int queueSize, + boolean paused, + long positionMs, + boolean showButtons, + boolean useNpImages, + String trackIdentifier, + int previousTrackCount, + int volume, + boolean isCurrentTrackFavorited) + { + Bot bot = mock(Bot.class); + BotConfig config = mock(BotConfig.class); + SettingsManager settingsManager = mock(SettingsManager.class); + Settings settings = mock(Settings.class); + + when(bot.getConfig()).thenReturn(config); + when(bot.getSettingsManager()).thenReturn(settingsManager); + when(config.showNpProgressBar()).thenReturn(showProgressBar); + when(config.useNPImages()).thenReturn(useNpImages); + + Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS); + when(guild.getName()).thenReturn("Test Guild"); + when(guild.getIconUrl()).thenReturn(null); + when(guild.getSelfMember().getVoiceState().getChannel().getName()).thenReturn("Music VC"); + when(settingsManager.getSettings(guild)).thenReturn(settings); + when(settings.useMinimalNowPlayingMessage(config)).thenReturn(minimalMessage); + when(settings.showNowPlayingButtons(config)).thenReturn(showButtons); + when(settings.getRepeatMode()).thenReturn(repeatMode); + + AudioTrack track = mock(AudioTrack.class, RETURNS_DEEP_STUBS); + AudioTrackInfo trackInfo = new AudioTrackInfo(title, author, 243000L, trackIdentifier, false, "https://example.com/track"); + when(track.getInfo()).thenReturn(trackInfo); + when(track.getIdentifier()).thenReturn(trackIdentifier); + when(track.getPosition()).thenReturn(positionMs); + when(track.getDuration()).thenReturn(243000L); + when(track.getSourceManager().getSourceName()).thenReturn("youtube"); + when(track.getUserData(RequestMetadata.class)).thenReturn(null); + + NowPlayingInfo info = new NowPlayingInfo(track, guild, paused, volume, queueSize, previousTrackCount, + isCurrentTrackFavorited, footerInfo); + return MessageFormatter.buildNowPlayingMessage(bot, info); + } + + private static String noMusicDescription(boolean minimalMessage, boolean showProgressBar) + { + Bot bot = mock(Bot.class); + BotConfig config = mock(BotConfig.class); + SettingsManager settingsManager = mock(SettingsManager.class); + Settings settings = mock(Settings.class); + Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS); + + when(bot.getConfig()).thenReturn(config); + when(bot.getSettingsManager()).thenReturn(settingsManager); + when(config.getSuccess()).thenReturn("ok"); + when(config.showNpProgressBar()).thenReturn(showProgressBar); + when(settingsManager.getSettings(guild)).thenReturn(settings); + when(settings.useMinimalNowPlayingMessage(config)).thenReturn(minimalMessage); + when(guild.getName()).thenReturn("Test Guild"); + when(guild.getSelfMember().getVoiceState().getChannel().getName()).thenReturn("Music VC"); + + NowPlayingInfo info = new NowPlayingInfo(null, guild, false, 50, 0, ""); + MessageCreateData message = MessageFormatter.buildNoMusicPlayingMessage(bot, info); + return getSingleEmbed(message).getDescription(); + } + + private static MessageEmbed getSingleEmbed(MessageCreateData message) + { + assertEquals(1, message.getEmbeds().size()); + return message.getEmbeds().get(0); + } + + private static MessageEmbed.Field getField(MessageEmbed embed, String fieldName) + { + return embed.getFields().stream() + .filter(f -> fieldName.equals(f.getName())) + .findFirst() + .orElse(null); + } + + private static Button getButton(MessageCreateData message, String customId) + { + return message.getComponents().stream() + .flatMap(row -> row.asActionRow().getButtons().stream()) + .filter(button -> customId.equals(button.getCustomId())) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected button with id: " + customId)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java new file mode 100644 index 0000000..0c702a8 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java @@ -0,0 +1,479 @@ +/* + * Copyright 2025 Arif Banai <a.banai@gmail.com>. + * + * 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.unit.utils; + +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.utils.OtherUtil; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.net.Proxy; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +public class OtherUtilTest +{ + private MockWebServer mockWebServer; + + @Mock + private BotConfig mockConfig; + + @BeforeEach + void setUp() throws IOException + { + MockitoAnnotations.openMocks(this); + mockWebServer = new MockWebServer(); + mockWebServer.start(); + } + + @AfterEach + void tearDown() throws IOException + { + mockWebServer.shutdown(); + } + + @ParameterizedTest(name = "{index}: isNewerVersion({0}, {1}) should be {2} - {3}") + @MethodSource("testData") + public void testIsNewerVersion(String current, String latest, boolean expected, String reason) + { + assertEquals(expected, OtherUtil.isNewerVersion(current, latest), reason); + } + + private static Stream<Arguments> testData() + { + return Stream.of( + // Newer versions + Arguments.of("0.5.1", "1.0.0", true, "Latest is newer (major)"), + Arguments.of("0.5.1", "0.6.0", true, "Latest is newer (minor)"), + Arguments.of("0.5.1", "0.5.2", true, "Latest is newer (patch)"), + + // Equal versions + Arguments.of("0.5.1", "0.5.1", false, "Versions are equal"), + + // Older versions (User is ahead) + Arguments.of("0.5.2", "0.5.1", false, "Current is newer (patch)"), + Arguments.of("0.6.0", "0.5.1", false, "Current is newer (minor)"), + + // Edge cases + Arguments.of("UNKNOWN", "0.5.1", true, "Unknown version should prompt update"), + Arguments.of("0.5.1-RELEASE", "0.5.1", false, "Handles non-numeric suffixes (equal)"), + Arguments.of("0.5.1", "0.5.2-BETA", true, "Handles suffixes in latest (newer)") + ); + } + + @Test + @DisplayName("getLatestVersion returns latest non-prerelease version when latest is not a pre-release") + void testGetLatestVersion_NonPrerelease() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "v0.6.2", + "prerelease": false, + "name": "Release 0.6.2" + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertEquals("0.6.2", result); + } + + @Test + @DisplayName("getLatestVersion strips 'v' prefix from tag name") + void testGetLatestVersion_StripsVPrefix() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "v1.0.0", + "prerelease": false + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertEquals("1.0.0", result); + } + + @Test + @DisplayName("getLatestVersion returns version without 'v' prefix when tag doesn't have it") + void testGetLatestVersion_NoVPrefix() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "0.6.2", + "prerelease": false + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertEquals("0.6.2", result); + } + + @Test + @DisplayName("getLatestVersion skips pre-release and returns latest non-prerelease from all releases") + void testGetLatestVersion_SkipsPrerelease() throws IOException + { + // Latest release is a pre-release + String latestReleaseJson = """ + { + "tag_name": "v0.7.0-beta", + "prerelease": true, + "name": "Pre-release 0.7.0-beta" + } + """; + + // All releases list with pre-release first, then non-prerelease + String allReleasesJson = """ + [ + { + "tag_name": "v0.7.0-beta", + "prerelease": true + }, + { + "tag_name": "v0.6.2", + "prerelease": false + }, + { + "tag_name": "v0.6.1", + "prerelease": false + } + ] + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(allReleasesJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertEquals("0.6.2", result); + } + + @Test + @DisplayName("getLatestVersion returns null when all releases are pre-releases") + void testGetLatestVersion_AllPrereleases() throws IOException + { + // Latest release is a pre-release + String latestReleaseJson = """ + { + "tag_name": "v0.7.0-beta", + "prerelease": true + } + """; + + // All releases are pre-releases + String allReleasesJson = """ + [ + { + "tag_name": "v0.7.0-beta", + "prerelease": true + }, + { + "tag_name": "v0.7.0-alpha", + "prerelease": true + } + ] + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(allReleasesJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertNull(result); + } + + @Test + @DisplayName("getLatestVersion returns null when API returns empty response") + void testGetLatestVersion_EmptyResponse() throws IOException + { + // First request returns empty object (no tag_name) + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{}") + .setHeader("Content-Type", "application/json")); + + // Second request (fallback to all releases) also returns empty + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("[]") + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertNull(result); + } + + @Test + @DisplayName("getLatestVersion returns null when API call fails") + void testGetLatestVersion_ApiFailure() throws IOException + { + // First request fails + mockWebServer.enqueue(new MockResponse() + .setResponseCode(500) + .setBody("Internal Server Error")); + + // Second request also fails + mockWebServer.enqueue(new MockResponse() + .setResponseCode(500) + .setBody("Internal Server Error")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertNull(result); + } + + @Test + @DisplayName("getLatestVersion handles multiple pre-releases before finding non-prerelease") + void testGetLatestVersion_MultiplePrereleases() throws IOException + { + // Latest release is a pre-release + String latestReleaseJson = """ + { + "tag_name": "v0.7.0-rc2", + "prerelease": true + } + """; + + // Multiple pre-releases before the first non-prerelease + String allReleasesJson = """ + [ + { + "tag_name": "v0.7.0-rc2", + "prerelease": true + }, + { + "tag_name": "v0.7.0-rc1", + "prerelease": true + }, + { + "tag_name": "v0.7.0-beta", + "prerelease": true + }, + { + "tag_name": "v0.6.2", + "prerelease": false + } + ] + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(allReleasesJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + String result = OtherUtil.getLatestVersion(baseUrl); + + assertEquals("0.6.2", result); + } + + @Test + @DisplayName("makeNonEmpty returns the string if not empty, otherwise returns zero-width space") + public void testMakeNonEmpty() + { + assertEquals("test", OtherUtil.makeNonEmpty("test")); + assertEquals("\u200B", OtherUtil.makeNonEmpty(null)); + assertEquals("\u200B", OtherUtil.makeNonEmpty("")); + } + + @Nested + @DisplayName("Proxy-Aware Version Checks") + class ProxyAwareVersionChecks + { + @Test + @DisplayName("getLatestVersion(BotConfig) works with null config") + void getLatestVersionWithNullConfig() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "v0.6.2", + "prerelease": false + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + // When config is null, should work without proxy + String result = OtherUtil.getLatestVersion(baseUrl, null); + + assertEquals("0.6.2", result); + } + + @Test + @DisplayName("getLatestVersion(BotConfig) works when proxy is disabled") + void getLatestVersionWithProxyDisabled() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "v0.6.3", + "prerelease": false + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + // Mock config with proxy disabled for GitHub + when(mockConfig.proxyGithub()).thenReturn(false); + when(mockConfig.hasProxy()).thenReturn(true); + when(mockConfig.getProxyHost()).thenReturn("127.0.0.1"); + when(mockConfig.getProxyPort()).thenReturn(8080); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + Proxy proxy = null; // No proxy since proxyGithub is false + String result = OtherUtil.getLatestVersion(baseUrl, proxy); + + assertEquals("0.6.3", result); + } + + @Test + @DisplayName("getLatestVersion(String, Proxy) accepts Proxy parameter") + void getLatestVersionWithProxyParameter() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "v0.6.4", + "prerelease": false + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + // Using Proxy.NO_PROXY to simulate direct connection (null proxy) + String result = OtherUtil.getLatestVersion(baseUrl, Proxy.NO_PROXY); + + assertEquals("0.6.4", result); + } + + @Test + @DisplayName("getLatestVersion(BotConfig) returns null when config has no proxy configured") + void getLatestVersionWithNoProxyConfigured() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "v0.6.5", + "prerelease": false + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + // Mock config with no proxy configured + when(mockConfig.proxyGithub()).thenReturn(true); // Wants to use proxy + when(mockConfig.hasProxy()).thenReturn(false); // But no proxy configured + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + // Since hasProxy() is false, proxy should be null even though proxyGithub is true + String result = OtherUtil.getLatestVersion(baseUrl, null); + + assertEquals("0.6.5", result); + } + + @Test + @DisplayName("Overloaded getLatestVersion() methods maintain backward compatibility") + void overloadedMethodsMaintainBackwardCompatibility() throws IOException + { + String latestReleaseJson = """ + { + "tag_name": "v1.0.0", + "prerelease": false + } + """; + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(latestReleaseJson) + .setHeader("Content-Type", "application/json")); + + String baseUrl = "http://localhost:" + mockWebServer.getPort() + "/repos/test/repo"; + + // Call the original single-arg version (should still work) + String result = OtherUtil.getLatestVersion(baseUrl); + + assertEquals("1.0.0", result); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/ProxyUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/ProxyUtilTest.java new file mode 100644 index 0000000..c47920f --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/ProxyUtilTest.java @@ -0,0 +1,134 @@ +/* + * 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.unit.utils; + +import com.jagrosh.jmusicbot.BotConfig; +import com.jagrosh.jmusicbot.utils.ProxyUtil; +import org.apache.http.HttpHost; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@DisplayName("ProxyUtil Tests") +class ProxyUtilTest { + + @Mock + private BotConfig config; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Nested + @DisplayName("createProxy() Tests") + class CreateProxyTests { + + @Test + @DisplayName("createProxy() returns null when proxy is not configured") + void createProxyReturnsNullWhenNotConfigured() { + when(config.hasProxy()).thenReturn(false); + + Proxy result = ProxyUtil.createProxy(config); + + assertNull(result); + } + + @Test + @DisplayName("createProxy() returns HTTP proxy when configured") + void createProxyReturnsHttpProxyWhenConfigured() { + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn("127.0.0.1"); + when(config.getProxyPort()).thenReturn(8080); + + Proxy result = ProxyUtil.createProxy(config); + + assertNotNull(result); + assertEquals(Proxy.Type.HTTP, result.type()); + + InetSocketAddress address = (InetSocketAddress) result.address(); + assertEquals("127.0.0.1", address.getHostString()); + assertEquals(8080, address.getPort()); + } + + @Test + @DisplayName("createProxy() handles different host and port values") + void createProxyHandlesDifferentValues() { + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn("proxy.example.com"); + when(config.getProxyPort()).thenReturn(18080); + + Proxy result = ProxyUtil.createProxy(config); + + assertNotNull(result); + InetSocketAddress address = (InetSocketAddress) result.address(); + assertEquals("proxy.example.com", address.getHostString()); + assertEquals(18080, address.getPort()); + } + } + + @Nested + @DisplayName("createApacheProxy() Tests") + class CreateApacheProxyTests { + + @Test + @DisplayName("createApacheProxy() returns null when proxy is not configured") + void createApacheProxyReturnsNullWhenNotConfigured() { + when(config.hasProxy()).thenReturn(false); + + HttpHost result = ProxyUtil.createApacheProxy(config); + + assertNull(result); + } + + @Test + @DisplayName("createApacheProxy() returns HttpHost when configured") + void createApacheProxyReturnsHttpHostWhenConfigured() { + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn("127.0.0.1"); + when(config.getProxyPort()).thenReturn(8080); + + HttpHost result = ProxyUtil.createApacheProxy(config); + + assertNotNull(result); + assertEquals("127.0.0.1", result.getHostName()); + assertEquals(8080, result.getPort()); + } + + @Test + @DisplayName("createApacheProxy() handles different host and port values") + void createApacheProxyHandlesDifferentValues() { + when(config.hasProxy()).thenReturn(true); + when(config.getProxyHost()).thenReturn("proxy.example.com"); + when(config.getProxyPort()).thenReturn(3128); + + HttpHost result = ProxyUtil.createApacheProxy(config); + + assertNotNull(result); + assertEquals("proxy.example.com", result.getHostName()); + assertEquals(3128, result.getPort()); + } + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java new file mode 100644 index 0000000..1558421 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2020 John Grosh <john.a.grosh@gmail.com>. + * + * 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.unit.utils; + +import com.jagrosh.jmusicbot.utils.TimeUtil; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Whew., Inc. + */ +public class TimeUtilTest +{ + @Test + public void singleDigit() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("5"); + assertNotNull(seek); + assertEquals(5000, seek.milliseconds); + } + + @Test + public void multipleDigits() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("99:9:999"); + assertNotNull(seek); + assertEquals(357939000, seek.milliseconds); + + seek = TimeUtil.parseTime("99h9m999s"); + assertNotNull(seek); + assertEquals(357939000, seek.milliseconds); + } + + @Test + public void decimalDigits() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("99.5:9.0:999.777"); + assertNotNull(seek); + assertEquals(359739777, seek.milliseconds); + } + + @Test + public void seeking() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("5"); + assertNotNull(seek); + assertFalse(seek.relative); + assertEquals(5000, seek.milliseconds); + } + + @Test + public void relativeSeekingForward() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("+5"); + assertNotNull(seek); + assertTrue(seek.relative); + assertEquals(5000, seek.milliseconds); + } + + @Test + public void relativeSeekingBackward() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("-5"); + assertNotNull(seek); + assertTrue(seek.relative); + assertEquals(-5000, seek.milliseconds); + } + + @Test + public void parseTimeArgumentLength() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime(""); + assertNull(seek); + } + + @Test + public void timestampTotalUnits() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("1:1:1:1"); + assertNull(seek); + + seek = TimeUtil.parseTime("1h2m3m4s5s"); + assertNotNull(seek); + assertEquals(3909000, seek.milliseconds); + } + + @Test + public void relativeSymbol() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("+-1:-+1:+-1"); + assertNull(seek); + } + + @Test + public void timestampNumberFormat() + { + TimeUtil.SeekTime seek = TimeUtil.parseTime("1:1:a"); + assertNull(seek); + + seek = TimeUtil.parseTime("1a2s"); + assertNotNull(seek); + assertEquals(3000, seek.milliseconds); + } + + @Test + public void testFormatTime() + { + assertEquals("LIVE", TimeUtil.formatTime(Long.MAX_VALUE)); + assertEquals("00:00", TimeUtil.formatTime(0)); + assertEquals("00:05", TimeUtil.formatTime(5000)); + assertEquals("00:59", TimeUtil.formatTime(59000)); + assertEquals("01:00", TimeUtil.formatTime(60000)); + assertEquals("1:01:01", TimeUtil.formatTime(3661000)); + } +} diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/YoutubeOauth2TokenHandlerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/YoutubeOauth2TokenHandlerTest.java new file mode 100644 index 0000000..ebcf38a --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/YoutubeOauth2TokenHandlerTest.java @@ -0,0 +1,223 @@ +/* + * 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.unit.utils; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.core.spi.FilterReply; +import com.jagrosh.jmusicbot.utils.YoutubeOauth2TokenHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for YoutubeOauth2TokenHandler. + * + * This handler captures OAuth authorization messages from the YouTube source library + * and stores the authorization URL and code so they can be sent to the bot owner. + * + * @see YoutubeOauth2TokenHandler + */ +@DisplayName("YoutubeOauth2TokenHandler Tests") +class YoutubeOauth2TokenHandlerTest { + + private YoutubeOauth2TokenHandler handler; + private Logger youtubeOauthLogger; + + @BeforeEach + void setUp() { + handler = new YoutubeOauth2TokenHandler(); + // Get the logger that the YouTube library uses + youtubeOauthLogger = (Logger) LoggerFactory.getLogger("dev.lavalink.youtube.http.YoutubeOauth2Handler"); + } + + @Nested + @DisplayName("OAuth Authorization Message Capture") + class OAuthAuthorizationMessageCapture { + + @Test + @DisplayName("captures authorization URL and code from OAuth message") + void capturesAuthorizationDataFromOAuthMessage() { + String expectedUrl = "https://www.google.com/device"; + String expectedCode = "ABC-123-XYZ"; + String format = "OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {}"; + Object[] params = new Object[] { expectedUrl, expectedCode }; + + // Before the message, data should be null + assertNull(handler.getData(), "Data should be null before OAuth message"); + + // Process the OAuth authorization message + FilterReply reply = handler.decide(null, youtubeOauthLogger, Level.INFO, format, params, null); + + // Should return NEUTRAL (allow the message to be logged) + assertEquals(FilterReply.NEUTRAL, reply, "Should return NEUTRAL for OAuth auth message"); + + // Data should now be captured + YoutubeOauth2TokenHandler.Data data = handler.getData(); + assertNotNull(data, "Data should be captured after OAuth message"); + assertEquals(expectedUrl, data.getAuthorisationUrl(), "Authorization URL should match"); + assertEquals(expectedCode, data.getCode(), "Code should match"); + } + + @Test + @DisplayName("ignores messages from other loggers") + void ignoresMessagesFromOtherLoggers() { + Logger otherLogger = (Logger) LoggerFactory.getLogger("com.example.OtherClass"); + String format = "OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {}"; + Object[] params = new Object[] { "https://example.com", "CODE123" }; + + FilterReply reply = handler.decide(null, otherLogger, Level.INFO, format, params, null); + + assertEquals(FilterReply.NEUTRAL, reply, "Should return NEUTRAL for other loggers"); + assertNull(handler.getData(), "Data should not be captured from other loggers"); + } + + @Test + @DisplayName("ignores messages with different format") + void ignoresMessagesWithDifferentFormat() { + String format = "Some other message format"; + Object[] params = new Object[] { "https://example.com", "CODE123" }; + + FilterReply reply = handler.decide(null, youtubeOauthLogger, Level.INFO, format, params, null); + + assertEquals(FilterReply.NEUTRAL, reply, "Should return NEUTRAL for different format"); + assertNull(handler.getData(), "Data should not be captured from different format"); + } + } + + @Nested + @DisplayName("OAuth Token Success Message") + class OAuthTokenSuccessMessage { + + @Test + @DisplayName("denies (suppresses) token success message to hide sensitive data") + void deniesTokenSuccessMessage() { + String format = "OAUTH INTEGRATION: Token retrieved successfully. Store your refresh token as this can be reused. ({})"; + String token = "ya29.a0AfH6SMBxxxxxxxxxxxxxx"; + Object[] params = new Object[] { token }; + + FilterReply reply = handler.decide(null, youtubeOauthLogger, Level.INFO, format, params, null); + + // Should return DENY to suppress the token from being logged + assertEquals(FilterReply.DENY, reply, "Should return DENY for token success message to hide token"); + } + + @Test + @DisplayName("ignores token success message from other loggers") + void ignoresTokenSuccessFromOtherLoggers() { + Logger otherLogger = (Logger) LoggerFactory.getLogger("com.example.OtherClass"); + String format = "OAUTH INTEGRATION: Token retrieved successfully. Store your refresh token as this can be reused. ({})"; + Object[] params = new Object[] { "token123" }; + + FilterReply reply = handler.decide(null, otherLogger, Level.INFO, format, params, null); + + assertEquals(FilterReply.NEUTRAL, reply, "Should return NEUTRAL for other loggers"); + } + } + + @Nested + @DisplayName("Data Class Tests") + class DataClassTests { + + @Test + @DisplayName("Data class stores authorization URL correctly") + void dataClassStoresAuthorizationUrl() { + String format = "OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {}"; + String url = "https://www.google.com/device"; + Object[] params = new Object[] { url, "CODE" }; + + handler.decide(null, youtubeOauthLogger, Level.INFO, format, params, null); + + assertEquals(url, handler.getData().getAuthorisationUrl()); + } + + @Test + @DisplayName("Data class stores code correctly") + void dataClassStoresCode() { + String format = "OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {}"; + String code = "ABCD-1234-EFGH"; + Object[] params = new Object[] { "https://example.com", code }; + + handler.decide(null, youtubeOauthLogger, Level.INFO, format, params, null); + + assertEquals(code, handler.getData().getCode()); + } + } + + @Nested + @DisplayName("Handler Initialization") + class HandlerInitialization { + + @Test + @DisplayName("getData returns null before any OAuth message") + void getDataReturnsNullInitially() { + YoutubeOauth2TokenHandler newHandler = new YoutubeOauth2TokenHandler(); + assertNull(newHandler.getData(), "getData should return null initially"); + } + + @Test + @DisplayName("init method does not throw") + void initMethodDoesNotThrow() { + YoutubeOauth2TokenHandler newHandler = new YoutubeOauth2TokenHandler(); + assertDoesNotThrow(() -> newHandler.init(), "init should not throw"); + } + } + + @Nested + @DisplayName("Message Format Matching - Regression Prevention") + class MessageFormatMatching { + + @Test + @DisplayName("exact format string match is required for OAuth auth message") + void exactFormatStringMatchRequired() { + // These slightly different formats should NOT match + String[] wrongFormats = { + "OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {} ", + "OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code", + "oauth integration: to give youtube-source access to your account, go to {} and enter code {}", + "OAUTH INTEGRATION:To give youtube-source access to your account, go to {} and enter code {}", + }; + + Object[] params = new Object[] { "https://example.com", "CODE" }; + + for (String wrongFormat : wrongFormats) { + handler = new YoutubeOauth2TokenHandler(); // Reset handler + handler.decide(null, youtubeOauthLogger, Level.INFO, wrongFormat, params, null); + assertNull(handler.getData(), + "Data should not be captured for format: " + wrongFormat); + } + } + + @Test + @DisplayName("exact format string match is required for token success message") + void exactFormatStringMatchRequiredForTokenSuccess() { + String correctFormat = "OAUTH INTEGRATION: Token retrieved successfully. Store your refresh token as this can be reused. ({})"; + Object[] params = new Object[] { "token" }; + + FilterReply reply = handler.decide(null, youtubeOauthLogger, Level.INFO, correctFormat, params, null); + assertEquals(FilterReply.DENY, reply, "Correct format should be denied"); + + // Wrong format should return NEUTRAL + String wrongFormat = "OAUTH INTEGRATION: Token retrieved successfully. ({})"; + reply = handler.decide(null, youtubeOauthLogger, Level.INFO, wrongFormat, params, null); + assertEquals(FilterReply.NEUTRAL, reply, "Wrong format should return NEUTRAL"); + } + } +}