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/.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/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..d0904a7ae 100644 --- a/pom.xml +++ b/pom.xml @@ -1,9 +1,10 @@ - + 4.0.0 com.arifbanai JMusicBot - 0.6.2 + 0.6.2-slash-commands 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..b53d856e3 100644 --- a/src/main/java/com/jagrosh/jmusicbot/Bot.java +++ b/src/main/java/com/jagrosh/jmusicbot/Bot.java @@ -15,6 +15,7 @@ */ 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; @@ -23,6 +24,8 @@ 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,12 +51,15 @@ 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 Instant startTime; private boolean shuttingDown = false; private JDA jda; private GUI gui; + private CommandClient commandClient; public Bot(EventWaiter waiter, BotConfig config, SettingsManager settings) { @@ -72,6 +78,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 +121,17 @@ public AloneInVoiceHandler getAloneInVoiceHandler() { return aloneInVoiceHandler; } - + + public MusicService getMusicService() + { + return musicService; + } + + public SearchService getSearchService() + { + return searchService; + } + public JDA getJDA() { return jda; @@ -178,6 +196,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..47666fc0c 100644 --- a/src/main/java/com/jagrosh/jmusicbot/BotConfig.java +++ b/src/main/java/com/jagrosh/jmusicbot/BotConfig.java @@ -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; @@ -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); @@ -452,6 +453,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/JMusicBot.java b/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java index c0108eebd..c1592961c 100644 --- a/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java +++ b/src/main/java/com/jagrosh/jmusicbot/JMusicBot.java @@ -16,16 +16,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 +26,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; + /** * @@ -140,6 +140,7 @@ private static void startBot() } CommandClient client = CommandFactory.createCommandClient(config, settings, bot); + bot.setCommandClient(client); // Now that GUI/Logging is ready, initialize the player manager bot.getPlayerManager().init(); diff --git a/src/main/java/com/jagrosh/jmusicbot/Listener.java b/src/main/java/com/jagrosh/jmusicbot/Listener.java index a24e884b1..57a97b635 100644 --- a/src/main/java/com/jagrosh/jmusicbot/Listener.java +++ b/src/main/java/com/jagrosh/jmusicbot/Listener.java @@ -15,6 +15,9 @@ */ package com.jagrosh.jmusicbot; +import com.jagrosh.jmusicbot.audio.AudioHandler; + +import com.jagrosh.jmusicbot.commands.SlashCommandRegistry; import com.jagrosh.jmusicbot.utils.OtherUtil; import com.jagrosh.jmusicbot.utils.YoutubeOauth2TokenHandler; import net.dv8tion.jda.api.JDA; @@ -24,15 +27,19 @@ 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.ShutdownEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; +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; /** * @@ -56,6 +63,13 @@ public void onReady(ReadyEvent event) 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)); } + + // 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,6 +131,104 @@ 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) { 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/entities/Prompt.java b/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java index 3de779d8b..98227663d 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) 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/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/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/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/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..d2d166461 --- /dev/null +++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java @@ -0,0 +1,100 @@ +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.Test; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +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); + } + } + + @Test + 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 + 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 + public void testStopAndClear() { + audioHandler.stopAndClear(); + + verify(audioPlayer).stopTrack(); + assertTrue(audioHandler.getQueue().isEmpty()); + } + + @Test + public void testIsMusicPlaying() { + 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)); + + when(voiceState.getChannel()).thenReturn(null); + assertFalse(audioHandler.isMusicPlaying(jda)); + + when(voiceState.getChannel()).thenReturn(audioChannel); + when(audioPlayer.getPlayingTrack()).thenReturn(null); + assertFalse(audioHandler.isMusicPlaying(jda)); + } +} 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/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/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..194ef506c 100644 --- a/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java +++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java @@ -79,6 +79,7 @@ private static Stream testData() } @Test +<<<<<<< HEAD @DisplayName("getLatestVersion returns latest non-prerelease version when latest is not a pre-release") void testGetLatestVersion_NonPrerelease() throws IOException { @@ -312,4 +313,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)); + } }