diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..57f685969 --- /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/FUNDING.yml b/.github/FUNDING.yml index ccddb9e60..5e5b2d664 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,7 +1,7 @@ # These are supported funding model platforms github: arif-banai -patreon: # Replace with patreon +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 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index d32252e82..56613f2eb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -45,7 +45,7 @@ jobs: - name: Build and Test with Coverage run: | COMMIT_TIME=$(git log -1 --format=%cI) - mvn --batch-mode --update-snapshots verify -Pcoverage -Dproject.build.outputTimestamp=$COMMIT_TIME + mvn --batch-mode verify -Pcoverage -Dproject.build.outputTimestamp=$COMMIT_TIME - name: Upload Coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 6fe724326..016981592 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -107,7 +107,7 @@ jobs: - name: Build with Maven run: | COMMIT_TIME=$(git log -1 --format=%cI) - mvn --batch-mode --update-snapshots verify -Dproject.build.outputTimestamp=$COMMIT_TIME + mvn --batch-mode verify -Dproject.build.outputTimestamp=$COMMIT_TIME - name: Rename JAR run: mv target/*-All.jar JMusicBot-${{ github.event.inputs.version_number }}.jar diff --git a/.github/workflows/publish-preview-image.yml b/.github/workflows/publish-preview-image.yml index ee787ca67..53c9cbc0f 100644 --- a/.github/workflows/publish-preview-image.yml +++ b/.github/workflows/publish-preview-image.yml @@ -71,7 +71,7 @@ jobs: if: ${{ github.event.inputs.run_tests != 'false' }} run: | COMMIT_TIME=$(git log -1 --format=%cI) - mvn --batch-mode --update-snapshots verify -Pdocker -Dproject.build.outputTimestamp=$COMMIT_TIME + mvn --batch-mode verify -Pdocker -Dproject.build.outputTimestamp=$COMMIT_TIME - name: Sanitize tag suffix id: sanitize diff --git a/.gitignore b/.gitignore index fc25521ac..391423e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,23 @@ *.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 index f1919f053..76616ea15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.12 # Multi-stage build for JMusicBot # Stage 1: Build the application -FROM maven:3.9-eclipse-temurin-25 AS builder +FROM maven:3.9.12-eclipse-temurin-25-alpine AS builder ARG BUILD_TIMESTAMP ENV BUILD_TIMESTAMP=$BUILD_TIMESTAMP @@ -10,7 +10,7 @@ ENV BUILD_TIMESTAMP=$BUILD_TIMESTAMP WORKDIR /build # Copy pom.xml first for better layer caching -COPY pom.xml . +COPY --link pom.xml . # Download dependencies with BuildKit cache mount for Maven repository # This significantly speeds up builds by persisting dependencies between builds @@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.m2/repository \ mvn dependency:go-offline -B -Pdocker # Copy source code -COPY src ./src +COPY --link src ./src # Build the application with BuildKit cache mount RUN --mount=type=cache,target=/root/.m2/repository \ @@ -29,9 +29,38 @@ RUN --mount=type=cache,target=/root/.m2/repository \ fi -# Stage 2: Runtime image -# Using Ubuntu Noble (24.04) for libraries required by jdave/udpqueue native libraries -FROM eclipse-temurin:25-jre-noble +# 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" \ @@ -41,18 +70,21 @@ LABEL org.opencontainers.image.title="JMusicBot" \ org.opencontainers.image.vendor="JMusicBot" \ org.opencontainers.image.licenses="Apache-2.0" -# Create non-root user for security -RUN groupadd --gid 10001 jmusicbot && \ - useradd --uid 10001 --gid 10001 --shell /bin/false jmusicbot +# 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 application directories -RUN mkdir -p /app /musicbot && \ +# 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 in a single layer +# Copy and set permissions for entrypoint script COPY --chown=jmusicbot:jmusicbot --chmod=755 docker/entrypoint.sh /app/entrypoint.sh WORKDIR /musicbot diff --git a/README.md b/README.md index df36a8a21..97dbe8e42 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,25 @@ [![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)](https://discord.gg/cyyUxNmmx6)
+[![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! -## ⚠️ Important Notice (Java 25) +## ⚠️ Check your Java Version and other requirements -* **Java 25 Minimum:** The bot now requires **Java 25 or higher**. Please update your hosting environment (check `java -version`) before running the new JAR. +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 @@ -38,7 +43,7 @@ A cross-platform Discord music bot with a clean interface, and that is easy to s * Playlist support (both web/youtube, and local) ## Supported sources and formats -JMusicBot supports all sources and formats supported by [lavaplayer](https://github.com/sedmelluq/lavaplayer#supported-formats): +JMusicBot supports all sources and formats supported by [lavaplayer](https://github.com/lavalink-devs/lavaplayer#supported-formats): ### Sources * YouTube * SoundCloud @@ -63,6 +68,36 @@ JMusicBot supports all sources and formats supported by [lavaplayer](https://git ## 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: + +```bash +java -Dnogui=true --enable-native-access=ALL-UNNAMED -jar JMusicBot-0.6.2-All.jar +``` + +### 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. @@ -128,7 +163,7 @@ Check the [Docker Compose Example](docker-compose.example.yml) for more details. - **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 default branch + - 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:** You can optionally set `JAVA_OPTS` environment variable to pass additional JVM arguments (e.g., `-Xmx512m -Xms256m` for memory settings). @@ -149,7 +184,16 @@ This project follows a **trunk-based development** workflow. The `master` branch 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). 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) and [**lavaplayer**](https://github.com/lavalink-devs/lavaplayer)! +**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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 000000000..0b5075008 --- /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: HistoryQueue~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 HistoryQueue~T~ { + -history: LinkedList~T~ + -maxSize: int + +add(T item) + +pop() T + } + + class QueuedTrack { + -track: AudioTrack + -metadata: RequestMetadata + +getIdentifier() long + } + + Queueable <|.. QueuedTrack + AbstractQueue <|-- LinearQueue + AbstractQueue <|-- FairQueue + AbstractQueue o-- HistoryQueue + 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/pom.xml b/pom.xml index ca431babf..75785bdbb 100644 --- a/pom.xml +++ b/pom.xml @@ -1,9 +1,10 @@ - + 4.0.0 com.arifbanai JMusicBot - 0.6.2 + 0.6.3-alpha-slashcommands jar JMusicBot diff --git a/scripts/run_jmusicbot.sh b/scripts/run_jmusicbot.sh index bbdaa909d..7c1d870d2 100644 --- a/scripts/run_jmusicbot.sh +++ b/scripts/run_jmusicbot.sh @@ -8,6 +8,10 @@ DOWNLOAD=true # 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 @@ -47,7 +51,8 @@ download() { } run() { - java -Dnogui=true --enable-native-access=ALL-UNNAMED -jar $(ls -t JMusicBot* | head -1) + # shellcheck disable=SC2086 + java -Dnogui=true $JAVA_OPTS -jar $(ls -t JMusicBot* | head -1) } while diff --git a/src/main/java/com/jagrosh/jmusicbot/Bot.java b/src/main/java/com/jagrosh/jmusicbot/Bot.java index 3c63d272e..582ef5993 100644 --- a/src/main/java/com/jagrosh/jmusicbot/Bot.java +++ b/src/main/java/com/jagrosh/jmusicbot/Bot.java @@ -15,14 +15,18 @@ */ 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.NowPlayingHandler; import com.jagrosh.jmusicbot.audio.PlayerManager; +import com.jagrosh.jmusicbot.entities.UserInteraction; import com.jagrosh.jmusicbot.gui.GUI; 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; @@ -48,18 +52,23 @@ public class Bot 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 boolean shuttingDown = false; private JDA jda; private GUI gui; + private CommandClient commandClient; - public Bot(EventWaiter waiter, BotConfig config, SettingsManager settings) + 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(); @@ -72,6 +81,8 @@ public Bot(EventWaiter waiter, BotConfig config, SettingsManager settings) this.nowplaying.init(); this.aloneInVoiceHandler = new AloneInVoiceHandler(this); this.aloneInVoiceHandler.init(); + this.musicService = new MusicService(this); + this.searchService = new SearchService(this); } public BotConfig getConfig() @@ -113,7 +124,22 @@ public AloneInVoiceHandler getAloneInVoiceHandler() { return aloneInVoiceHandler; } - + + public MusicService getMusicService() + { + return musicService; + } + + public SearchService getSearchService() + { + return searchService; + } + + public UserInteraction getUserInteraction() + { + return userInteraction; + } + public JDA getJDA() { return jda; @@ -178,6 +204,16 @@ public void setGUI(GUI gui) this.gui = gui; } + public void setCommandClient(CommandClient commandClient) + { + this.commandClient = commandClient; + } + + public CommandClient getCommandClient() + { + return commandClient; + } + public YoutubeOauth2TokenHandler getYouTubeOauth2Handler() { return youTubeOauth2TokenHandler; } diff --git a/src/main/java/com/jagrosh/jmusicbot/BotConfig.java b/src/main/java/com/jagrosh/jmusicbot/BotConfig.java index 76eac9c0a..70f362dc0 100644 --- a/src/main/java/com/jagrosh/jmusicbot/BotConfig.java +++ b/src/main/java/com/jagrosh/jmusicbot/BotConfig.java @@ -37,8 +37,8 @@ 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.Prompt; 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; @@ -60,7 +60,7 @@ public class BotConfig { evalEngine; private boolean stayInChannel, songInGame, npImages, updatealerts, useEval, dbots, useYouTubeOauth; private long owner, maxSeconds, aloneTimeUntilStop; - private int maxYTPlaylistPages; + private int maxYTPlaylistPages, maxHistorySize; private double skipratio; private OnlineStatus status; private Activity game; @@ -95,11 +95,11 @@ public void load() { valid = true; } catch (ConfigException ex) { - userInteraction.alert(Prompt.Level.ERROR, "Config", + 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(Prompt.Level.ERROR, "Config Migration", + userInteraction.alert(Level.ERROR, "Config Migration", "Failed to migrate configuration: " + ex.getMessage() + "\n\nConfig Location: " + path.toAbsolutePath().toString()); } } @@ -258,6 +258,7 @@ private void loadConfigValues(Config config, Config migratedUserConfig) { 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); aloneTimeUntilStop = ALONE_TIME_UNTIL_STOP.getLong(config); playlistsFolder = PLAYLISTS_FOLDER.getString(config); @@ -324,22 +325,26 @@ private void writeToFile() { .trim(); ConfigIO.writeConfigFile(path, content); } catch (Exception ex) { - userInteraction.alert(Prompt.Level.WARNING, "Config", "Failed to write new config options to config.txt: " + 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()); } } - public static void writeDefaultConfig() { - Prompt prompt = new Prompt(null, null, true, true); - prompt.alert(Prompt.Level.INFO, "JMusicBot Config", "Generating default config file"); + /** + * 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 { - prompt.alert(Prompt.Level.INFO, "JMusicBot Config", + userInteraction.alert(Level.INFO, "JMusicBot Config", "Writing default config file to " + path.toAbsolutePath().toString()); ConfigIO.writeConfigFile(path, ConfigIO.loadDefaultConfig()); } catch (Exception ex) { - prompt.alert(Prompt.Level.ERROR, "JMusicBot Config", + userInteraction.alert(Level.ERROR, "JMusicBot Config", "An error occurred writing the default config file: " + ex.getMessage()); } } @@ -452,6 +457,10 @@ public int getMaxYTPlaylistPages() { return maxYTPlaylistPages; } + public int getMaxHistorySize() { + return maxHistorySize; + } + public boolean useYouTubeOauth() { return useYouTubeOauth; } diff --git a/src/main/java/com/jagrosh/jmusicbot/DiscordService.java b/src/main/java/com/jagrosh/jmusicbot/DiscordService.java index 96ddfb1e2..e676f1147 100644 --- a/src/main/java/com/jagrosh/jmusicbot/DiscordService.java +++ b/src/main/java/com/jagrosh/jmusicbot/DiscordService.java @@ -2,7 +2,7 @@ import com.jagrosh.jdautilities.command.CommandClient; import com.jagrosh.jdautilities.commons.waiter.EventWaiter; -import com.jagrosh.jmusicbot.entities.Prompt; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; import com.jagrosh.jmusicbot.entities.UserInteraction; import com.jagrosh.jmusicbot.utils.OtherUtil; import com.sedmelluq.discord.lavaplayer.jdaudp.NativeAudioSendFactory; @@ -49,7 +49,7 @@ public static JDA createJDA(BotConfig config, Bot bot, EventWaiter waiter, Comma // Perform post-startup validation String unsupportedReason = OtherUtil.getUnsupportedBotReason(jda); if (unsupportedReason != null) { - userInteraction.alert(Prompt.Level.ERROR, "JMusicBot", "JMusicBot cannot be run on this Discord bot: " + unsupportedReason); + userInteraction.alert(Level.ERROR, "JMusicBot", "JMusicBot cannot be run on this Discord bot: " + unsupportedReason); jda.shutdown(); System.exit(1); } diff --git a/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java b/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java index c0108eebd..bdfc2e9f4 100644 --- a/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java +++ b/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java @@ -15,17 +15,9 @@ */ package com.jagrosh.jmusicbot; -import ch.qos.logback.classic.Level; -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; - import com.jagrosh.jdautilities.command.CommandClient; import com.jagrosh.jdautilities.commons.waiter.EventWaiter; -import com.jagrosh.jmusicbot.commands.CommandFactory; +import com.jagrosh.jmusicbot.commands.v1.CommandFactory; import com.jagrosh.jmusicbot.entities.Prompt; import com.jagrosh.jmusicbot.entities.UserInteraction; import com.jagrosh.jmusicbot.gui.GUI; @@ -33,6 +25,13 @@ import com.jagrosh.jmusicbot.utils.ConsoleUtil; import com.jagrosh.jmusicbot.utils.InstanceLock; import com.jagrosh.jmusicbot.utils.OtherUtil; +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; + /** * @@ -69,7 +68,9 @@ public static void main(String[] args) { if(args.length > 0) { if (args[0].equalsIgnoreCase("generate-config")) { - BotConfig.writeDefaultConfig(); + // Use headless prompt for config generation (nogui=true, noprompt=true) + UserInteraction userInteraction = new Prompt(null, null, true, true); + BotConfig.writeDefaultConfig(userInteraction); return; } } @@ -97,7 +98,7 @@ private static void startBot() // Check for another running instance if (!InstanceLock.tryAcquire()) { - userInteraction.alert(Prompt.Level.ERROR, "JMusicBot", + 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."); @@ -117,12 +118,12 @@ private static void startBot() // set log level from config ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel( - Level.toLevel(config.getLogLevel(), Level.INFO)); + ch.qos.logback.classic.Level.toLevel(config.getLogLevel(), ch.qos.logback.classic.Level.INFO)); // set up the listener EventWaiter waiter = new EventWaiter(); SettingsManager settings = new SettingsManager(); - Bot bot = new Bot(waiter, config, settings); + Bot bot = new Bot(waiter, config, settings, userInteraction); // Initialize GUI (ConsolePanel will reuse the already-redirected streams) if(!userInteraction.isNoGUI()) @@ -136,10 +137,13 @@ private static void startBot() catch(Exception e) { LOG.error("Could not start GUI. Use -Dnogui=true for server environments."); + 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(); @@ -152,13 +156,13 @@ private static void startBot() } catch(IllegalArgumentException ex) { - userInteraction.alert(Prompt.Level.ERROR, "JMusicBot", + userInteraction.alert(UserInteraction.Level.ERROR, "JMusicBot", "Invalid configuration. Check your token.\nConfig Location: " + config.getConfigLocation()); System.exit(1); } catch(ErrorResponseException ex) { - userInteraction.alert(Prompt.Level.ERROR, "JMusicBot", "Invalid response from Discord. Check your internet connection."); + userInteraction.alert(UserInteraction.Level.ERROR, "JMusicBot", "Invalid response from Discord. Check your internet connection."); System.exit(1); } catch(Exception ex) diff --git a/src/main/java/com/jagrosh/jmusicbot/Listener.java b/src/main/java/com/jagrosh/jmusicbot/Listener.java index a24e884b1..8a1ccf297 100644 --- a/src/main/java/com/jagrosh/jmusicbot/Listener.java +++ b/src/main/java/com/jagrosh/jmusicbot/Listener.java @@ -15,6 +15,10 @@ */ package com.jagrosh.jmusicbot; +import com.jagrosh.jmusicbot.audio.AudioHandler; + +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; @@ -24,15 +28,21 @@ import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; 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.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.CloseCode; +import net.dv8tion.jda.api.utils.messages.MessageEditData; +import com.jagrosh.jmusicbot.service.MusicService; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; /** * @@ -53,9 +63,19 @@ 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(event.getJDA().getInviteUrl(JMusicBot.RECOMMENDED_PERMS)); + 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); + } + + // Register slash commands if they have changed + if(bot.getCommandClient() != null) + { + SlashCommandRegistry.registerIfChanged(event.getJDA(), bot.getCommandClient()); } + credit(event.getJDA()); event.getJDA().getGuilds().forEach((Guild guild) -> { @@ -117,12 +137,130 @@ public void onMessageDelete(@NotNull MessageDeleteEvent event) bot.getNowplayingHandler().onMessageDelete(event.getGuild(), event.getMessageIdLong()); } + @Override + public void onButtonInteraction(ButtonInteractionEvent event) + { + if (!event.getComponentId().equals("stop") && !event.getComponentId().equals("pause") && !event.getComponentId().equals("skip") + && !event.getComponentId().equals("previous") && !event.getComponentId().equals("shuffle") + && !event.getComponentId().equals("repeat") && !event.getComponentId().equals("voldown") + && !event.getComponentId().equals("volup")) + 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; + } + + // Permissions check + 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(); + MusicService.OutputAdapter adapter = 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 onSuccess) { + event.editMessage(content).queue(hook -> hook.retrieveOriginal().queue(onSuccess)); + } + + @Override + public void editNowPlaying(AudioHandler handler) { + event.editMessage(MessageEditData.fromCreateData(handler.getNowPlaying(event.getJDA()))).queue(); + } + + @Override + public void editNoMusic(AudioHandler handler) { + event.editMessage(MessageEditData.fromCreateData(handler.getNoMusicPlaying(event.getJDA()))).queue(); + } + + @Override + public void onShowHelp() { + // Not used for buttons + } + }; + + switch (event.getComponentId()) + { + case "previous": + musicService.previous(event.getGuild(), event.getMember(), adapter); + break; + case "shuffle": + musicService.shuffle(event.getGuild(), event.getMember(), 0, adapter); + break; + case "repeat": + musicService.cycleRepeatMode(event.getGuild(), event.getMember(), adapter); + break; + case "voldown": + musicService.adjustVolume(event.getGuild(), event.getMember(), -10, adapter); + break; + case "volup": + musicService.adjustVolume(event.getGuild(), event.getMember(), 10, adapter); + break; + case "stop": + musicService.stop(event.getGuild(), event.getMember(), adapter); + break; + case "pause": + musicService.pause(event.getGuild(), event.getMember(), adapter); + break; + case "skip": + musicService.skip(event.getGuild(), event.getMember(), adapter); + break; + } + } + @Override public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { bot.getAloneInVoiceHandler().onVoiceUpdate(event); } + @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) { diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/AudioHandler.java b/src/main/java/com/jagrosh/jmusicbot/audio/AudioHandler.java index 9bd37b5d1..fb75c976a 100644 --- a/src/main/java/com/jagrosh/jmusicbot/audio/AudioHandler.java +++ b/src/main/java/com/jagrosh/jmusicbot/audio/AudioHandler.java @@ -52,16 +52,16 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler 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; - + private AudioFrame lastFrame; private AbstractQueue queue; + private String lastReason = null; protected AudioHandler(PlayerManager manager, Guild guild, AudioPlayer player) { @@ -70,11 +70,20 @@ protected AudioHandler(PlayerManager manager, Guild guild, AudioPlayer player) this.guildId = guild.getIdLong(); this.setQueueType(manager.getBot().getSettingsManager().getSettings(guildId).getQueueType()); + // Set history size from config + this.queue.setMaxHistorySize(manager.getBot().getConfig().getMaxHistorySize()); } public void setQueueType(QueueType type) { queue = type.createInstance(queue); + // History and its max size are preserved when changing queue types + // If this is a new queue (first initialization), max size will be set in constructor + } + + public void setLastReason(String reason) + { + this.lastReason = reason; } public int addTrackToFront(QueuedTrack qtrack) @@ -86,6 +95,7 @@ public int addTrackToFront(QueuedTrack qtrack) } else { + LOGGER.debug("Added track to front of queue: {}", qtrack.getTrack().getInfo().title); queue.addAt(0, qtrack); return 0; } @@ -98,10 +108,22 @@ public int addTrack(QueuedTrack qtrack) audioPlayer.playTrack(qtrack.getTrack()); return -1; } - else - return queue.add(qtrack); + + 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; @@ -109,7 +131,8 @@ public AbstractQueue getQueue() public void stopAndClear() { - queue.clear(); + LOGGER.debug("Stopping and clearing queue"); + queue.clearAll(); defaultQueue.clear(); audioPlayer.stopTrack(); //current = null; @@ -181,22 +204,39 @@ public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason.name(), track != null && track.getInfo() != null ? track.getInfo().title : "N/A"); } - + else if (track != null && track.getInfo() != null) { + LOGGER.debug("Track ended: {} Reason: {}", track.getInfo().title, endReason); + } + + // Add to queue history for tracking previously played tracks + if (endReason.mayStartNext && track != null) + { + QueuedTrack completedTrack = new QueuedTrack(track.makeClone(), track.getUserData(RequestMetadata.class)); + queue.addToHistory(completedTrack); + } + 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); @@ -205,9 +245,11 @@ public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason player.setPaused(false); } } - else + 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()); } } @@ -274,8 +316,21 @@ public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyExcep } @Override - public void onTrackStart(AudioPlayer player, AudioTrack track) + 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(); // Log track start with details for debugging @@ -286,7 +341,10 @@ public void onTrackStart(AudioPlayer player, AudioTrack track) track.getInfo().uri, track.getSourceManager() != null ? track.getSourceManager().getSourceName() : "Unknown"); } - + + if (lastReason == null) + lastReason = "Playing next song."; + manager.getBot().getNowplayingHandler().onTrackUpdate(guildId, track); } @@ -297,7 +355,9 @@ public NowPlayingInfo getNowPlayingInfo(JDA jda) audioPlayer.getPlayingTrack(), jda.getGuildById(guildId), audioPlayer.isPaused(), - audioPlayer.getVolume() + audioPlayer.getVolume(), + queue.size(), + lastReason ); } @@ -320,27 +380,6 @@ public String getStatusEmoji() } // Audio Send Handler methods - /*@Override - public boolean canProvide() - { - if (lastFrame == null) - lastFrame = audioPlayer.provide(); - - return lastFrame != null; - } - - @Override - public byte[] provide20MsAudio() - { - if (lastFrame == null) - lastFrame = audioPlayer.provide(); - - byte[] data = lastFrame != null ? lastFrame.getData() : null; - lastFrame = null; - - return data; - }*/ - @Override public boolean canProvide() { diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingHandler.java b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingHandler.java index f5f887d47..9f3cc4ace 100644 --- a/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingHandler.java +++ b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingHandler.java @@ -16,6 +16,7 @@ package com.jagrosh.jmusicbot.audio; import com.jagrosh.jmusicbot.Bot; +import com.jagrosh.jmusicbot.utils.FormatUtil; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import net.dv8tion.jda.api.entities.Activity; import net.dv8tion.jda.api.entities.Guild; @@ -66,19 +67,82 @@ public void clearLastNPMessage(Guild guild) // "event"-based methods public void onTrackUpdate(long guildId, AudioTrack track) { - // Trigger immediate UI update for this guild - updateSingleGuild(guildId); + // For track updates (start/stop), we want to potentially send a NEW message + if (track != null) + { + // Send new message + sendNewMessage(guildId); + } + else + { + // Track stopped, just update UI (which will probably clear it or show "No music playing") + updateSingleGuild(guildId); + } // update bot status if applicable if(bot.getConfig().getSongInStatus()) { if(track != null) - bot.getJDA().getPresence().setActivity(Activity.listening(track.getInfo().title)); + { + String title = FormatUtil.getTrackTitle(track); + bot.getJDA().getPresence().setActivity(Activity.listening(title)); + } else + { bot.resetGame(); + } } } - + + private void sendNewMessage(long guildId) + { + Guild guild = bot.getJDA().getGuildById(guildId); + if(guild == null) + { + lastNP.remove(guildId); + return; + } + + AudioHandler handler = (AudioHandler) guild.getAudioManager().getSendingHandler(); + MessageCreateData msg = handler.getNowPlaying(bot.getJDA()); + if (msg == null) return; + + NPLocation loc = lastNP.get(guildId); + TextChannel tc; + if(loc == null) + { + // If we don't have a last NP message, try to use the channel from the current track's metadata + AudioTrack track = handler.getPlayer().getPlayingTrack(); + if (track != null && track.getUserData(RequestMetadata.class) != null) + { + long channelId = track.getUserData(RequestMetadata.class).channelId; + tc = guild.getTextChannelById(channelId); + } + else + { + tc = null; + } + } + else + { + tc = guild.getTextChannelById(loc.channelId()); + } + + if (tc == null) { + lastNP.remove(guildId); + return; + } + + // Clean up previous message if it exists + if (loc != null) + tc.deleteMessageById(loc.messageId()).queue(s -> {}, t -> {}); + + tc.sendMessage(msg).queue( + m -> setLastNPMessage(m), + throwable -> handleUpdateError(guildId, throwable) + ); + } + public void onMessageDelete(Guild guild, long messageId) { NPLocation loc = lastNP.get(guild.getIdLong()); diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingInfo.java b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingInfo.java index 8bea16181..1b33ba471 100644 --- a/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingInfo.java +++ b/src/main/java/com/jagrosh/jmusicbot/audio/NowPlayingInfo.java @@ -13,8 +13,10 @@ public class NowPlayingInfo { public final long position; public final long duration; public final int volume; + public final int queueSize; + public final String footerInfo; - public NowPlayingInfo(AudioTrack track, Guild guild, boolean isPaused, int volume) { + public NowPlayingInfo(AudioTrack track, Guild guild, boolean isPaused, int volume, int queueSize, String footerInfo) { this.track = track; this.guild = guild; this.isPaused = isPaused; @@ -25,5 +27,7 @@ public NowPlayingInfo(AudioTrack track, Guild guild, boolean isPaused, int volum ? 0 : track.getDuration(); this.volume = volume; + this.queueSize = queueSize; + this.footerInfo = footerInfo; } } diff --git a/src/main/java/com/jagrosh/jmusicbot/audio/RequestMetadata.java b/src/main/java/com/jagrosh/jmusicbot/audio/RequestMetadata.java index a36fc4730..150dd324e 100644 --- a/src/main/java/com/jagrosh/jmusicbot/audio/RequestMetadata.java +++ b/src/main/java/com/jagrosh/jmusicbot/audio/RequestMetadata.java @@ -35,26 +35,35 @@ public class RequestMetadata { private static final ObjectMapper objectMapper = new ObjectMapper(); - public static final RequestMetadata EMPTY = new RequestMetadata((UserInfo) null, (RequestInfo) null); + 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("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) + 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() @@ -83,7 +92,7 @@ public String toString() public static RequestMetadata fromResultHandler(AudioTrack track, CommandEvent event) { - return new RequestMetadata(event.getAuthor(), new RequestInfo(event.getArgs(), track.getInfo().uri)); + return new RequestMetadata(event.getAuthor(), new RequestInfo(event.getArgs(), track.getInfo().uri), event.getChannel().getIdLong()); } public static class RequestInfo 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 000000000..6f0a981e4 --- /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/MusicCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/MusicCommand.java deleted file mode 100644 index c237c9cd7..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/MusicCommand.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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; - -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.settings.Settings; -import net.dv8tion.jda.api.entities.GuildVoiceState; -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; - -/** - * - * @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()); - TextChannel tchannel = settings.getTextChannel(event.getGuild()); - if(tchannel!=null && !event.getChannel().equals(tchannel)) - { - try - { - event.getMessage().delete().queue(); - } catch(PermissionException ignore){} - event.replyInDm(event.getClient().getError()+" You can only use that command in "+tchannel.getAsMention()+"!"); - return; - } - bot.getPlayerManager().setUpHandler(event.getGuild()); // no point constantly checking for this later - if(bePlaying && !((AudioHandler)event.getGuild().getAudioManager().getSendingHandler()).isMusicPlaying(event.getJDA())) - { - event.reply(event.getClient().getError()+" There must be music playing to use that!"); - return; - } - if(beListening) - { - AudioChannel current = event.getGuild().getSelfMember().getVoiceState().getChannel(); - if(current==null) - current = settings.getVoiceChannel(event.getGuild()); - GuildVoiceState userState = event.getMember().getVoiceState(); - if(userState.getChannel() == null || userState.isDeafened() || (current!=null && !userState.getChannel().equals(current))) - { - event.replyError("You must be listening in "+(current==null ? "a voice channel" : current.getAsMention())+" to use that!"); - return; - } - - VoiceChannel afkChannel = userState.getGuild().getAfkChannel(); - if(afkChannel != null && afkChannel.equals(userState.getChannel())) - { - event.replyError("You cannot use that command in an AFK channel!"); - return; - } - - if(event.getGuild().getSelfMember().getVoiceState().getChannel() == null) - { - try - { - event.getGuild().getAudioManager().openAudioConnection(userState.getChannel()); - } - catch(PermissionException ex) - { - event.reply(event.getClient().getError()+" I am unable to connect to "+userState.getChannel().getAsMention()+"!"); - return; - } - } - } - - doCommand(event); - } - - public abstract void doCommand(CommandEvent event); -} 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 000000000..df8d39ea8 --- /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 000000000..84bfe2933 --- /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/dj/MoveTrackCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/dj/MoveTrackCmd.java deleted file mode 100644 index 9199be01d..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/MoveTrackCmd.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.jagrosh.jmusicbot.commands.dj; - - -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.commands.DJCommand; -import com.jagrosh.jmusicbot.queue.AbstractQueue; - -/** - * Command that provides users the ability to move a track in the playlist. - */ -public class MoveTrackCmd extends DJCommand -{ - - public MoveTrackCmd(Bot bot) - { - super(bot); - 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 - { - // Validate the args - 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; - } - - // Validate that from and to are available - AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); - AbstractQueue queue = handler.getQueue(); - if (isUnavailablePosition(queue, from)) - { - String reply = String.format("`%d` is not a valid position in the queue!", from); - event.replyError(reply); - return; - } - if (isUnavailablePosition(queue, to)) - { - String reply = String.format("`%d` is not a valid position in the queue!", to); - event.replyError(reply); - return; - } - - // Move the track - QueuedTrack track = queue.moveItem(from - 1, to - 1); - String trackTitle = track.getTrack().getInfo().title; - String reply = String.format("Moved **%s** from position `%d` to `%d`.", trackTitle, from, to); - event.replySuccess(reply); - } - - private static boolean isUnavailablePosition(AbstractQueue queue, int position) - { - return (position < 1 || position > queue.size()); - } -} \ No newline at end of file diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/dj/PlaynextCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/dj/PlaynextCmd.java deleted file mode 100644 index 0d5924edc..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/PlaynextCmd.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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.dj; - -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.DJCommand; -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.track.AudioPlaylist; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import net.dv8tion.jda.api.entities.Message; - -/** - * - * @author John Grosh (john.a.grosh@gmail.com) - */ -public class PlaynextCmd extends DJCommand -{ - private final String loadingEmoji; - - public PlaynextCmd(Bot bot) - { - super(bot); - 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 -> bot.getPlayerManager().loadItemOrdered(event.getGuild(), args, new ResultHandler(m,event,false))); - } - - private class ResultHandler implements AudioLoadResultHandler - { - private final Message m; - private final CommandEvent event; - private final boolean ytsearch; - - private ResultHandler(Message m, CommandEvent event, boolean ytsearch) - { - this.m = m; - this.event = event; - this.ytsearch = ytsearch; - } - - private void loadSingle(AudioTrack track) - { - if(bot.getConfig().isTooLong(track)) - { - m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" This track (**"+track.getInfo().title+"**) is longer than the allowed maximum: `" - + TimeUtil.formatTime(track.getDuration())+"` > `"+ TimeUtil.formatTime(bot.getConfig().getMaxSeconds()*1000)+"`")).queue(); - return; - } - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - int pos = handler.addTrackToFront(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1; - String addMsg = FormatUtil.filter(event.getClient().getSuccess()+" Added **"+track.getInfo().title - +"** (`"+ TimeUtil.formatTime(track.getDuration())+"`) "+(pos==0?"to begin playing":" to the queue at position "+pos)); - m.editMessage(addMsg).queue(); - } - - @Override - public void trackLoaded(AudioTrack track) - { - loadSingle(track); - } - - @Override - public void playlistLoaded(AudioPlaylist playlist) - { - 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); - } - - @Override - public void noMatches() - { - if(ytsearch) - m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" No results found for `"+event.getArgs()+"`.")).queue(); - else - bot.getPlayerManager().loadItemOrdered(event.getGuild(), "ytsearch:"+event.getArgs(), new ResultHandler(m,event,true)); - } - - @Override - public void loadFailed(FriendlyException throwable) - { - if(throwable.severity==FriendlyException.Severity.COMMON) - m.editMessage(event.getClient().getError()+" Error loading: "+throwable.getMessage()).queue(); - else - m.editMessage(event.getClient().getError()+" Error loading track.").queue(); - } - } -} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/music/PlayCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/music/PlayCmd.java deleted file mode 100644 index 8f9e0e423..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/PlayCmd.java +++ /dev/null @@ -1,264 +0,0 @@ -/* - * 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.music; - -import com.jagrosh.jdautilities.command.Command; -import com.jagrosh.jdautilities.command.CommandEvent; -import com.jagrosh.jdautilities.menu.ButtonMenu; -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.DJCommand; -import com.jagrosh.jmusicbot.commands.MusicCommand; -import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist; -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 net.dv8tion.jda.api.exceptions.PermissionException; - -import java.util.concurrent.TimeUnit; - -/** - * - * @author John Grosh - */ -public class PlayCmd extends MusicCommand -{ - private final static String LOAD = "\uD83D\uDCE5"; // 📥 - private final static String CANCEL = "\uD83D\uDEAB"; // 🚫 - - private final String loadingEmoji; - - public PlayCmd(Bot bot) - { - super(bot); - 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) - { - if(event.getArgs().isEmpty() && event.getMessage().getAttachments().isEmpty()) - { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - if(handler.getPlayer().getPlayingTrack()!=null && handler.getPlayer().isPaused()) - { - if(DJCommand.checkDJPermission(event)) - { - handler.getPlayer().setPaused(false); - event.replySuccess("Resumed **"+handler.getPlayer().getPlayingTrack().getInfo().title+"**."); - } - else - event.replyError("Only DJs can unpause the player!"); - return; - } - StringBuilder builder = new StringBuilder(event.getClient().getWarning()+" Play Commands:\n"); - builder.append("\n`").append(event.getClient().getPrefix()).append(name).append(" ` - plays the first result from Youtube"); - builder.append("\n`").append(event.getClient().getPrefix()).append(name).append(" ` - plays the provided song, playlist, or stream"); - for(Command cmd: children) - builder.append("\n`").append(event.getClient().getPrefix()).append(name).append(" ").append(cmd.getName()).append(" ").append(cmd.getArguments()).append("` - ").append(cmd.getHelp()); - event.reply(builder.toString()); - 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 -> bot.getPlayerManager().loadItemOrdered(event.getGuild(), args, new ResultHandler(m,event,false))); - } - - private class ResultHandler implements AudioLoadResultHandler - { - private final Message m; - private final CommandEvent event; - private final boolean ytsearch; - - private ResultHandler(Message m, CommandEvent event, boolean ytsearch) - { - this.m = m; - this.event = event; - this.ytsearch = ytsearch; - } - - private void loadSingle(AudioTrack track, AudioPlaylist playlist) - { - if(bot.getConfig().isTooLong(track)) - { - m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" This track (**"+track.getInfo().title+"**) is longer than the allowed maximum: `" - + TimeUtil.formatTime(track.getDuration())+"` > `"+ TimeUtil.formatTime(bot.getConfig().getMaxSeconds()*1000)+"`")).queue(); - return; - } - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - int pos = handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1; - String addMsg = FormatUtil.filter(event.getClient().getSuccess()+" Added **"+track.getInfo().title - +"** (`"+ TimeUtil.formatTime(track.getDuration())+"`) "+(pos==0?"to begin playing":" to the queue at position "+pos)); - if(playlist==null || !event.getSelfMember().hasPermission(event.getTextChannel(), Permission.MESSAGE_ADD_REACTION)) - m.editMessage(addMsg).queue(); - else - { - new ButtonMenu.Builder() - .setText(addMsg+"\n"+event.getClient().getWarning()+" This track has a playlist of **"+playlist.getTracks().size()+"** tracks attached. Select "+LOAD+" to load playlist.") - .setChoices(LOAD, CANCEL) - .setEventWaiter(bot.getWaiter()) - .setTimeout(30, TimeUnit.SECONDS) - .setAction(re -> - { - if(re.getName().equals(LOAD)) - m.editMessage(addMsg+"\n"+event.getClient().getSuccess()+" Loaded **"+loadPlaylist(playlist, track)+"** additional tracks!").queue(); - else - m.editMessage(addMsg).queue(); - }).setFinalAction(m -> - { - try{ m.clearReactions().queue(); }catch(PermissionException ignore) {} - }).build().display(m); - } - } - - private int loadPlaylist(AudioPlaylist playlist, AudioTrack exclude) - { - int[] count = {0}; - playlist.getTracks().stream().forEach((track) -> { - if(!bot.getConfig().isTooLong(track) && !track.equals(exclude)) - { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event))); - count[0]++; - } - }); - return count[0]; - } - - @Override - public void trackLoaded(AudioTrack track) - { - loadSingle(track, null); - } - - @Override - public void playlistLoaded(AudioPlaylist playlist) - { - 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); - if(playlist.getTracks().size() == 0) - { - m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" The playlist "+(playlist.getName()==null ? "" : "(**"+playlist.getName() - +"**) ")+" could not be loaded or contained 0 entries")).queue(); - } - else if(count==0) - { - m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" All entries in this playlist "+(playlist.getName()==null ? "" : "(**"+playlist.getName() - +"**) ")+"were longer than the allowed maximum (`"+bot.getConfig().getMaxTime()+"`)")).queue(); - } - else - { - m.editMessage(FormatUtil.filter(event.getClient().getSuccess()+" Found " - +(playlist.getName()==null?"a playlist":"playlist **"+playlist.getName()+"**")+" with `" - + playlist.getTracks().size()+"` entries; added to the queue!" - + (count - { - 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/music/QueueCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/music/QueueCmd.java deleted file mode 100644 index 066cd02cf..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/QueueCmd.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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.music; - -import com.jagrosh.jdautilities.command.CommandEvent; -import com.jagrosh.jdautilities.menu.Paginator; -import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.audio.QueuedTrack; -import com.jagrosh.jmusicbot.commands.MusicCommand; -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 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.List; -import java.util.concurrent.TimeUnit; - -/** - * - * @author John Grosh - */ -public class QueueCmd extends MusicCommand -{ - private final Paginator.Builder builder; - - public QueueCmd(Bot bot) - { - super(bot); - 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){ - // do nothing - }}) - .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){} - AudioHandler ah = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - List list = ah.getQueue().getList(); - if(list.isEmpty()) - { - MessageCreateData nowp = ah.getNowPlaying(event.getJDA()); - MessageCreateData nonowp = ah.getNoMusicPlaying(event.getJDA()); - MessageCreateData built = new MessageCreateBuilder() - .setContent(event.getClient().getWarning() + " There is no music in the queue!") - .setEmbeds((nowp==null ? nonowp : nowp).getEmbeds().get(0)).build(); - event.reply(built, m -> - { - if(nowp!=null) - bot.getNowplayingHandler().setLastNPMessage(m); - }); - return; - } - String[] songs = new String[list.size()]; - long total = 0; - for(int i=0; i getQueueTitle(ah, event.getClient().getSuccess(), songs.length, fintotal, settings.getRepeatMode(), settings.getQueueType())) - .setItems(songs) - .setUsers(event.getAuthor()) - .setColor(event.getSelfMember().getColors().getPrimary()) - ; - builder.build().paginate(event.getChannel(), pagenum); - } - - private String getQueueTitle(AudioHandler ah, String success, int songslength, long total, RepeatMode repeatmode, QueueType queueType) - { - StringBuilder sb = new StringBuilder(); - if(ah.getPlayer().getPlayingTrack()!=null) - { - sb.append(ah.getStatusEmoji()).append(" **") - .append(ah.getPlayer().getPlayingTrack().getInfo().title).append("**\n"); - } - return FormatUtil.filter(sb.append(success).append(" Current Queue | ").append(songslength) - .append(" entries | `").append(TimeUtil.formatTime(total)).append("` ") - .append("| ").append(queueType.getEmoji()).append(" `").append(queueType.getUserFriendlyName()).append('`') - .append(repeatmode.getEmoji() != null ? " | "+repeatmode.getEmoji() : "").toString()); - } -} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/music/RemoveCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/music/RemoveCmd.java deleted file mode 100644 index 37382e293..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/RemoveCmd.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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.music; - -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.commands.MusicCommand; -import com.jagrosh.jmusicbot.settings.Settings; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.User; - -/** - * - * @author John Grosh - */ -public class RemoveCmd extends MusicCommand -{ - public RemoveCmd(Bot bot) - { - super(bot); - 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) - { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - if(handler.getQueue().isEmpty()) - { - event.replyError("There is nothing in the queue!"); - return; - } - if(event.getArgs().equalsIgnoreCase("all")) - { - int count = handler.getQueue().removeAll(event.getAuthor().getIdLong()); - if(count==0) - event.replyWarning("You don't have any songs in the queue!"); - else - event.replySuccess("Successfully removed your "+count+" entries."); - return; - } - int pos; - try { - pos = Integer.parseInt(event.getArgs()); - } catch(NumberFormatException e) { - pos = 0; - } - if(pos<1 || pos>handler.getQueue().size()) - { - event.replyError("Position must be a valid integer between 1 and "+handler.getQueue().size()+"!"); - return; - } - Settings settings = event.getClient().getSettingsFor(event.getGuild()); - boolean isDJ = event.getMember().hasPermission(Permission.MANAGE_SERVER); - if(!isDJ) - isDJ = event.getMember().getRoles().contains(settings.getRole(event.getGuild())); - QueuedTrack qt = handler.getQueue().get(pos-1); - if(qt.getIdentifier()==event.getAuthor().getIdLong()) - { - handler.getQueue().remove(pos-1); - event.replySuccess("Removed **"+qt.getTrack().getInfo().title+"** from the queue"); - } - else if(isDJ) - { - handler.getQueue().remove(pos-1); - User u; - try { - u = event.getJDA().getUserById(qt.getIdentifier()); - } catch(Exception e) { - u = null; - } - event.replySuccess("Removed **"+qt.getTrack().getInfo().title - +"** from the queue (requested by "+(u==null ? "someone" : "**"+u.getName()+"**")+")"); - } - else - { - event.replyError("You cannot remove **"+qt.getTrack().getInfo().title+"** because you didn't add it!"); - } - } -} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/music/SeekCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/music/SeekCmd.java deleted file mode 100644 index 6cf0f7958..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/SeekCmd.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2020 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.music; - -import com.jagrosh.jdautilities.command.CommandEvent; -import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.audio.RequestMetadata; -import com.jagrosh.jmusicbot.commands.DJCommand; -import com.jagrosh.jmusicbot.commands.MusicCommand; -import com.jagrosh.jmusicbot.utils.TimeUtil; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - - -/** - * @author Whew., Inc. - */ -public class SeekCmd extends MusicCommand -{ - private final static Logger LOG = LoggerFactory.getLogger("Seeking"); - - public SeekCmd(Bot bot) - { - super(bot); - 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) - { - AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); - AudioTrack playingTrack = handler.getPlayer().getPlayingTrack(); - if (!playingTrack.isSeekable()) - { - event.replyError("This track is not seekable."); - return; - } - - - if (!DJCommand.checkDJPermission(event) && playingTrack.getUserData(RequestMetadata.class).getOwner() != event.getAuthor().getIdLong()) - { - event.replyError("You cannot seek **" + playingTrack.getInfo().title + "** because you didn't add it!"); - return; - } - - String args = event.getArgs(); - TimeUtil.SeekTime seekTime = TimeUtil.parseTime(args); - if (seekTime == null) - { - event.replyError("Invalid seek! Expected format: " + arguments + "\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 > trackDuration) - { - event.replyError("Cannot seek to `" + TimeUtil.formatTime(seekMilliseconds) + "` because the current track is `" + TimeUtil.formatTime(trackDuration) + "` long!"); - return; - } - - try - { - playingTrack.setPosition(seekMilliseconds); - } - catch (Exception e) - { - event.replyError("An error occurred while trying to seek: " + e.getMessage()); - LOG.warn("Failed to seek track " + playingTrack.getIdentifier(), e); - return; - } - event.replySuccess("Successfully seeked to `" + TimeUtil.formatTime(playingTrack.getPosition()) + "/" + TimeUtil.formatTime(playingTrack.getDuration()) + "`!"); - } -} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/music/SkipCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/music/SkipCmd.java deleted file mode 100644 index 875d21299..000000000 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/SkipCmd.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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.music; - -import com.jagrosh.jdautilities.command.CommandEvent; -import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.audio.RequestMetadata; -import com.jagrosh.jmusicbot.commands.MusicCommand; -import com.jagrosh.jmusicbot.utils.FormatUtil; - -/** - * - * @author John Grosh - */ -public class SkipCmd extends MusicCommand -{ - public SkipCmd(Bot bot) - { - super(bot); - 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) - { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - RequestMetadata rm = handler.getRequestMetadata(); - double skipRatio = bot.getSettingsManager().getSettings(event.getGuild()).getSkipRatio(); - if(skipRatio == -1) { - skipRatio = bot.getConfig().getSkipRatio(); - } - if(event.getAuthor().getIdLong() == rm.getOwner() || skipRatio == 0) - { - event.reply(event.getClient().getSuccess()+" Skipped **"+handler.getPlayer().getPlayingTrack().getInfo().title+"**"); - handler.getPlayer().stopTrack(); - } - else - { - int listeners = (int)event.getSelfMember().getVoiceState().getChannel().getMembers().stream() - .filter(m -> !m.getUser().isBot() && !m.getVoiceState().isDeafened()).count(); - String msg; - if(handler.getVotes().contains(event.getAuthor().getId())) - msg = event.getClient().getWarning()+" You already voted to skip this song `["; - else - { - msg = event.getClient().getSuccess()+" You voted to skip the song `["; - handler.getVotes().add(event.getAuthor().getId()); - } - int skippers = (int)event.getSelfMember().getVoiceState().getChannel().getMembers().stream() - .filter(m -> handler.getVotes().contains(m.getUser().getId())).count(); - int required = (int)Math.ceil(listeners * skipRatio); - msg += skippers + " votes, " + required + "/" + listeners + " needed]`"; - if(skippers>=required) - { - msg += "\n" + event.getClient().getSuccess() + " Skipped **" + handler.getPlayer().getPlayingTrack().getInfo().title - + "** " + (rm.getOwner() == 0L ? "(autoplay)" : "(requested by **" + FormatUtil.formatUsername(rm.user) + "**)"); - handler.getPlayer().stopTrack(); - } - event.reply(msg); - } - } - -} diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/AdminCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/AdminCommand.java similarity index 96% rename from src/main/java/com/jagrosh/jmusicbot/commands/AdminCommand.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/AdminCommand.java index 8d0168c67..fa9f8b5a4 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/AdminCommand.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/AdminCommand.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands; +package com.jagrosh.jmusicbot.commands.v1; import com.jagrosh.jdautilities.command.Command; import net.dv8tion.jda.api.Permission; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/CommandFactory.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/CommandFactory.java similarity index 55% rename from src/main/java/com/jagrosh/jmusicbot/commands/CommandFactory.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/CommandFactory.java index 75a43497f..271b47985 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/CommandFactory.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/CommandFactory.java @@ -1,4 +1,4 @@ -package com.jagrosh.jmusicbot.commands; +package com.jagrosh.jmusicbot.commands.v1; import com.jagrosh.jdautilities.command.CommandClient; import com.jagrosh.jdautilities.command.CommandClientBuilder; @@ -7,11 +7,35 @@ import com.jagrosh.jmusicbot.Bot; import com.jagrosh.jmusicbot.BotConfig; import com.jagrosh.jmusicbot.JMusicBot; -import com.jagrosh.jmusicbot.commands.admin.*; -import com.jagrosh.jmusicbot.commands.dj.*; -import com.jagrosh.jmusicbot.commands.general.SettingsCmd; -import com.jagrosh.jmusicbot.commands.music.*; -import com.jagrosh.jmusicbot.commands.owner.*; +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.PrefixSlashCmd; +import com.jagrosh.jmusicbot.commands.v2.admin.QueuetypeSlashCmd; +import com.jagrosh.jmusicbot.commands.v2.admin.SetdjSlashCmd; +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.NowPlayingSlashCmd; +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; @@ -74,6 +98,36 @@ public static CommandClient createCommandClient(BotConfig config, SettingsManage new SetnameCmd(bot), new SetstatusCmd(bot), new ShutdownCmd(bot) + ).addSlashCommands( + // Music commands + new NowPlayingSlashCmd(bot), + new PlaySlashCmd(bot), + new PlaylistsSlashCmd(bot), + new QueueSlashCmd(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 SetdjSlashCmd(bot), + new SettcSlashCmd(bot), + new SetvcSlashCmd(bot), + new SkipratioSlashCmd(bot) ).setManualUpsert(true); if (config.useEval()) diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/DJCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/DJCommand.java similarity index 71% rename from src/main/java/com/jagrosh/jmusicbot/commands/DJCommand.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/DJCommand.java index b0f573667..2e54fb4e3 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/DJCommand.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/DJCommand.java @@ -13,12 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands; +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; /** @@ -45,4 +47,17 @@ public static boolean checkDJPermission(CommandEvent event) 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 000000000..4037906c3 --- /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/OwnerCommand.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/OwnerCommand.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/OwnerCommand.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/OwnerCommand.java index 1e78d8227..cdcea011b 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/OwnerCommand.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/OwnerCommand.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands; +package com.jagrosh.jmusicbot.commands.v1; import com.jagrosh.jdautilities.command.Command; 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 000000000..c1b9fca62 --- /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/admin/PrefixCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/PrefixCmd.java similarity index 94% rename from src/main/java/com/jagrosh/jmusicbot/commands/admin/PrefixCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/PrefixCmd.java index 1eaea786c..ffe72904c 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/admin/PrefixCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/PrefixCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.admin; +package com.jagrosh.jmusicbot.commands.v1.admin; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.AdminCommand; +import com.jagrosh.jmusicbot.commands.v1.AdminCommand; import com.jagrosh.jmusicbot.settings.Settings; /** diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/admin/QueueTypeCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/QueueTypeCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/admin/QueueTypeCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/QueueTypeCmd.java index 798e620e6..dce15844e 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/admin/QueueTypeCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/QueueTypeCmd.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.admin; +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.AdminCommand; +import com.jagrosh.jmusicbot.commands.v1.AdminCommand; import com.jagrosh.jmusicbot.settings.QueueType; import com.jagrosh.jmusicbot.settings.Settings; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SetdjCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetdjCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/admin/SetdjCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetdjCmd.java index 34ce6354a..014434f97 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SetdjCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetdjCmd.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.admin; +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.AdminCommand; +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; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SettcCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SettcCmd.java similarity index 96% rename from src/main/java/com/jagrosh/jmusicbot/commands/admin/SettcCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SettcCmd.java index 26841ddb1..14a5a2cf5 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SettcCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SettcCmd.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.admin; +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.AdminCommand; +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; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SetvcCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetvcCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/admin/SetvcCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetvcCmd.java index 180e0b33f..1c4b60287 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SetvcCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SetvcCmd.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.admin; +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.AdminCommand; +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; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SkipratioCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SkipratioCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/admin/SkipratioCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SkipratioCmd.java index f2a06d7b6..06d1b1733 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/admin/SkipratioCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/admin/SkipratioCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.admin; +package com.jagrosh.jmusicbot.commands.v1.admin; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.AdminCommand; +import com.jagrosh.jmusicbot.commands.v1.AdminCommand; import com.jagrosh.jmusicbot.settings.Settings; /** diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/dj/ForceRemoveCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceRemoveCmd.java similarity index 62% rename from src/main/java/com/jagrosh/jmusicbot/commands/dj/ForceRemoveCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceRemoveCmd.java index 897b1c4bd..4a827ddc9 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/ForceRemoveCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceRemoveCmd.java @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.dj; +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.audio.AudioHandler; -import com.jagrosh.jmusicbot.commands.DJCommand; +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; @@ -35,9 +35,12 @@ */ 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 = ""; @@ -56,65 +59,56 @@ public void doCommand(CommandEvent event) return; } - AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); - if (handler.getQueue().isEmpty()) + if (musicService.isQueueEmpty(event.getGuild())) { event.replyError("There is nothing in the queue!"); return; } - - User target; List found = FinderUtil.findMembers(event.getArgs(), event.getGuild()); - if(found.isEmpty()) + if (found.isEmpty()) { event.replyError("Unable to find the user!"); return; } - else if(found.size()>1) + else if (found.size() > 1) { OrderedMenu.Builder builder = new OrderedMenu.Builder(); - for(int i=0; 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()); + .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; } - else - { - target = found.get(0).getUser(); - } - - removeAllEntries(target, event); + removeAllEntries(found.get(0).getUser(), event); } private void removeAllEntries(User target, CommandEvent event) { - int count = ((AudioHandler) event.getGuild().getAudioManager().getSendingHandler()).getQueue().removeAll(target.getIdLong()); + int count = musicService.removeAllTracksByUser(event.getGuild(), target.getIdLong()); if (count == 0) { - event.replyWarning("**"+target.getName()+"** doesn't have any songs in the queue!"); + event.replyWarning("**" + target.getName() + "** doesn't have any songs in the queue!"); } else { - event.replySuccess("Successfully removed `"+count+"` entries from "+FormatUtil.formatUsername(target)+"."); + event.replySuccess("Successfully removed `" + count + "` entries from " + FormatUtil.formatUsername(target) + "."); } } } diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/dj/ForceskipCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceskipCmd.java similarity index 57% rename from src/main/java/com/jagrosh/jmusicbot/commands/dj/ForceskipCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceskipCmd.java index 29b42a138..df66a78ed 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/ForceskipCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/ForceskipCmd.java @@ -13,24 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.dj; +package com.jagrosh.jmusicbot.commands.v1.dj; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.audio.RequestMetadata; -import com.jagrosh.jmusicbot.commands.DJCommand; -import com.jagrosh.jmusicbot.utils.FormatUtil; +import com.jagrosh.jmusicbot.commands.v1.DJCommand; +import com.jagrosh.jmusicbot.service.MusicService; /** * * @author John Grosh */ -public class ForceskipCmd extends DJCommand +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); @@ -38,12 +39,12 @@ public ForceskipCmd(Bot bot) } @Override - public void doCommand(CommandEvent event) + public void doCommand(CommandEvent event) { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - RequestMetadata rm = handler.getRequestMetadata(); - event.reply(event.getClient().getSuccess()+" Skipped **"+handler.getPlayer().getPlayingTrack().getInfo().title - +"** "+(rm.getOwner() == 0L ? "(autoplay)" : "(requested by **" + FormatUtil.formatUsername(rm.user) + "**)")); - handler.getPlayer().stopTrack(); + 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 000000000..e358046e7 --- /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/dj/PauseCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PauseCmd.java similarity index 63% rename from src/main/java/com/jagrosh/jmusicbot/commands/dj/PauseCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PauseCmd.java index 9a923f02c..3101dba34 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/PauseCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/PauseCmd.java @@ -13,22 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.dj; +package com.jagrosh.jmusicbot.commands.v1.dj; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.commands.DJCommand; +import com.jagrosh.jmusicbot.commands.v1.DJCommand; +import com.jagrosh.jmusicbot.service.MusicService; /** * * @author John Grosh */ -public class PauseCmd extends DJCommand +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); @@ -36,15 +39,15 @@ public PauseCmd(Bot bot) } @Override - public void doCommand(CommandEvent event) + public void doCommand(CommandEvent event) { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - if(handler.getPlayer().isPaused()) + if (musicService.isPaused(event.getGuild())) { - event.replyWarning("The player is already paused! Use `"+event.getClient().getPrefix()+"play` to unpause!"); + event.replyWarning("The player is already paused! Use `" + event.getClient().getPrefix() + "play` to unpause!"); return; } - handler.getPlayer().setPaused(true); - event.replySuccess("Paused **"+handler.getPlayer().getPlayingTrack().getInfo().title+"**. Type `"+event.getClient().getPrefix()+"play` to unpause!"); + + 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 000000000..fc45ead75 --- /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/dj/RepeatCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/RepeatCmd.java similarity index 66% rename from src/main/java/com/jagrosh/jmusicbot/commands/dj/RepeatCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/RepeatCmd.java index e355b28a0..7f41bfd04 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/RepeatCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/RepeatCmd.java @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.dj; +package com.jagrosh.jmusicbot.commands.v1.dj; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.DJCommand; +import com.jagrosh.jmusicbot.commands.v1.DJCommand; +import com.jagrosh.jmusicbot.service.MusicService; import com.jagrosh.jmusicbot.settings.RepeatMode; -import com.jagrosh.jmusicbot.settings.Settings; /** * @@ -27,39 +27,40 @@ */ 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) + protected void execute(CommandEvent event) { String args = event.getArgs(); RepeatMode value; - Settings settings = event.getClient().getSettingsFor(event.getGuild()); - if(args.isEmpty()) + RepeatMode currentMode = musicService.getRepeatMode(event.getGuild()); + + if (args.isEmpty()) { - if(settings.getRepeatMode() == RepeatMode.OFF) - value = RepeatMode.ALL; - else - value = RepeatMode.OFF; + value = (currentMode == RepeatMode.OFF) ? RepeatMode.ALL : RepeatMode.OFF; } - else if(args.equalsIgnoreCase("false") || args.equalsIgnoreCase("off")) + else if (args.equalsIgnoreCase("false") || args.equalsIgnoreCase("off")) { value = RepeatMode.OFF; } - else if(args.equalsIgnoreCase("true") || args.equalsIgnoreCase("on") || args.equalsIgnoreCase("all")) + else if (args.equalsIgnoreCase("true") || args.equalsIgnoreCase("on") || args.equalsIgnoreCase("all")) { value = RepeatMode.ALL; } - else if(args.equalsIgnoreCase("one") || args.equalsIgnoreCase("single")) + else if (args.equalsIgnoreCase("one") || args.equalsIgnoreCase("single")) { value = RepeatMode.SINGLE; } @@ -68,8 +69,9 @@ else if(args.equalsIgnoreCase("one") || args.equalsIgnoreCase("single")) event.replyError("Valid options are `off`, `all` or `single` (or leave empty to toggle between `off` and `all`)"); return; } - settings.setRepeatMode(value); - event.replySuccess("Repeat mode is now `"+value.getUserFriendlyName()+"`"); + + musicService.setRepeatMode(event.getGuild(), value); + event.replySuccess("Repeat mode is now `" + value.getUserFriendlyName() + "`"); } @Override diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/dj/SkiptoCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/SkiptoCmd.java similarity index 58% rename from src/main/java/com/jagrosh/jmusicbot/commands/dj/SkiptoCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/SkiptoCmd.java index e7fffb531..a4657c749 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/SkiptoCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/SkiptoCmd.java @@ -13,22 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.dj; +package com.jagrosh.jmusicbot.commands.v1.dj; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.commands.DJCommand; +import com.jagrosh.jmusicbot.commands.v1.DJCommand; +import com.jagrosh.jmusicbot.service.MusicService; /** * * @author John Grosh */ -public class SkiptoCmd extends DJCommand +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 = ""; @@ -37,26 +40,30 @@ public SkiptoCmd(Bot bot) } @Override - public void doCommand(CommandEvent event) + public void doCommand(CommandEvent event) { - int index = 0; + int index; try { index = Integer.parseInt(event.getArgs()); } - catch(NumberFormatException e) + catch (NumberFormatException e) { - event.reply(event.getClient().getError()+" `"+event.getArgs()+"` is not a valid integer!"); + event.replyError("`" + event.getArgs() + "` is not a valid integer!"); return; } - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - if(index<1 || index>handler.getQueue().size()) + + int queueSize = musicService.getQueueSize(event.getGuild()); + if (index < 1 || index > queueSize) { - event.reply(event.getClient().getError()+" Position must be a valid integer between 1 and "+handler.getQueue().size()+"!"); + event.replyError("Position must be a valid integer between 1 and " + queueSize + "!"); return; } - handler.getQueue().skip(index-1); - event.reply(event.getClient().getSuccess()+" Skipped to **"+handler.getQueue().get(0).getTrack().getInfo().title+"**"); - handler.getPlayer().stopTrack(); + + 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/dj/StopCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/StopCmd.java similarity index 66% rename from src/main/java/com/jagrosh/jmusicbot/commands/dj/StopCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/StopCmd.java index 8caa67c8d..d055a120f 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/StopCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/StopCmd.java @@ -13,22 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.dj; +package com.jagrosh.jmusicbot.commands.v1.dj; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.commands.DJCommand; +import com.jagrosh.jmusicbot.commands.v1.DJCommand; +import com.jagrosh.jmusicbot.service.MusicService; /** * * @author John Grosh */ -public class StopCmd extends DJCommand +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); @@ -36,11 +39,9 @@ public StopCmd(Bot bot) } @Override - public void doCommand(CommandEvent event) + public void doCommand(CommandEvent event) { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - handler.stopAndClear(); - event.getGuild().getAudioManager().closeAudioConnection(); - event.reply(event.getClient().getSuccess()+" The player has stopped and the queue has been cleared."); + 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/dj/VolumeCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/VolumeCmd.java similarity index 53% rename from src/main/java/com/jagrosh/jmusicbot/commands/dj/VolumeCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/VolumeCmd.java index 0b50c648f..99f47653d 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/dj/VolumeCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/dj/VolumeCmd.java @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.dj; +package com.jagrosh.jmusicbot.commands.v1.dj; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.commands.DJCommand; -import com.jagrosh.jmusicbot.settings.Settings; +import com.jagrosh.jmusicbot.commands.v1.DJCommand; +import com.jagrosh.jmusicbot.service.MusicService; import com.jagrosh.jmusicbot.utils.FormatUtil; /** @@ -28,9 +27,12 @@ */ 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"; @@ -40,30 +42,33 @@ public VolumeCmd(Bot bot) @Override public void doCommand(CommandEvent event) { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - Settings settings = event.getClient().getSettingsFor(event.getGuild()); - int volume = handler.getPlayer().getVolume(); - if(event.getArgs().isEmpty()) + int currentVolume = musicService.getVolume(event.getGuild()); + + if (event.getArgs().isEmpty()) { - event.reply(FormatUtil.volumeIcon(volume)+" Current volume is `"+volume+"`"); + event.reply(FormatUtil.volumeIcon(currentVolume) + " Current volume is `" + currentVolume + "`"); } else { - int nvolume; - try{ - nvolume = Integer.parseInt(event.getArgs()); - }catch(NumberFormatException e){ - nvolume = -1; + 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!"); } - if(nvolume<0 || nvolume>150) - event.reply(event.getClient().getError()+" Volume must be a valid integer between 0 and 150!"); else { - handler.getPlayer().setVolume(nvolume); - settings.setVolume(nvolume); - event.reply(FormatUtil.volumeIcon(nvolume)+" Volume changed from `"+volume+"` to `"+nvolume+"`"); + event.reply(FormatUtil.volumeIcon(result.newVolume) + " Volume changed from `" + result.oldVolume + "` to `" + result.newVolume + "`"); } } } - } diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/general/SettingsCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/general/SettingsCmd.java similarity index 98% rename from src/main/java/com/jagrosh/jmusicbot/commands/general/SettingsCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/general/SettingsCmd.java index 6fd1964ff..7a54eaeb9 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/general/SettingsCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/general/SettingsCmd.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.general; +package com.jagrosh.jmusicbot.commands.v1.general; import com.jagrosh.jdautilities.command.Command; import com.jagrosh.jdautilities.command.CommandEvent; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/music/LyricsCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/LyricsCmd.java similarity index 97% rename from src/main/java/com/jagrosh/jmusicbot/commands/music/LyricsCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/music/LyricsCmd.java index b6bb9665e..2a47f7e1c 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/LyricsCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/LyricsCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.music; +package com.jagrosh.jmusicbot.commands.v1.music; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.MusicCommand; +import com.jagrosh.jmusicbot.commands.v1.MusicCommand; // Lyrics functionality removed - JLyrics dependency removed // import net.dv8tion.jda.api.EmbedBuilder; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/music/NowPlayingCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/NowPlayingCmd.java similarity index 64% rename from src/main/java/com/jagrosh/jmusicbot/commands/music/NowPlayingCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/music/NowPlayingCmd.java index 5a742828e..18742cc12 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/NowPlayingCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/NowPlayingCmd.java @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.music; +package com.jagrosh.jmusicbot.commands.v1.music; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.commands.MusicCommand; +import com.jagrosh.jmusicbot.commands.v1.MusicCommand; +import com.jagrosh.jmusicbot.service.MusicService; import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.utils.messages.MessageCreateData; /** * @@ -28,9 +27,12 @@ */ 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); @@ -38,25 +40,24 @@ public NowPlayingCmd(Bot bot) } @Override - public void doCommand(CommandEvent event) + public void doCommand(CommandEvent event) { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); + MusicService.NowPlayingInfo info = musicService.getNowPlayingInfo(event.getGuild(), event.getJDA()); - if(handler == null) + if (info == null) { - event.reply(event.getClient().getWarning() + " There is no music playing in this server."); + event.replyWarning("There is no music playing in this server."); return; } - MessageCreateData nowPlayingMsg = handler.getNowPlaying(event.getJDA()); - if(nowPlayingMsg==null) + if (info.nowPlayingMessage == null || !info.isPlaying) { - event.reply(handler.getNoMusicPlaying(event.getJDA())); + event.reply(info.noMusicMessage); bot.getNowplayingHandler().clearLastNPMessage(event.getGuild()); } else { - event.reply(nowPlayingMsg, msg -> bot.getNowplayingHandler().setLastNPMessage(msg)); + 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 000000000..f5e1ac016 --- /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/music/PlaylistsCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlaylistsCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/music/PlaylistsCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlaylistsCmd.java index df615a0d5..1026254cd 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/PlaylistsCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/PlaylistsCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.music; +package com.jagrosh.jmusicbot.commands.v1.music; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.MusicCommand; +import com.jagrosh.jmusicbot.commands.v1.MusicCommand; import java.util.List; 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 000000000..6745be1c5 --- /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 000000000..b3e3daf9a --- /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/music/SCSearchCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SCSearchCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/music/SCSearchCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SCSearchCmd.java index 63e4c679e..37db82834 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/SCSearchCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SCSearchCmd.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.music; +package com.jagrosh.jmusicbot.commands.v1.music; import com.jagrosh.jmusicbot.Bot; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/music/SearchCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SearchCmd.java similarity index 98% rename from src/main/java/com/jagrosh/jmusicbot/commands/music/SearchCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SearchCmd.java index 2d0cac3ae..b405fa8ac 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/SearchCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SearchCmd.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.music; +package com.jagrosh.jmusicbot.commands.v1.music; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jdautilities.menu.OrderedMenu; @@ -21,7 +21,7 @@ import com.jagrosh.jmusicbot.audio.AudioHandler; import com.jagrosh.jmusicbot.audio.QueuedTrack; import com.jagrosh.jmusicbot.audio.RequestMetadata; -import com.jagrosh.jmusicbot.commands.MusicCommand; +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; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SeekCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SeekCmd.java new file mode 100644 index 000000000..8be0d0105 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/SeekCmd.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020 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 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/music/ShuffleCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/ShuffleCmd.java similarity index 74% rename from src/main/java/com/jagrosh/jmusicbot/commands/music/ShuffleCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/music/ShuffleCmd.java index c151f4d83..9f19f8866 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/music/ShuffleCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/music/ShuffleCmd.java @@ -13,22 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.music; +package com.jagrosh.jmusicbot.commands.v1.music; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.audio.AudioHandler; -import com.jagrosh.jmusicbot.commands.MusicCommand; +import com.jagrosh.jmusicbot.commands.v1.MusicCommand; +import com.jagrosh.jmusicbot.service.MusicService; /** * * @author John Grosh */ -public class ShuffleCmd extends MusicCommand +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); @@ -37,11 +40,10 @@ public ShuffleCmd(Bot bot) } @Override - public void doCommand(CommandEvent event) + public void doCommand(CommandEvent event) { - AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler(); - int s = handler.getQueue().shuffle(event.getAuthor().getIdLong()); - switch (s) + 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!"); @@ -50,9 +52,8 @@ public void doCommand(CommandEvent event) event.replyWarning("You only have one song in the queue!"); break; default: - event.replySuccess("You successfully shuffled your "+s+" entries."); + 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 000000000..34f444f1e --- /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/owner/AutoplaylistCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/AutoplaylistCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/AutoplaylistCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/AutoplaylistCmd.java index 107340de7..98b710065 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/AutoplaylistCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/AutoplaylistCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; import com.jagrosh.jmusicbot.settings.Settings; /** diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/DebugCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/DebugCmd.java similarity index 99% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/DebugCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/DebugCmd.java index d8d775295..23b293b77 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/DebugCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/DebugCmd.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import java.nio.file.Files; import java.time.Duration; @@ -29,7 +29,7 @@ import com.jagrosh.jmusicbot.BotConfig; import com.jagrosh.jmusicbot.audio.AudioHandler; import com.jagrosh.jmusicbot.audio.AudioSource; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +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; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/EvalCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/EvalCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/EvalCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/EvalCmd.java index 84ce64301..de329c7f6 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/EvalCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/EvalCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; import net.dv8tion.jda.api.entities.channel.ChannelType; import javax.script.ScriptEngine; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/PlaylistCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/PlaylistCmd.java similarity index 98% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/PlaylistCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/PlaylistCmd.java index 3acb0fb6f..ddf0d923d 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/PlaylistCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/PlaylistCmd.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +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.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist; import java.io.IOException; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetavatarCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetavatarCmd.java similarity index 95% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/SetavatarCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetavatarCmd.java index 37dafd6cc..d0a852b9b 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetavatarCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetavatarCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; import com.jagrosh.jmusicbot.utils.OtherUtil; import net.dv8tion.jda.api.entities.Icon; diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetgameCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetgameCmd.java similarity index 98% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/SetgameCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetgameCmd.java index 7950d5429..a6a5fbb11 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetgameCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetgameCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; import net.dv8tion.jda.api.entities.Activity; /** diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetnameCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetnameCmd.java similarity index 94% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/SetnameCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetnameCmd.java index 94844d471..35432db43 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetnameCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetnameCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; import net.dv8tion.jda.api.exceptions.RateLimitedException; /** diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetstatusCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetstatusCmd.java similarity index 94% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/SetstatusCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetstatusCmd.java index 06b769832..d9a2627e4 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/SetstatusCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/SetstatusCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; import net.dv8tion.jda.api.OnlineStatus; /** diff --git a/src/main/java/com/jagrosh/jmusicbot/commands/owner/ShutdownCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ShutdownCmd.java similarity index 92% rename from src/main/java/com/jagrosh/jmusicbot/commands/owner/ShutdownCmd.java rename to src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ShutdownCmd.java index 2a59cecf8..cb276baf7 100644 --- a/src/main/java/com/jagrosh/jmusicbot/commands/owner/ShutdownCmd.java +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v1/owner/ShutdownCmd.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.jagrosh.jmusicbot.commands.owner; +package com.jagrosh.jmusicbot.commands.v1.owner; import com.jagrosh.jdautilities.command.CommandEvent; import com.jagrosh.jmusicbot.Bot; -import com.jagrosh.jmusicbot.commands.OwnerCommand; +import com.jagrosh.jmusicbot.commands.v1.OwnerCommand; /** * 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 000000000..cf9ce46b3 --- /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 000000000..741c3e775 --- /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 000000000..06a2020b4 --- /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/SlashOutputAdapters.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/SlashOutputAdapters.java new file mode 100644 index 000000000..23e071232 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/SlashOutputAdapters.java @@ -0,0 +1,162 @@ +/* + * 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.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 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 onSuccess) + { + hook.editOriginal(content).queue(onSuccess); + } + + @Override + public void editNowPlaying(AudioHandler handler) + { + hook.editOriginal(MessageEditData.fromCreateData(handler.getNowPlaying(jda))).queue(); + } + + @Override + public void editNoMusic(AudioHandler handler) + { + hook.editOriginal(MessageEditData.fromCreateData(handler.getNoMusicPlaying(jda))).queue(); + } + + @Override + public void onShowHelp() + { + hook.editOriginal(warningEmoji + " Please include a song title or URL!").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 000000000..5dfec7efc --- /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 000000000..9f39ea697 --- /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 000000000..a5ebcd11d --- /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 000000000..7aa820473 --- /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/SetvcSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/admin/SetvcSlashCmd.java new file mode 100644 index 000000000..d38ef5043 --- /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 000000000..a305f5c71 --- /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 000000000..6ff3549d2 --- /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 000000000..14893f321 --- /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 000000000..2598e8876 --- /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 000000000..3275e3e15 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/PauseSlashCmd.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.audio.AudioHandler; +import com.jagrosh.jmusicbot.commands.v2.DJSlashCommand; + +/** + * 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); + event.reply(event.getClient().getSuccess() + " Paused **" + handler.getPlayer().getPlayingTrack().getInfo().title + + "**. 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 000000000..af48306a1 --- /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 000000000..b415150de --- /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 000000000..af7c0a93d --- /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 000000000..bb20ba454 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/dj/StopSlashCmd.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.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; + +/** + * 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) + { + AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler(); + handler.stopAndClear(); + event.getGuild().getAudioManager().closeAudioConnection(); + 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 000000000..cebfce8da --- /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/NowPlayingSlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NowPlayingSlashCmd.java new file mode 100644 index 000000000..b8a803179 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/NowPlayingSlashCmd.java @@ -0,0 +1,40 @@ +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) + { + super(bot); + this.name = "nowplaying"; + this.help = "shows the song that is currently playing"; + 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/PlaySlashCmd.java b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaySlashCmd.java new file mode 100644 index 000000000..7034501e7 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaySlashCmd.java @@ -0,0 +1,136 @@ +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, new AudioLoadResultHandler() + { + @Override + public void trackLoaded(AudioTrack track) + { + event.replyChoices(new Command.Choice(track.getInfo().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 buildChoicesFromPlaylist(AudioPlaylist playlist) + { + List 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 = truncateTitle(track.getInfo().title); + choices.add(new Command.Choice(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 000000000..c5186b271 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/PlaylistsSlashCmd.java @@ -0,0 +1,71 @@ +/* + * 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 java.util.List; + +/** + * Slash command to show available playlists. + */ +public class PlaylistsSlashCmd extends MusicSlashCommand +{ + 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) + { + 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!") + .setEphemeral(true).queue(); + return; + } + + List list = bot.getPlaylistLoader().getPlaylistNames(); + if (list == null) + { + event.reply(event.getClient().getError() + " Failed to load available playlists!") + .setEphemeral(true).queue(); + } + else if (list.isEmpty()) + { + event.reply(event.getClient().getWarning() + " There are no playlists in the Playlists folder!") + .setEphemeral(true).queue(); + } + else + { + StringBuilder builder = new StringBuilder(event.getClient().getSuccess() + " Available playlists:\n"); + list.forEach(str -> builder.append("`").append(str).append("` ")); + builder.append("\nUse `/play playlist:` to play a playlist"); + event.reply(builder.toString()).queue(); + } + } +} 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 000000000..48148c0a4 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/QueueSlashCmd.java @@ -0,0 +1,108 @@ +/* + * 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.utils.TimeUtil; +import net.dv8tion.jda.api.EmbedBuilder; +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.Collections; + +/** + * Slash command to show the current queue. + */ +public class QueueSlashCmd extends MusicSlashCommand +{ + private 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; + } + + int startIndex = (page - 1) * TRACKS_PER_PAGE; + int endIndex = Math.min(startIndex + TRACKS_PER_PAGE, queueInfo.tracks.length); + + StringBuilder sb = new StringBuilder(); + for (int i = startIndex; i < endIndex; i++) + { + sb.append("`").append(i + 1).append(".` ").append(queueInfo.tracks[i]).append("\n"); + } + + String title = musicService.formatQueueTitle(queueInfo, event.getClient().getSuccess()); + + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Queue - Page " + page + "/" + totalPages) + .setDescription(sb.toString()) + .setFooter("Total: " + queueInfo.tracks.length + " tracks | Duration: " + TimeUtil.formatTime(queueInfo.totalDuration)) + .setColor(event.getMember().getColor()); + + event.reply(title).addEmbeds(embed.build()).queue(); + } +} 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 000000000..ca1c16b0f --- /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 000000000..6766ec833 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/commands/v2/music/SearchSlashCmd.java @@ -0,0 +1,201 @@ +/* + * 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 = track.getInfo().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(track.getInfo().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 000000000..07bf0b7b0 --- /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", "[+ | -] 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 000000000..6220e488b --- /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 000000000..7a7b1165b --- /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/model/ConfigOption.java b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java index ccacbada5..13e6c1f09 100644 --- a/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java +++ b/src/main/java/com/jagrosh/jmusicbot/config/model/ConfigOption.java @@ -58,6 +58,7 @@ public enum ConfigOption { // 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"), 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"), diff --git a/src/main/java/com/jagrosh/jmusicbot/config/validation/ConfigValidator.java b/src/main/java/com/jagrosh/jmusicbot/config/validation/ConfigValidator.java index c39a335c5..c4f452da8 100644 --- a/src/main/java/com/jagrosh/jmusicbot/config/validation/ConfigValidator.java +++ b/src/main/java/com/jagrosh/jmusicbot/config/validation/ConfigValidator.java @@ -17,8 +17,8 @@ import java.nio.file.Path; -import com.jagrosh.jmusicbot.entities.Prompt; import com.jagrosh.jmusicbot.entities.UserInteraction; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; /** * Handles validation of required configuration values. @@ -40,7 +40,7 @@ public static ValidationResult validateToken(String token, UserInteraction userI + "\nhttps://github.com/jagrosh/MusicBot/wiki/Getting-a-Bot-Token." + "\nBot Token: "); if (newToken == null) { - alertWithConfigLocation(userInteraction, Prompt.Level.WARNING, + alertWithConfigLocation(userInteraction, Level.WARNING, "No token provided! Exiting.", configPath); return ValidationResult.invalid(); } @@ -71,7 +71,7 @@ public static ValidationResult validateOwner(Long owner, UserInteraction userInt } catch (NumberFormatException | NullPointerException ex) { // Fall through to error } - alertWithConfigLocation(userInteraction, Prompt.Level.ERROR, + alertWithConfigLocation(userInteraction, Level.ERROR, "Invalid User ID! Exiting.", configPath); return ValidationResult.invalid(); } @@ -81,7 +81,7 @@ public static ValidationResult validateOwner(Long owner, UserInteraction userInt /** * Shows an alert with the config file location appended. */ - private static void alertWithConfigLocation(UserInteraction userInteraction, Prompt.Level level, + private static void alertWithConfigLocation(UserInteraction userInteraction, Level level, String message, Path configPath) { userInteraction.alert(level, CONTEXT, message + "\n\nConfig Location: " + configPath.toAbsolutePath().toString()); diff --git a/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java b/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java index 3de779d8b..af2eab56b 100644 --- a/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java +++ b/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java @@ -42,7 +42,15 @@ public Prompt(String title) public Prompt(String title, String noguiMessage) { - this(title, noguiMessage, "true".equalsIgnoreCase(System.getProperty("nogui")), "true".equalsIgnoreCase(System.getProperty("noprompt"))); + 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) @@ -70,13 +78,13 @@ public String prompt(String content) { try { return JOptionPane.showInputDialog(null, content, title, JOptionPane.QUESTION_MESSAGE); } catch (Exception e) { - alert(Level.WARNING, title, noguiMessage); + alert(UserInteraction.Level.WARNING, title, noguiMessage); return promptCli(content); // preserves your original “retry via CLI” behavior } } @Override - public void alert(Level level, String context, String message) { + public void alert(UserInteraction.Level level, String context, String message) { if (nogui) { logAlert(level, context, message); return; @@ -91,12 +99,12 @@ public void alert(Level level, String context, String message) { ); } catch (Exception e) { nogui = true; - alert(Level.WARNING, context, noguiMessage); + alert(UserInteraction.Level.WARNING, context, noguiMessage); alert(level, context, message); } } - private void logAlert(Level level, String context, String message) { + private void logAlert(UserInteraction.Level level, String context, String message) { var log = LoggerFactory.getLogger(context); switch (level) { case WARNING -> log.warn(message); @@ -105,7 +113,7 @@ private void logAlert(Level level, String context, String message) { } } - private int optionFor(Level level) { + private int optionFor(UserInteraction.Level level) { return switch (level) { case INFO -> JOptionPane.INFORMATION_MESSAGE; case WARNING -> JOptionPane.WARNING_MESSAGE; @@ -130,14 +138,22 @@ private String promptCli(String content) { ? scanner.nextLine() : null; } catch (Exception e) { - alert(Level.ERROR, title, "Unable to read input from command line."); + alert(UserInteraction.Level.ERROR, title, "Unable to read input from command line."); e.printStackTrace(); return null; } } - public enum Level - { - INFO, WARNING, ERROR; + /** + * @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 index cb34c3f9b..9dd78294a 100644 --- a/src/main/java/com/jagrosh/jmusicbot/entities/UserInteraction.java +++ b/src/main/java/com/jagrosh/jmusicbot/entities/UserInteraction.java @@ -23,6 +23,14 @@ * @author Arif Banai (arif-banai) */ public interface UserInteraction { + + /** + * Severity levels for alert messages. + */ + enum Level { + INFO, WARNING, ERROR + } + /** * Prompts the user for input. * @@ -38,7 +46,7 @@ public interface UserInteraction { * @param context The context/category of the alert * @param message The message to display */ - void alert(Prompt.Level level, String context, String message); + void alert(Level level, String context, String message); /** * Checks if running in no-GUI mode. diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java b/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java index 0d3b06705..5978870a7 100644 --- a/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java +++ b/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java @@ -29,9 +29,11 @@ public abstract class AbstractQueue protected AbstractQueue(AbstractQueue queue) { this.list = queue != null ? queue.getList() : new LinkedList<>(); + this.history = queue != null ? queue.getHistory() : new HistoryQueue<>(); } protected final List list; + protected final HistoryQueue history; public abstract int add(T item); @@ -48,9 +50,42 @@ public int 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); + } + + public T removeLastPlayed() + { + return history.removeFirst(); + } + + /** + * 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(); @@ -61,6 +96,11 @@ public List getList() return list; } + public HistoryQueue getHistory() + { + return history; + } + public T get(int index) { return list.get(index); } @@ -89,12 +129,18 @@ public void clear() list.clear(); } + public void clearAll() + { + list.clear(); + history.clear(); + } + public int shuffle(long identifier) { List iset = new ArrayList<>(); for(int i=0; i The type of items to store in history + */ +public class HistoryQueue { + private final LinkedList history; + private int maxSize; + + /** + * Creates a new HistoryQueue. + * The max size must be set via setMaxSize() before use. + */ + public HistoryQueue() { + this.history = new LinkedList<>(); + } + + /** + * Adds an item to the history. The item is added at the front (most recent). + * If the history is at max size, the oldest item is removed. + * + * @param item The item to add to history + */ + public void add(T item) { + if (item == null) { + return; + } + history.addFirst(item); + // Remove oldest items if we exceed max size + while (history.size() > maxSize) { + history.removeLast(); + } + } + + /** + * 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() { + if (history.isEmpty()) { + return null; + } + return history.removeFirst(); + } + + /** + * 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) + */ + public void setMaxSize(int size) { + if (size < 0) { + throw new IllegalArgumentException("Max size cannot be negative"); + } + this.maxSize = size; + // Remove oldest items if current size exceeds new max + while (history.size() > maxSize) { + history.removeLast(); + } + } + + /** + * 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(); + } + + /** + * 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 getList() { + return List.copyOf(history); + } + + /** + * Gets the item at the specified index. + * Index 0 is the most recent item. + * + * @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); + } +} 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 000000000..7e0b3d6a9 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/service/AudioLoadResultHandlers.java @@ -0,0 +1,352 @@ +/* + * 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, 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 000000000..d4a475565 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/service/MusicService.java @@ -0,0 +1,1191 @@ +/* + * 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.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.FormatUtil; +import com.jagrosh.jmusicbot.utils.TimeUtil; +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.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.List; +import java.util.function.Consumer; + +/** + * Unified service for all music operations including player control and queue management. + * This service encapsulates all interactions with AudioHandler. + */ +public class MusicService +{ + private static final Logger LOG = LoggerFactory.getLogger(MusicService.class); + + private final Bot bot; + + 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, + 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); + output.replySuccess("Resumed **" + handler.getPlayer().getPlayingTrack().getInfo().title + "**."); + } + 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, + 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 **" + playing.getInfo().title + "**"); + return; + } + + if (handler.getQueue().getHistory().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 **" + previous.getTrack().getInfo().title + "**"); + } + 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.stopAndClear(); + 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.stopAndClear(); + 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 ? track.getInfo().title : 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 = track.getInfo().title; + 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 = handler.getPlayer().getPlayingTrack().getInfo().title; + 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 = handler.getPlayer().getPlayingTrack().getInfo().title; + 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 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 **" + playingTrack.getInfo().title + "** 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: [+ | -] 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 **" + qt.getTrack().getInfo().title + "** 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 **" + qt.getTrack().getInfo().title + + "** from the queue (requested by " + (u == null ? "someone" : "**" + u.getName() + "**") + ")"); + } + else + { + output.replyError("You cannot remove **" + qt.getTrack().getInfo().title + "** 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 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 = track.getTrack().getInfo().title; + output.replySuccess("Moved **" + trackTitle + "** from position `" + from + "` to `" + to + "`."); + } + + /** + * 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 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 = track.getTrack().getInfo().title; + + 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 = handler.getQueue().get(0).getTrack().getInfo().title; + 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 = handler.getQueue().get(0).getTrack().getInfo().title; + 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 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 = handler.getPlayer().getPlayingTrack().getInfo().title; + } + + 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()); + } + + private boolean isInvalidPosition(AbstractQueue queue, int position) + { + return position < 1 || position > queue.size(); + } + + /** + * 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; + } + } + + /** + * Adapter interface for abstracting output operations. + *

+ * 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 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 000000000..e0d04d721 --- /dev/null +++ b/src/main/java/com/jagrosh/jmusicbot/service/SearchService.java @@ -0,0 +1,205 @@ +/* + * 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.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, + 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 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); + choices[i] = "`[" + TimeUtil.formatTime(track.getDuration()) + "]` [**" + + track.getInfo().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/utils/FormatUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java index 5e4f18e92..4d2652e38 100644 --- a/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java +++ b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java @@ -16,6 +16,8 @@ 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; @@ -50,17 +52,6 @@ public static String formatUsername(User user) { return formatUsername(user.getName(), user.getDiscriminator()); } - - public static String progressBar(double percent) - { - String str = ""; - for(int i=0; i<12; i++) - if(i == (int)(percent*12)) - str+="\uD83D\uDD18"; // 🔘 - else - str+="▬"; - return str; - } public static String volumeIcon(int volume) { @@ -110,4 +101,20 @@ public static String filter(String input) .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; + } } diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java b/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java index 00c5530f4..405734da5 100644 --- a/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java +++ b/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java @@ -4,9 +4,13 @@ import com.jagrosh.jmusicbot.audio.AudioHandler; import com.jagrosh.jmusicbot.audio.NowPlayingInfo; import com.jagrosh.jmusicbot.audio.RequestMetadata; -import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioTrack; +import com.jagrosh.jmusicbot.settings.RepeatMode; +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; @@ -17,42 +21,81 @@ public static MessageCreateData buildNowPlayingMessage(Bot bot, NowPlayingInfo i return buildNoMusicPlayingMessage(bot, info); MessageCreateBuilder mb = new MessageCreateBuilder(); - mb.addContent(FormatUtil.filter(bot.getConfig().getSuccess() + " **Now Playing in " + info.guild.getSelfMember().getVoiceState().getChannel().getAsMention() + "...**")); EmbedBuilder eb = new EmbedBuilder(); eb.setColor(info.guild.getSelfMember().getColors().getPrimary()); + eb.setAuthor(info.guild.getName(), null, info.guild.getIconUrl()); + + // Handle local file names using the util method + String title = FormatUtil.getTrackTitle(info.track); + + try { + eb.setTitle(title, info.track.getInfo().uri); + } catch (Exception e) { + eb.setTitle(title); + } + + if (info.track.getInfo().author != null && (!info.track.getInfo().author.isEmpty() && !info.track.getInfo().author.equalsIgnoreCase( "unknown artist") )) { + eb.addField("Author", info.track.getInfo().author, false); + } + + StringBuilder description = new StringBuilder(); + description.append("**Playing from:** ").append(info.track.getSourceManager().getSourceName()); + eb.setDescription(description.toString()); + + eb.addField("Duration", TimeUtil.formatTime(info.duration), true); + eb.addField("Queue", String.valueOf(info.queueSize), true); + eb.addField("Volume", info.volume + "%", true); + + RepeatMode repeatMode = bot.getSettingsManager().getSettings(info.guild).getRepeatMode(); + if (repeatMode != RepeatMode.OFF) { + eb.addField("Repeat", repeatMode.getEmoji() + " " + repeatMode.getUserFriendlyName(), true); + } RequestMetadata rm = info.track.getUserData(RequestMetadata.class); if (rm != null && rm.getOwner() != 0L) { User u = info.guild.getJDA().getUserById(rm.user.id); - if (u == null) - eb.setAuthor(FormatUtil.formatUsername(rm.user), null, rm.user.avatar); - else - eb.setAuthor(FormatUtil.formatUsername(u), null, u.getEffectiveAvatarUrl()); + String requester = (u == null) ? FormatUtil.formatUsername(rm.user) : u.getAsMention(); + eb.addField("Requester", requester, false); } - try { - eb.setTitle(info.track.getInfo().title, info.track.getInfo().uri); - } catch (Exception e) { - eb.setTitle(info.track.getInfo().title); + if (!(info.track instanceof LocalAudioTrack) && bot.getConfig().useNPImages()) { + var thumbnailUrl = info.track.getInfo().artworkUrl; + if (thumbnailUrl == null || thumbnailUrl.isEmpty()) + thumbnailUrl = "https://img.youtube.com/vi/" + info.track.getIdentifier() + "/mqdefault.jpg"; + eb.setThumbnail(thumbnailUrl); } - if (info.track instanceof YoutubeAudioTrack && bot.getConfig().useNPImages()) { - eb.setThumbnail("https://img.youtube.com/vi/" + info.track.getIdentifier() + "/mqdefault.jpg"); - } + if (info.footerInfo != null && !info.footerInfo.isEmpty()) + eb.setFooter(info.footerInfo); - if (info.track.getInfo().author != null && !info.track.getInfo().author.isEmpty()) - eb.setFooter("Source: " + info.track.getInfo().author, null); + mb.setEmbeds(eb.build()); - double progress = (double) info.position / info.duration; - String statusEmoji = info.isPaused ? AudioHandler.PAUSE_EMOJI : AudioHandler.PLAY_EMOJI; + // Add interactive buttons using ActionRow.of for better compatibility + Button repeatButton = switch (repeatMode) { + case ALL -> Button.primary("repeat", Emoji.fromUnicode("\uD83D\uDD01")); // 🔁 + case SINGLE -> Button.primary("repeat", Emoji.fromUnicode("\uD83D\uDD02")); // 🔂 + default -> Button.secondary("repeat", Emoji.fromUnicode("\uD83D\uDD01")); // 🔁 + }; - eb.setDescription(statusEmoji - + " " + FormatUtil.progressBar(progress) - + " `[" + TimeUtil.formatTime(info.position) + "/" + TimeUtil.formatTime(info.duration) + "]` " - + FormatUtil.volumeIcon(info.volume)); + mb.setComponents( + ActionRow.of( + Button.secondary("previous", Emoji.fromUnicode("\u23EE")), // Previous ⏮ + info.isPaused + ? Button.primary("pause", Emoji.fromUnicode("\u25B6")) // Pause ⏸ + : Button.secondary("pause", Emoji.fromUnicode("\u23F8")), // or Resume ▶ + Button.secondary("skip", Emoji.fromUnicode("\u23ED")), // Skip ⏭ + Button.secondary("stop", Emoji.fromUnicode("\u23F9")) // Stop ⏹ + ), + ActionRow.of( + Button.secondary("shuffle", Emoji.fromUnicode("\uD83D\uDD00")), // Shuffle 🔀 + repeatButton, // Repeat cycle + Button.secondary("voldown", Emoji.fromUnicode("\uD83D\uDD09")), // Vol Down 🔉 + Button.secondary("volup", Emoji.fromUnicode("\uD83D\uDD0A")) // Vol Up 🔊 + ) + ); - return mb.setEmbeds(eb.build()).build(); + return mb.build(); } public static MessageCreateData buildNoMusicPlayingMessage(Bot bot, NowPlayingInfo info) { @@ -60,7 +103,7 @@ public static MessageCreateData buildNoMusicPlayingMessage(Bot bot, NowPlayingIn .setContent(FormatUtil.filter(bot.getConfig().getSuccess() + " **Now Playing...**")) .setEmbeds(new EmbedBuilder() .setTitle("No music playing") - .setDescription(AudioHandler.STOP_EMOJI + " " + FormatUtil.progressBar(-1) + " " + FormatUtil.volumeIcon(info.volume)) + .setDescription(AudioHandler.STOP_EMOJI + " " + FormatUtil.volumeIcon(info.volume)) .setColor(info.guild.getSelfMember().getColors().getPrimary()) .build()).build(); } diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java index ee7c4cde6..97486261d 100644 --- a/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java +++ b/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java @@ -16,8 +16,8 @@ package com.jagrosh.jmusicbot.utils; import com.jagrosh.jmusicbot.JMusicBot; -import com.jagrosh.jmusicbot.entities.Prompt; 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; @@ -31,6 +31,7 @@ import okhttp3.ResponseBody; import java.io.*; +import java.util.concurrent.TimeUnit; import java.net.URISyntaxException; import java.net.URL; import java.net.URLConnection; @@ -161,7 +162,7 @@ public static OnlineStatus parseStatus(String status) public static void checkJavaVersion(UserInteraction userInteraction) { if(!System.getProperty("java.vm.name").contains("64")) - userInteraction.alert(Prompt.Level.WARNING, "Java Version", + userInteraction.alert(Level.WARNING, "Java Version", "It appears that you may not be using a supported Java version. Please use 64-bit java."); } @@ -189,7 +190,11 @@ public static String getLatestVersion(String baseUrl) { try { - OkHttpClient client = new OkHttpClient.Builder().build(); + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build(); // First, try to get the latest release Response response = client.newCall(new Request.Builder().get() .url(baseUrl + "/releases/latest").build()) @@ -275,7 +280,7 @@ public static void checkVersion(UserInteraction userInteraction) if(latestVersion != null && isNewerVersion(version, latestVersion)) { - userInteraction.alert(Prompt.Level.WARNING, "JMusicBot Version", String.format(NEW_VERSION_AVAILABLE, version, latestVersion)); + userInteraction.alert(Level.WARNING, "JMusicBot Version", String.format(NEW_VERSION_AVAILABLE, version, latestVersion)); } } diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index c99e1e667..1c50e7423 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -35,6 +35,10 @@ playback { # Example: 10 pages = up to 1000 tracks. maxYouTubePlaylistPages = 10 + # Maximum number of tracks to keep in playback history. + # Used for the previous/rewind functionality. + maxHistorySize = 10 + # Ratio of users that must vote to skip the currently playing song. # Some guilds may override this, this is the default. skipRatio = 0.55 diff --git a/src/test/java/com/jagrosh/jmusicbot/MockUserInteraction.java b/src/test/java/com/jagrosh/jmusicbot/MockUserInteraction.java index d8d79489a..7cadf5f29 100644 --- a/src/test/java/com/jagrosh/jmusicbot/MockUserInteraction.java +++ b/src/test/java/com/jagrosh/jmusicbot/MockUserInteraction.java @@ -15,7 +15,6 @@ */ package com.jagrosh.jmusicbot; -import com.jagrosh.jmusicbot.entities.Prompt; import com.jagrosh.jmusicbot.entities.UserInteraction; import java.util.ArrayList; @@ -90,7 +89,7 @@ public String prompt(String message) { } @Override - public void alert(Prompt.Level level, String context, String message) { + public void alert(Level level, String context, String message) { alertCalls.add(new AlertCall(level, context, message)); } @@ -141,17 +140,17 @@ public String getLastPrompt() { * Represents an alert call. */ public static class AlertCall { - private final Prompt.Level level; + private final Level level; private final String context; private final String message; - public AlertCall(Prompt.Level level, String context, String message) { + public AlertCall(Level level, String context, String message) { this.level = level; this.context = context; this.message = message; } - public Prompt.Level getLevel() { + public Level getLevel() { return level; } 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 000000000..8d717bcf4 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/TestBase.java @@ -0,0 +1,94 @@ +package com.jagrosh.jmusicbot; + +import com.jagrosh.jmusicbot.audio.AudioHandler; +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); + + // 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); + + // 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 index 6d4fff327..b31a810d1 100644 --- a/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigIntegrationTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigIntegrationTest.java @@ -23,8 +23,6 @@ import com.typesafe.config.parser.ConfigDocument; import com.typesafe.config.parser.ConfigDocumentFactory; -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; diff --git a/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigMigrationIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigMigrationIntegrationTest.java index 651cc999e..c60325819 100644 --- a/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigMigrationIntegrationTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/integration/BotConfigMigrationIntegrationTest.java @@ -20,7 +20,7 @@ import com.jagrosh.jmusicbot.MockUserInteraction; import com.jagrosh.jmusicbot.audio.AudioSource; import com.jagrosh.jmusicbot.config.io.ConfigIO; -import com.jagrosh.jmusicbot.entities.Prompt; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; import com.jagrosh.jmusicbot.testutil.config.V1ConfigBuilder; import com.typesafe.config.Config; import org.junit.jupiter.api.DisplayName; @@ -225,7 +225,7 @@ void testBotConfigShowsErrorOnMigrationFailure() throws IOException { // User should have been alerted about the migration failure MockUserInteraction.AlertCall lastAlert = mockUserInteraction.getLastAlert(); assertNotNull(lastAlert, "Expected an alert to be shown"); - assertEquals(Prompt.Level.ERROR, lastAlert.getLevel()); + 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/migration/ConfigMigrationIntegrationTest.java b/src/test/java/com/jagrosh/jmusicbot/integration/config/migration/ConfigMigrationIntegrationTest.java index 5ef779afc..3d6307fb1 100644 --- a/src/test/java/com/jagrosh/jmusicbot/integration/config/migration/ConfigMigrationIntegrationTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/integration/config/migration/ConfigMigrationIntegrationTest.java @@ -22,15 +22,12 @@ 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.LegacyConfigTestData; 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.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; 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 000000000..1c87b38fa --- /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 000000000..15d75864f --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/audio/AudioTestFixture.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.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.HistoryQueue; +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 mockTracks = new ArrayList<>(); + private AudioTrack currentTrack; + + // Queue mock + private AbstractQueue queue; + private HistoryQueue history; + + // Message mocks + private final Message message; + private final MessageCreateAction messageCreateAction; + private final MessageEditAction messageEditAction; + private final AuditableRestAction 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(HistoryQueue.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.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.getSongInStatus()).thenReturn(false); + when(config.getMaxHistorySize()).thenReturn(10); + + // 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 callback = inv.getArgument(0); + callback.accept(message); + return null; + }).when(messageCreateAction).queue(any()); + doNothing().when(messageCreateAction).queue(); + + doAnswer(inv -> { + Consumer 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 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 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 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; + } + + // ==================== 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 getQueue() + { + return queue; + } + + public HistoryQueue getHistory() + { + return history; + } + + public Message getMessage() + { + return message; + } + + public MessageCreateAction getMessageCreateAction() + { + return messageCreateAction; + } + + public MessageEditAction getMessageEditAction() + { + return messageEditAction; + } + + public List 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 000000000..d44a7ec95 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/commands/SlashCommandTestFixture.java @@ -0,0 +1,565 @@ +/* + * 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.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 editAction; + private final RestAction 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); + + // 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(replyAction.setEphemeral(anyBoolean())).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 getEditAction() + { + return editAction; + } + + public RestAction 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 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 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 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 000000000..5410f082b --- /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 000000000..9b517ab48 --- /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/listener/ListenerTestFixture.java b/src/test/java/com/jagrosh/jmusicbot/testutil/listener/ListenerTestFixture.java new file mode 100644 index 000000000..7061540b0 --- /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 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 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 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 000000000..d4a732d71 --- /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: + *

+ * ServiceTestFixture fixture = MusicServiceScenarioBuilder.with(ServiceTestFixture.create())
+ *     .standardPlayback()
+ *     .build();
+ * 
+ */ +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 000000000..a83ec3504 --- /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: + *
+ * OutputAdapterSpy spy = new OutputAdapterSpy();
+ * musicService.play(guild, member, "query", textChannel, spy);
+ * spy.assertSuccessMessageContains("Added");
+ * 
+ */ +public class OutputAdapterSpy implements MusicService.OutputAdapter +{ + // Captured calls + private final List successMessages = new ArrayList<>(); + private final List errorMessages = new ArrayList<>(); + private final List warningMessages = new ArrayList<>(); + private final List editedMessages = new ArrayList<>(); + private final List nowPlayingEdits = new ArrayList<>(); + private final List noMusicEdits = new ArrayList<>(); + private int helpShownCount = 0; + + // Callbacks for async operations + private Consumer 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 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 getSuccessMessages() + { + return new ArrayList<>(successMessages); + } + + public List getErrorMessages() + { + return new ArrayList<>(errorMessages); + } + + public List getWarningMessages() + { + return new ArrayList<>(warningMessages); + } + + public List getEditedMessages() + { + return new ArrayList<>(editedMessages); + } + + public List getNowPlayingEdits() + { + return new ArrayList<>(nowPlayingEdits); + } + + public List getNoMusicEdits() + { + return new ArrayList<>(noMusicEdits); + } + + public int getHelpShownCount() + { + return helpShownCount; + } + + public Consumer 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 000000000..0fe24286a --- /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: + *
+ * PermissionStateBuilder.with(fixture)
+ *     .asTrackOwner()
+ *     .withoutDJRole()
+ *     .build();
+ * 
+ */ +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 000000000..9e31e7932 --- /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: + *
+ * AbstractQueue queue = QueueStateBuilder.create()
+ *     .addTrack("Song 1", "Artist 1", 180000)
+ *     .addTrack("Song 2", "Artist 2", 240000)
+ *     .build();
+ * 
+ */ +public class QueueStateBuilder +{ + private final List tracks = new ArrayList<>(); + private final List 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 build() + { + AbstractQueue 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.HistoryQueue historyMock = mock(com.jagrosh.jmusicbot.queue.HistoryQueue.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 builtQueue = build(); + when(fixture.getAudioHandler().getQueue()).thenReturn(builtQueue); + } + + /** + * Returns the list of tracks for direct access. + */ + public List getTracks() + { + return new ArrayList<>(tracks); + } + + /** + * Returns the history list for direct access. + */ + public List 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 000000000..ca162b215 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/testutil/service/ServiceTestFixture.java @@ -0,0 +1,576 @@ +/* + * 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.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 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); + + // 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 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 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 index 2902be3f1..4dcc1bc2f 100644 --- a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java @@ -16,6 +16,7 @@ package com.jagrosh.jmusicbot.unit; 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; @@ -217,21 +218,27 @@ void youtubeClientsConfiguredForOAuthMode() throws Exception { // With OAuth enabled Object[] oauthClients = (Object[]) buildClients.invoke(null, true); assertNotNull(oauthClients, "Should return clients for OAuth mode"); - assertEquals(5, oauthClients.length, "OAuth mode should use 5 clients (3 metadata-only: AndroidVr, MWeb, Web; 2 OAuth: TvHtml5Embedded, Tv)"); + assertEquals(5, oauthClients.length, "OAuth mode should use 5 clients (AndroidVr, MWeb, Web, TvHtml5Embedded, Tv)"); - // Verify client types - first 3 are metadata-only (playback disabled) + // Verify client types assertEquals("AndroidVr", oauthClients[0].getClass().getSimpleName(), - "First OAuth client should be AndroidVr (metadata-only)"); + "First OAuth client should be AndroidVr"); assertEquals("MWeb", oauthClients[1].getClass().getSimpleName(), - "Second OAuth client should be MWeb (metadata-only)"); + "Second OAuth client should be MWeb"); assertEquals("Web", oauthClients[2].getClass().getSimpleName(), - "Third OAuth client should be Web (metadata-only)"); - - // Last 2 are OAuth clients for streaming + "Third OAuth client should be Web"); assertEquals("TvHtml5Embedded", oauthClients[3].getClass().getSimpleName(), - "Fourth OAuth client should be TvHtml5Embedded (OAuth streaming)"); + "Fourth OAuth client should be TvHtml5Embedded"); assertEquals("Tv", oauthClients[4].getClass().getSimpleName(), - "Fifth OAuth client should be Tv (OAuth streaming)"); + "Fifth OAuth client should be Tv"); + + // 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 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 000000000..688c8d91c --- /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 com.jagrosh.jmusicbot.gui.GUI; +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 GUI 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/JMusicBotTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/JMusicBotTest.java new file mode 100644 index 000000000..18e058e90 --- /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 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 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 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 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 intents = Arrays.stream(JMusicBot.INTENTS).collect(Collectors.toSet()); + assertTrue(intents.contains(GatewayIntent.DIRECT_MESSAGES)); + } + + @Test + @DisplayName("INTENTS contains GUILD_MESSAGES") + void intents_containsGuildMessages() + { + Set 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 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 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 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/ListenerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/ListenerTest.java new file mode 100644 index 000000000..7b6d7aba3 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/ListenerTest.java @@ -0,0 +1,440 @@ +/* + * 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; +import com.jagrosh.jmusicbot.entities.UserInteraction.Level; +import com.jagrosh.jmusicbot.service.MusicService; +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.*; + +/** + * Unit tests for the Listener class. + * Uses the ListenerTestFixture for consistent mock setup. + */ +@DisplayName("Listener Tests") +public class ListenerTest +{ + private ListenerTestFixture fixture; + private Listener listener; + + @BeforeEach + void setUp() + { + fixture = ListenerTestFixture.create(); + listener = new Listener(fixture.getBot()); + } + + // ==================== onMessageDelete Tests ==================== + + @Nested + @DisplayName("onMessageDelete") + class OnMessageDeleteTests + { + @Test + @DisplayName("onMessageDelete() delegates to NowPlayingHandler when from guild") + void onMessageDelete_delegatesToNowPlayingHandler() + { + // When + listener.onMessageDelete(fixture.getMessageDeleteEvent()); + + // Then + verify(fixture.getNowPlayingHandler()).onMessageDelete( + fixture.getGuild(), + ListenerTestFixture.MESSAGE_ID + ); + } + + @Test + @DisplayName("onMessageDelete() does nothing when not from guild") + void onMessageDelete_doesNothingWhenNotFromGuild() + { + // Given + when(fixture.getMessageDeleteEvent().isFromGuild()).thenReturn(false); + + // When + listener.onMessageDelete(fixture.getMessageDeleteEvent()); + + // Then + verify(fixture.getNowPlayingHandler(), never()).onMessageDelete(any(), anyLong()); + } + } + + // ==================== onButtonInteraction Tests ==================== + + @Nested + @DisplayName("onButtonInteraction") + class OnButtonInteractionTests + { + @Test + @DisplayName("onButtonInteraction() ignores unknown button IDs") + void onButtonInteraction_ignoresUnknownButtonId() + { + // Given - default fixture has "unknown" button ID + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + 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("onButtonInteraction() handles stop button") + void onButtonInteraction_handlesStopButton() + { + // Given + fixture.withButtonId("stop") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).stop( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() handles pause button") + void onButtonInteraction_handlesPauseButton() + { + // Given + fixture.withButtonId("pause") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).pause( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() handles skip button") + void onButtonInteraction_handlesSkipButton() + { + // Given + fixture.withButtonId("skip") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).skip( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() handles previous button") + void onButtonInteraction_handlesPreviousButton() + { + // Given + fixture.withButtonId("previous") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).previous( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() handles shuffle button") + void onButtonInteraction_handlesShuffleButton() + { + // Given + fixture.withButtonId("shuffle") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).shuffle( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(0), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() handles repeat button") + void onButtonInteraction_handlesRepeatButton() + { + // Given + fixture.withButtonId("repeat") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).cycleRepeatMode( + eq(fixture.getGuild()), + eq(fixture.getMember()), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() handles voldown button") + void onButtonInteraction_handlesVoldownButton() + { + // Given + fixture.withButtonId("voldown") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).adjustVolume( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(-10), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() handles volup button") + void onButtonInteraction_handlesVolupButton() + { + // Given + fixture.withButtonId("volup") + .withMemberInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getMusicService()).adjustVolume( + eq(fixture.getGuild()), + eq(fixture.getMember()), + eq(10), + any(MusicService.OutputAdapter.class) + ); + } + + @Test + @DisplayName("onButtonInteraction() replies error when no audio handler") + void onButtonInteraction_repliesErrorWhenNoHandler() + { + // Given + fixture.withButtonId("stop") + .withNoAudioHandler(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getButtonInteractionEvent()).reply("There is no music playing!"); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("onButtonInteraction() replies error when user not in voice") + void onButtonInteraction_repliesErrorWhenUserNotInVoice() + { + // Given + fixture.withButtonId("stop") + .withMemberNotInVoiceChannel() + .withAudioHandlerPlaying(); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then + verify(fixture.getButtonInteractionEvent()).reply("You must be in the same voice channel to use this!"); + verify(fixture.getReplyAction()).setEphemeral(true); + } + + @Test + @DisplayName("onButtonInteraction() handles null guild gracefully") + void onButtonInteraction_handlesNullGuild() + { + // Given + fixture.withButtonId("stop"); + when(fixture.getButtonInteractionEvent().getGuild()).thenReturn(null); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then - should not throw, and no service calls + verify(fixture.getMusicService(), never()).stop(any(), any(), any()); + } + + @Test + @DisplayName("onButtonInteraction() handles null member gracefully") + void onButtonInteraction_handlesNullMember() + { + // Given + fixture.withButtonId("stop"); + when(fixture.getButtonInteractionEvent().getMember()).thenReturn(null); + + // When + listener.onButtonInteraction(fixture.getButtonInteractionEvent()); + + // Then - should not throw, and no service calls + verify(fixture.getMusicService(), never()).stop(any(), any(), any()); + } + } + + // ==================== onGuildVoiceUpdate Tests ==================== + + @Nested + @DisplayName("onGuildVoiceUpdate") + class OnGuildVoiceUpdateTests + { + @Test + @DisplayName("onGuildVoiceUpdate() delegates to AloneInVoiceHandler") + void onGuildVoiceUpdate_delegatesToAloneInVoiceHandler() + { + // When + listener.onGuildVoiceUpdate(fixture.getGuildVoiceUpdateEvent()); + + // Then + verify(fixture.getAloneInVoiceHandler()).onVoiceUpdate(fixture.getGuildVoiceUpdateEvent()); + } + } + + // ==================== onSessionDisconnect Tests ==================== + + @Nested + @DisplayName("onSessionDisconnect") + class OnSessionDisconnectTests + { + @Test + @DisplayName("onSessionDisconnect() shows error alert when close code is DISALLOWED_INTENTS") + void onSessionDisconnect_showsAlertForDisallowedIntents() + { + // Given + fixture.withCloseCode(CloseCode.DISALLOWED_INTENTS); + + // When + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + // Then + verify(fixture.getUserInteraction()).alert( + eq(Level.ERROR), + eq("JMusicBot"), + contains("missing required Discord intents") + ); + } + + @Test + @DisplayName("onSessionDisconnect() does not show alert for null close code") + void onSessionDisconnect_doesNothingForNullCloseCode() + { + // Given - default fixture has null close code + + // When + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + // Then + verify(fixture.getUserInteraction(), never()).alert(any(), any(), any()); + } + + @Test + @DisplayName("onSessionDisconnect() does not show alert for other close codes") + void onSessionDisconnect_doesNothingForOtherCloseCodes() + { + // Given + fixture.withCloseCode(CloseCode.GRACEFUL_CLOSE); + + // When + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + // Then + verify(fixture.getUserInteraction(), never()).alert(any(), any(), any()); + } + + @Test + @DisplayName("onSessionDisconnect() error message includes instructions for enabling intents") + void onSessionDisconnect_messageIncludesInstructions() + { + // Given + fixture.withCloseCode(CloseCode.DISALLOWED_INTENTS); + + // When + listener.onSessionDisconnect(fixture.getSessionDisconnectEvent()); + + // Then + 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") + ) + ); + } + } + + // ==================== onShutdown Tests ==================== + + @Nested + @DisplayName("onShutdown") + class OnShutdownTests + { + @Test + @DisplayName("onShutdown() calls bot.shutdown()") + void onShutdown_callsBotShutdown() + { + // When + listener.onShutdown(fixture.getShutdownEvent()); + + // Then + verify(fixture.getBot()).shutdown(); + } + } +} 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 000000000..ab32758ed --- /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 000000000..53e8cb852 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java @@ -0,0 +1,324 @@ +/* + * 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.sedmelluq.discord.lavaplayer.track.AudioTrack; +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); + + // 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(); + } + } + + // ==================== 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()); + } + } +} 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 000000000..d3246b53a --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/NowPlayingHandlerTest.java @@ -0,0 +1,276 @@ +/* + * 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.testutil.audio.AudioTestFixture; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.managers.Presence; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +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 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()); + + // Create handler + nowPlayingHandler = new NowPlayingHandler(fixture.getBot()); + } + + // ==================== Initialization Tests ==================== + + @Nested + @DisplayName("Initialization") + class InitializationTests + { + @Test + @DisplayName("init() schedules update task when not using NP images") + void init_schedulesUpdateTask_whenNotUsingNPImages() + { + // Given + when(fixture.getConfig().useNPImages()).thenReturn(false); + + // When + nowPlayingHandler.init(); + + // Then + verify(fixture.getThreadpool()).scheduleWithFixedDelay(any(Runnable.class), eq(0L), eq(10L), any()); + } + + @Test + @DisplayName("init() does not schedule task when using NP images") + void init_doesNotScheduleTask_whenUsingNPImages() + { + // Given + when(fixture.getConfig().useNPImages()).thenReturn(true); + + // 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(mock(MessageCreateData.class)); + + // 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(mock(MessageCreateData.class)); + + // When + nowPlayingHandler.onTrackUpdate(GUILD_ID, track); + + // Then + verify(presence, never()).setActivity(any()); + } + } + + // ==================== 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())); + } + } +} 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 000000000..4394b150f --- /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 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>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + ArgumentCaptor> 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 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>> 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>> 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>> 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>> 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>> 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>> successCaptor = ArgumentCaptor.forClass(Consumer.class); + ArgumentCaptor> 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>> 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>> 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/v2/MusicSlashCommandTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/MusicSlashCommandTest.java new file mode 100644 index 000000000..9bf845e61 --- /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 000000000..ffede7067 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/SlashOutputAdaptersTest.java @@ -0,0 +1,327 @@ +/* + * 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.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; + @Mock + private MessageEditData messageEditData; + + @BeforeEach + void setUp() + { + MockitoAnnotations.openMocks(this); + fixture = SlashCommandTestFixture.create(); + } + + // ==================== 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 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 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/music/PlaySlashCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/PlaySlashCmdTest.java new file mode 100644 index 000000000..d7d9d67d9 --- /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 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 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 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 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 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 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> choicesCaptor = + ArgumentCaptor.forClass((Class>) (Class) List.class); + verify(fixture.getAutoCompleteEvent()).replyChoices(choicesCaptor.capture()); + List capturedChoices = choicesCaptor.getValue(); + assertEquals(2, capturedChoices.size()); + } + + @Test + void testOnAutoComplete_NoMatches_RepliesEmptyChoices() + { + // Given + String query = "nonexistent song"; + when(fixture.getFocusedOption().getValue()).thenReturn(query); + + ArgumentCaptor 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 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/QueueSlashCmdTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/QueueSlashCmdTest.java new file mode 100644 index 000000000..6cd905540 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/v2/music/QueueSlashCmdTest.java @@ -0,0 +1,251 @@ +/* + * 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.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 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_ShowsFirstPage() + { + // 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(fixture.getMusicService().formatQueueTitle(queueInfo, "✅")).thenReturn("✅ Queue"); + when(fixture.getReplyAction().addEmbeds(any(MessageEmbed.class))).thenReturn(fixture.getReplyAction()); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("✅ Queue"); + verify(fixture.getReplyAction()).addEmbeds(any(MessageEmbed.class)); + } + + @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(fixture.getMusicService().formatQueueTitle(queueInfo, "✅")).thenReturn("✅ Queue"); + when(fixture.getReplyAction().addEmbeds(any(MessageEmbed.class))).thenReturn(fixture.getReplyAction()); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("✅ Queue"); + ArgumentCaptor embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getReplyAction()).addEmbeds(embedCaptor.capture()); + assertTrue(embedCaptor.getValue().getTitle().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(fixture.getMusicService().formatQueueTitle(queueInfo, "✅")).thenReturn("✅ Queue"); + when(fixture.getReplyAction().addEmbeds(any(MessageEmbed.class))).thenReturn(fixture.getReplyAction()); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("✅ Queue"); + ArgumentCaptor embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getReplyAction()).addEmbeds(embedCaptor.capture()); + assertTrue(embedCaptor.getValue().getTitle().contains("Page 1")); // Should clamp to last 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(fixture.getMusicService().formatQueueTitle(queueInfo, "✅")).thenReturn("✅ Queue"); + when(fixture.getReplyAction().addEmbeds(any(MessageEmbed.class))).thenReturn(fixture.getReplyAction()); + + // When + command.doCommand(fixture.getEvent()); + + // Then + verify(fixture.getEvent()).reply("✅ Queue"); + ArgumentCaptor embedCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(fixture.getReplyAction()).addEmbeds(embedCaptor.capture()); + MessageEmbed capturedEmbed = embedCaptor.getValue(); + assertTrue(capturedEmbed.getTitle().contains("Page 1/3")); + assertTrue(capturedEmbed.getDescription().contains("Track 1")); + assertTrue(capturedEmbed.getDescription().contains("Track 10")); + } +} 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 000000000..2b507ef31 --- /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 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 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 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 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 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 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 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/queue/HistoryQueueTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/queue/HistoryQueueTest.java new file mode 100644 index 000000000..67e1f85ff --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/queue/HistoryQueueTest.java @@ -0,0 +1,97 @@ +package com.jagrosh.jmusicbot.unit.queue; + +import com.jagrosh.jmusicbot.queue.HistoryQueue; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; + +public class HistoryQueueTest { + + @Test + public void testAddAndSize() { + HistoryQueue queue = new HistoryQueue<>(); + queue.setMaxSize(3); + + queue.add("one"); + assertEquals(1, queue.size()); + assertEquals("one", queue.get(0)); + + queue.add("two"); + assertEquals(2, queue.size()); + assertEquals("two", queue.get(0)); + assertEquals("one", queue.get(1)); + } + + @Test + public void testMaxSize() { + HistoryQueue queue = new HistoryQueue<>(); + queue.setMaxSize(2); + + queue.add("one"); + queue.add("two"); + queue.add("three"); + + assertEquals(2, queue.size()); + assertEquals("three", queue.get(0)); + assertEquals("two", queue.get(1)); + + // Ensure "one" was removed (it was the oldest) + List list = queue.getList(); + assertFalse(list.contains("one")); + } + + @Test + public void testRemoveFirst() { + HistoryQueue queue = new HistoryQueue<>(); + queue.setMaxSize(10); + queue.add("one"); + queue.add("two"); + + assertEquals("two", queue.removeFirst()); + assertEquals(1, queue.size()); + assertEquals("one", queue.get(0)); + + assertEquals("one", queue.removeFirst()); + assertTrue(queue.isEmpty()); + assertNull(queue.removeFirst()); + } + + @Test + public void testSetMaxSizeShrink() { + HistoryQueue queue = new HistoryQueue<>(); + queue.setMaxSize(5); + queue.add("1"); + queue.add("2"); + queue.add("3"); + queue.add("4"); + queue.add("5"); + + queue.setMaxSize(2); + assertEquals(2, queue.size()); + assertEquals("5", queue.get(0)); + assertEquals("4", queue.get(1)); + } + + @Test + public void testSetNegativeMaxSize() { + HistoryQueue queue = new HistoryQueue<>(); + assertThrows(IllegalArgumentException.class, () -> queue.setMaxSize(-1)); + } + + @Test + public void testClear() { + HistoryQueue queue = new HistoryQueue<>(); + queue.setMaxSize(10); + queue.add("one"); + queue.clear(); + assertTrue(queue.isEmpty()); + assertEquals(0, queue.size()); + } + + @Test + public void testAddNull() { + HistoryQueue queue = new HistoryQueue<>(); + queue.add(null); + assertEquals(0, queue.size()); + } +} 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 000000000..16af6ca90 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/queue/LinearQueueTest.java @@ -0,0 +1,78 @@ +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 { + + @Test + public void testAddAndPull() { + LinearQueue queue = new LinearQueue<>(null); + 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 queue = new LinearQueue<>(null); + queue.setMaxHistorySize(10); + 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 queue = new LinearQueue<>(null); + 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 queue = new LinearQueue<>(null); + 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()); + } + + 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/service/MusicServiceTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/service/MusicServiceTest.java new file mode 100644 index 000000000..4696ce9ce --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/service/MusicServiceTest.java @@ -0,0 +1,1140 @@ +/* + * 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.HistoryQueue; +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 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); + + // Setup history with a previous track + HistoryQueue history = mock(HistoryQueue.class); + when(history.isEmpty()).thenReturn(false); + when(fixture.getQueue().getHistory()).thenReturn(history); + + 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.getAudioPlayer()).playTrack(prevAudioTrack); + output.assertSuccessMessageContains("Went back to"); + } + + @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 + HistoryQueue history = mock(HistoryQueue.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()).stopAndClear(); + 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()).stopAndClear(); + 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()).stopAndClear(); + 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")); + } + } +} 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 000000000..01e2c754b --- /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/SettingsTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java new file mode 100644 index 000000000..7b8784b96 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java @@ -0,0 +1,130 @@ +package com.jagrosh.jmusicbot.unit.settings; + +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 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); + } + + @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); + + 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); + + 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 000000000..a45494766 --- /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/OtherUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java index 1fbec2bc9..847007a98 100644 --- a/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java @@ -238,11 +238,18 @@ void testGetLatestVersion_AllPrereleases() throws IOException @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); @@ -253,6 +260,12 @@ void testGetLatestVersion_EmptyResponse() throws IOException @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")); @@ -312,4 +325,13 @@ void testGetLatestVersion_MultiplePrereleases() throws IOException 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("")); + } } diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java index 5a138ad6e..1558421d5 100644 --- a/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java @@ -115,4 +115,15 @@ public void timestampNumberFormat() 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)); + } }