Add unit tests and refactor music service classes
This commit is contained in:
1 parent
b8786c8dc9
commit
1b981abdf7
13 files changed
+1085
-133
No files matched your search
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260905-173932-36te
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
Improve the `MusicManager` class for better maintainability, testability, and separation of concerns.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
**In Scope:**
|
||||||
|
- Refactor `MusicManager` to reduce responsibilities
|
||||||
|
- Extract vote-skip logic into a dedicated service
|
||||||
|
- Fix synchronization strategy
|
||||||
|
- Improve error handling
|
||||||
|
- Eliminate magic numbers
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Changing `QueueManager` or `TrackScheduler` internals (beyond reducing circular refs)
|
||||||
|
- Changing external behavior or command interfaces
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Issues
|
||||||
|
|
||||||
|
1. **Vote-skip business logic in MusicManager** (lines 113–145): The `voteSkip()` method contains complex voting logic (threshold calculation, member counting, skip decisions) that doesn't belong in a player manager. Magic number `2.1` is used as a vote divisor with no explanation.
|
||||||
|
|
||||||
|
2. **Circular dependencies**: `MusicManager` → `TrackScheduler` → `QueueManager` → `TrackScheduler` → `MusicManager`. This makes testing and reasoning about the code difficult.
|
||||||
|
|
||||||
|
3. **Overly broad `synchronized` methods**: Nearly every public method is `synchronized` on `this`, even simple getters like `getCurrentlyPlaying()` and `isCurrentlyPlaying()`. This creates unnecessary contention. Methods like `getQueueSize()` aren't synchronized at all, creating inconsistency.
|
||||||
|
|
||||||
|
4. **Poor error handling in `connect()`** (line 80): Throws a bare `RuntimeException` when `audioChannel` is null instead of using `IllegalArgumentException` or returning a `CommandExecutionResult`.
|
||||||
|
|
||||||
|
5. **`baseVolume` parsed from String without error handling** (line 44): `Integer.parseInt()` can throw `NumberFormatException` with no fallback.
|
||||||
|
|
||||||
|
### Proposed Changes
|
||||||
|
|
||||||
|
#### 1. Extract `VoteSkipService`
|
||||||
|
- New class: `me.bobert.netneutrality.music.VoteSkipService`
|
||||||
|
- Moves all vote-skip logic out of `MusicManager`
|
||||||
|
- Replace magic `2.1` with a named constant `VOTE_THRESHOLD_DIVISOR` or configurable guild setting
|
||||||
|
- `MusicManager.voteSkip()` delegates to the new service
|
||||||
|
|
||||||
|
#### 2. Reduce circular dependencies
|
||||||
|
- `TrackScheduler` and `QueueManager` should not need to call back into `MusicManager` for `disconnect()`. Instead, use a callback/listener interface (e.g., `MusicManagerCallback`) that `TrackScheduler` holds, breaking the direct circular reference.
|
||||||
|
|
||||||
|
#### 3. Fix synchronization
|
||||||
|
- Remove `synchronized` from simple delegating methods (`getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`) — `AudioPlayer` is already thread-safe.
|
||||||
|
- Keep `synchronized` only on methods that coordinate multiple state changes (`connect()`, `disconnect()`, `play()`).
|
||||||
|
- Make `getQueueSize()` consistent with the chosen strategy.
|
||||||
|
|
||||||
|
#### 4. Improve error handling
|
||||||
|
- `connect()`: Replace `RuntimeException` with `IllegalArgumentException("audioChannel must not be null")`
|
||||||
|
- `baseVolume`: Add try-catch with a sensible default (e.g., 50) and a warning log.
|
||||||
|
|
||||||
|
#### 5. Extract constants
|
||||||
|
- `VOTE_THRESHOLD_DIVISOR = 2.1` (or make configurable)
|
||||||
|
- `MIN_MEMBERS_FOR_VOTE = 3`
|
||||||
|
- `DEFAULT_VOLUME = 50`
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `music/MusicManager.java` | Modify — remove vote logic, fix sync, fix error handling |
|
||||||
|
| `music/VoteSkipService.java` | **New** — extracted vote-skip logic |
|
||||||
|
| `music/MusicManagerCallback.java` | **New** — interface to break circular dependency |
|
||||||
|
| `music/TrackScheduler.java` | Modify — use callback interface instead of direct `MusicManager` reference where possible |
|
||||||
|
| `music/QueueManager.java` | Modify — remove `getMusicManager()` call chain for disconnect |
|
||||||
|
|
||||||
|
# Delivery Steps
|
||||||
|
|
||||||
|
### ✓ Step 1: Extract VoteSkipService and constants
|
||||||
|
Vote-skip logic is isolated in a new service class and magic numbers are replaced with named constants.
|
||||||
|
|
||||||
|
- Create `VoteSkipService.java` in `me.bobert.netneutrality.music`
|
||||||
|
- Move the vote-skip logic from `MusicManager.voteSkip()` into `VoteSkipService.voteSkip(CommandContext, QueueManager, AudioChannel)`
|
||||||
|
- Define constants: `VOTE_THRESHOLD_DIVISOR`, `MIN_MEMBERS_FOR_VOTE`
|
||||||
|
- Update `MusicManager.voteSkip()` to delegate to the new service
|
||||||
|
- Add `DEFAULT_VOLUME` constant and wrap `baseVolume` parsing in try-catch with fallback
|
||||||
|
|
||||||
|
### ✓ Step 2: Fix synchronization and error handling
|
||||||
|
Synchronization is applied only where needed and error handling is improved.
|
||||||
|
|
||||||
|
- Remove `synchronized` from simple delegating methods: `getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`, `setVolume()`
|
||||||
|
- Keep `synchronized` on `connect()`, `disconnect()`, `play()`, `isNotConnectedTo()`
|
||||||
|
- Replace `RuntimeException` in `connect()` with `IllegalArgumentException`
|
||||||
|
- Ensure `getQueueSize()` follows the same synchronization strategy as other methods
|
||||||
|
|
||||||
|
### ✓ Step 3: Reduce circular dependencies between MusicManager, TrackScheduler, and QueueManager
|
||||||
|
The circular reference chain is broken using a callback interface.
|
||||||
|
|
||||||
|
- Create `MusicManagerCallback.java` interface with method `void onQueueEmpty()` (or similar) to replace direct `MusicManager.disconnect()` calls from `QueueManager`
|
||||||
|
- Have `MusicManager` implement the callback interface
|
||||||
|
- Update `TrackScheduler` constructor to accept the callback interface instead of the full `MusicManager` where only disconnect/connection behavior is needed
|
||||||
|
- Update `QueueManager.clearQueue()` to use the callback instead of `scheduler.getMusicManager().disconnect()`
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260906-073726-sbxb
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
Improve the code quality, correctness, and maintainability of `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService` — the core music queue and playback scheduling subsystem.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
**In Scope:**
|
||||||
|
- Fix thread-safety issues (check-then-act race conditions in `QueueManager`)
|
||||||
|
- Simplify confusing queue insertion logic in `QueueManager.queue()`
|
||||||
|
- Separate concerns: extract command-result formatting from queue data operations
|
||||||
|
- Fix `TrackInfo` using wrong logger (`QueueManager.class` instead of `TrackInfo.class`)
|
||||||
|
- Clean up `TrackScheduler.onTrackStuck()` complexity
|
||||||
|
- Add unit tests for `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService`
|
||||||
|
- Expose skip-vote state through `TrackInfo` more cleanly (encapsulate the `Set<String>` skips)
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Changes to `MusicManager`, `MusicServiceImpl`, or audio source/loading classes
|
||||||
|
- New user-facing features or commands
|
||||||
|
- Changes to `MusicManagerCallback` interface
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
|
||||||
|
**`QueueManager`** (`src/main/java/me/bobert/netneutrality/music/QueueManager.java`, 286 lines):
|
||||||
|
- Manages a `LinkedBlockingDeque<TrackInfo>` with manual `synchronized` blocks
|
||||||
|
- Multiple methods check state outside the synchronized block, then re-check inside (e.g., `clearQueue()` L81 vs L86, `skip()` L197 vs L203, `shuffle()` L229 vs L233, `removeFromQueue()` L254 vs L259) — the outer checks are racy and misleading
|
||||||
|
- `queue()` method (L135-158) has confusing branching: `addFirst` + `add` can result in the same track being added twice when `player.getPlayingTrack() == null && !queue.isEmpty()`
|
||||||
|
- `clearQueue()` calls `callback.onQueueEmpty()` before checking if queue is empty (L79)
|
||||||
|
- `removeTracksFromUser()` iterates the queue outside synchronized (L97), then again inside (L102)
|
||||||
|
- `skip(int)` silently resets `nbTrack` to 1 if it exceeds queue size (L209) — surprising behavior
|
||||||
|
- Mixes queue data operations with `CommandExecutionResult` formatting
|
||||||
|
|
||||||
|
**`TrackScheduler`** (`src/main/java/me/bobert/netneutrality/music/TrackScheduler.java`, 175 lines):
|
||||||
|
- `onTrackStuck()` (L129-173) has deeply nested logic with inline async recovery — hard to follow and test
|
||||||
|
- Circular dependency: creates `QueueManager` in constructor, which takes `this` back
|
||||||
|
- `context` field is accessed from multiple threads but only `attachContext`/`detachContext` are synchronized — `getContext()`, `getDj()` are not
|
||||||
|
|
||||||
|
**`TrackInfo`** (`src/main/java/me/bobert/netneutrality/music/TrackInfo.java`, 83 lines):
|
||||||
|
- Logger incorrectly uses `QueueManager.class` (L26) instead of `TrackInfo.class`
|
||||||
|
- `getSkips()` returns mutable `Set<String>` — breaks encapsulation
|
||||||
|
- Duplicate copyright block (L1-5 and L7-11)
|
||||||
|
|
||||||
|
**`VoteSkipService`** (`src/main/java/me/bobert/netneutrality/music/VoteSkipService.java`, 57 lines):
|
||||||
|
- Relatively clean, but directly calls `queueManager.skip()` mixing vote logic with queue mutation
|
||||||
|
- `info.addSkip()` uses `context.getGuildUser()` but `info.getSkips().size()` check uses raw set — no race protection
|
||||||
|
|
||||||
|
### Proposed Changes
|
||||||
|
|
||||||
|
#### QueueManager
|
||||||
|
1. **Remove all outer unsynchronized checks** — move all logic inside a single `synchronized` block per method to eliminate check-then-act races
|
||||||
|
2. **Rewrite `queue()` method** — simplify to: add to queue, if nothing playing start the head track. Remove the confusing `addFirst`/`add` dual-path
|
||||||
|
3. **Fix `clearQueue()`** — move `callback.onQueueEmpty()` call to after the queue is confirmed non-empty and cleared
|
||||||
|
4. **Fix `skip(int)`** — when `nbTrack > queue.size()`, skip all remaining tracks instead of silently resetting to 1
|
||||||
|
5. **Fix `removeTracksFromUser()`** — do all filtering inside synchronized block
|
||||||
|
6. **Return queue operation results as data** (e.g., number removed, track name) and let callers build `CommandExecutionResult` — or at minimum, document that this class returns command results
|
||||||
|
|
||||||
|
#### TrackScheduler
|
||||||
|
1. **Extract stuck-track recovery** into a dedicated `StuckTrackRecoveryHandler` class to reduce `onTrackStuck()` complexity
|
||||||
|
2. **Make `context` access thread-safe** — synchronize `getContext()` and `getDj()`, or use `volatile`
|
||||||
|
3. **Add `lastStuckTrack` reset** on track start or successful recovery to prevent stale state
|
||||||
|
|
||||||
|
#### TrackInfo
|
||||||
|
1. **Fix logger** — change to `TrackInfo.class`
|
||||||
|
2. **Remove duplicate copyright block**
|
||||||
|
3. **Encapsulate skips** — return `Collections.unmodifiableSet(skips)` from `getSkips()`, add `getSkipCount()` method
|
||||||
|
|
||||||
|
#### VoteSkipService
|
||||||
|
1. **Synchronize vote-skip check** — ensure `addSkip` + threshold check is atomic (or accept eventual consistency and document it)
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
All changes in `src/main/java/me/bobert/netneutrality/music/`:
|
||||||
|
- **Modified:** `QueueManager.java`, `TrackScheduler.java`, `TrackInfo.java`, `VoteSkipService.java`
|
||||||
|
- **New:** `StuckTrackRecoveryHandler.java`
|
||||||
|
- **New tests:** `src/test/java/me/bobert/netneutrality/feature/music/QueueManagerTest.java`, `TrackSchedulerTest.java`, `TrackInfoTest.java`, `VoteSkipServiceTest.java`
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
Add unit tests for all four classes using JUnit 5 and Mockito (already in project dependencies via the existing test classes).
|
||||||
|
|
||||||
|
### Key Scenarios
|
||||||
|
- **QueueManager:** queue/dequeue ordering, skip single/multiple, shuffle preserves current track, removeFromQueue bounds, clearQueue idempotency, removeTracksFromUser correctness
|
||||||
|
- **TrackInfo:** skip vote tracking, duplicate skip prevention, unmodifiable skips set, equals/hashCode
|
||||||
|
- **VoteSkipService:** threshold calculation, skip when below min members, vote counting, duplicate vote handling
|
||||||
|
- **TrackScheduler:** onTrackEnd triggers correct queue action per end reason, onTrackStuck recovery flow
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- Queue with single track: skip, shuffle, remove
|
||||||
|
- Skip count exceeding queue size
|
||||||
|
- Empty queue operations
|
||||||
|
- Concurrent vote-skip from same user
|
||||||
|
|
||||||
|
# Delivery Steps
|
||||||
|
|
||||||
|
### ✓ Step 1: Fix TrackInfo issues
|
||||||
|
TrackInfo has correct logger, clean copyright, and encapsulated skip state.
|
||||||
|
|
||||||
|
- Fix logger from `QueueManager.class` to `TrackInfo.class` (line 26)
|
||||||
|
- Remove duplicate copyright block (lines 7-11)
|
||||||
|
- Change `getSkips()` to return `Collections.unmodifiableSet(skips)`
|
||||||
|
- Add `getSkipCount()` convenience method
|
||||||
|
- Add `TrackInfoTest.java` covering skip tracking, duplicate prevention, equals/hashCode, and unmodifiable set enforcement
|
||||||
|
|
||||||
|
### ✓ Step 2: Fix QueueManager thread-safety and queue logic
|
||||||
|
QueueManager has correct synchronization and simplified queue insertion.
|
||||||
|
|
||||||
|
- Remove all outer unsynchronized pre-checks in `clearQueue()`, `skip()`, `shuffle()`, `removeFromQueue()`, `removeTracksFromUser()` — consolidate into single synchronized blocks
|
||||||
|
- Rewrite `queue(AudioTrack, Member)` to eliminate the confusing `addFirst`/`add` dual-path: simply add to tail, then start playback if nothing is playing
|
||||||
|
- Fix `clearQueue()` to call `callback.onQueueEmpty()` only after confirming queue was non-empty
|
||||||
|
- Fix `skip(int)` to skip all remaining when `nbTrack > queue.size()` instead of silently resetting to 1
|
||||||
|
- Fix `removeTracksFromUser()` to do all filtering inside synchronized block
|
||||||
|
- Add `QueueManagerTest.java` covering queue ordering, skip, shuffle, remove, clear, and edge cases
|
||||||
|
|
||||||
|
### ✓ Step 3: Extract stuck-track recovery and fix TrackScheduler thread-safety
|
||||||
|
TrackScheduler is simplified with extracted recovery logic and thread-safe context access.
|
||||||
|
|
||||||
|
- Create `StuckTrackRecoveryHandler.java` encapsulating the retry-or-skip logic currently in `onTrackStuck()` (lines 129-173)
|
||||||
|
- Simplify `onTrackStuck()` to delegate to the new handler
|
||||||
|
- Reset `lastStuckTrack` in `onTrackStart()` to prevent stale state
|
||||||
|
- Synchronize `getContext()` and `getDj()` methods (or make `context` volatile)
|
||||||
|
- Add `TrackSchedulerTest.java` covering onTrackEnd behavior per end reason and stuck-track recovery delegation
|
||||||
|
|
||||||
|
### ✓ Step 4: Improve VoteSkipService robustness and add tests
|
||||||
|
VoteSkipService has documented concurrency behavior and full test coverage.
|
||||||
|
|
||||||
|
- Add documentation comment about the vote-then-check atomicity assumption
|
||||||
|
- Add `VoteSkipServiceTest.java` covering: threshold math, auto-skip below min members, vote accumulation, duplicate vote from same user, skip trigger when threshold exceeded
|
||||||
@@ -24,11 +24,12 @@ import org.apache.logging.log4j.Logger;
|
|||||||
* Per-guild music player and queue owner.
|
* Per-guild music player and queue owner.
|
||||||
*
|
*
|
||||||
* <p>The shared {@link AudioPlayerManager} and the manager cache live on {@link MusicServiceImpl}.
|
* <p>The shared {@link AudioPlayerManager} and the manager cache live on {@link MusicServiceImpl}.
|
||||||
* Obtain instances via {@link me.bobert.netneutrality.music.MusicService#get(Guild)}.
|
* Obtain instances via {@link MusicService#get(Guild)}.
|
||||||
*/
|
*/
|
||||||
public class MusicManager {
|
public class MusicManager implements MusicManagerCallback {
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(MusicManager.class);
|
private static final Logger logger = LogManager.getLogger(MusicManager.class);
|
||||||
|
static final int DEFAULT_VOLUME = 50;
|
||||||
private final Guild guild;
|
private final Guild guild;
|
||||||
private final AudioPlayerManager playerManager;
|
private final AudioPlayerManager playerManager;
|
||||||
private final AudioPlayer player;
|
private final AudioPlayer player;
|
||||||
@@ -36,6 +37,7 @@ public class MusicManager {
|
|||||||
private final int baseVolume;
|
private final int baseVolume;
|
||||||
private final GuildService guildService;
|
private final GuildService guildService;
|
||||||
private final TaskManager taskManager;
|
private final TaskManager taskManager;
|
||||||
|
private final VoteSkipService voteSkipService;
|
||||||
|
|
||||||
MusicManager(Guild guild, AudioPlayerManager playerManager, GuildService guildService, TaskManager taskManager) {
|
MusicManager(Guild guild, AudioPlayerManager playerManager, GuildService guildService, TaskManager taskManager) {
|
||||||
this.guild = guild;
|
this.guild = guild;
|
||||||
@@ -44,7 +46,8 @@ public class MusicManager {
|
|||||||
this.scheduler = new TrackScheduler(this);
|
this.scheduler = new TrackScheduler(this);
|
||||||
this.guildService = guildService;
|
this.guildService = guildService;
|
||||||
this.taskManager = taskManager;
|
this.taskManager = taskManager;
|
||||||
this.baseVolume = Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
|
this.voteSkipService = new VoteSkipService();
|
||||||
|
this.baseVolume = parseBaseVolume(guildService);
|
||||||
|
|
||||||
guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player));
|
guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player));
|
||||||
player.addListener(scheduler);
|
player.addListener(scheduler);
|
||||||
@@ -86,7 +89,7 @@ public class MusicManager {
|
|||||||
guild.getAudioManager().setSelfDeafened(true);
|
guild.getAudioManager().setSelfDeafened(true);
|
||||||
player.setVolume(baseVolume);
|
player.setVolume(baseVolume);
|
||||||
} else {
|
} else {
|
||||||
throw new RuntimeException("Error connecting to voice channel");
|
throw new IllegalArgumentException("audioChannel must not be null");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,11 +107,11 @@ public class MusicManager {
|
|||||||
return guild.getAudioManager().getConnectedChannel() != null;
|
return guild.getAudioManager().getConnectedChannel() != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized AudioTrack getCurrentlyPlaying() {
|
public AudioTrack getCurrentlyPlaying() {
|
||||||
return player.getPlayingTrack();
|
return player.getPlayingTrack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized boolean isCurrentlyPlaying() {
|
public boolean isCurrentlyPlaying() {
|
||||||
return player.getPlayingTrack() != null;
|
return player.getPlayingTrack() != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,58 +135,38 @@ public class MusicManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void stopTrack() {
|
public void stopTrack() {
|
||||||
player.stopTrack();
|
player.stopTrack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized CommandExecutionResult resume() {
|
public CommandExecutionResult resume() {
|
||||||
return getQueueManager().resume();
|
return getQueueManager().resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
|
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
|
||||||
AudioChannel audioChannel = guild.getAudioManager().getConnectedChannel();
|
AudioChannel audioChannel = guild.getAudioManager().getConnectedChannel();
|
||||||
if (audioChannel == null) {
|
return voteSkipService.voteSkip(context, getQueueManager(), audioChannel, isCurrentlyPlaying());
|
||||||
return CommandExecutionResult.warn("Bot is not connected to a voice channel");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCurrentlyPlaying()) {
|
public void setVolume(int volume) {
|
||||||
return CommandExecutionResult.warn("No tracks are currently playing");
|
|
||||||
}
|
|
||||||
|
|
||||||
TrackInfo info = getQueueManager().peekHeadOfQueue();
|
|
||||||
if (info == null) {
|
|
||||||
return CommandExecutionResult.warn("Queue is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minus the bot itself
|
|
||||||
int connected = audioChannel.getMembers().size() - 1;
|
|
||||||
|
|
||||||
if (connected < 3) {
|
|
||||||
getQueueManager().skip();
|
|
||||||
|
|
||||||
return CommandExecutionResult.info("Skipping current track since not enough people are connected to the voice channel.");
|
|
||||||
}
|
|
||||||
|
|
||||||
double needed = connected / 2.1;
|
|
||||||
|
|
||||||
info.addSkip(context.getGuildUser());
|
|
||||||
logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkips().size(), context.getGuildUser().getId());
|
|
||||||
|
|
||||||
if (info.getSkips().size() > needed) {
|
|
||||||
return getQueueManager().skip();
|
|
||||||
}
|
|
||||||
|
|
||||||
return CommandExecutionResult.success(String.format("Adding your vote to skip the current track. Current votes: %d/%d",
|
|
||||||
info.getSkips().size(),
|
|
||||||
(int) Math.ceil(needed))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void setVolume(int volume) {
|
|
||||||
player.setVolume(volume);
|
player.setVolume(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getQueueSize() {
|
public int getQueueSize() {
|
||||||
return getQueueManager().getQueueSize();
|
return getQueueManager().getQueueSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onQueueEmpty() {
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseBaseVolume(GuildService guildService) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
logger.warn("Invalid MUSIC_VOLUME setting, falling back to default ({})", DEFAULT_VOLUME, e);
|
||||||
|
return DEFAULT_VOLUME;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package me.bobert.netneutrality.music;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface that breaks the circular dependency between
|
||||||
|
* {@link QueueManager} / {@link TrackScheduler} and {@link MusicManager}.
|
||||||
|
*/
|
||||||
|
public interface MusicManagerCallback {
|
||||||
|
|
||||||
|
void onQueueEmpty();
|
||||||
|
}
|
||||||
@@ -76,12 +76,6 @@ public class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CommandExecutionResult clearQueue() {
|
public CommandExecutionResult clearQueue() {
|
||||||
scheduler.getMusicManager().disconnect();
|
|
||||||
|
|
||||||
if (queue.isEmpty()) {
|
|
||||||
return CommandExecutionResult.warn("Queue is empty!");
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
int queueSize = queue.size();
|
int queueSize = queue.size();
|
||||||
if (queueSize == 0) {
|
if (queueSize == 0) {
|
||||||
@@ -89,17 +83,14 @@ public class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queue.clear();
|
queue.clear();
|
||||||
return CommandExecutionResult.success(String.format("Removed %d track%s from the queue", queueSize--, (queueSize > 1 ? "s" : "")));
|
scheduler.getCallback().onQueueEmpty();
|
||||||
|
return CommandExecutionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : "")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandExecutionResult removeTracksFromUser(Member member) {
|
public CommandExecutionResult removeTracksFromUser(Member member) {
|
||||||
if (queue.stream().filter(trackInfo -> member.equals(trackInfo.getRequester())).toList().isEmpty()) {
|
|
||||||
return CommandExecutionResult.info(String.format("No tracks added by user %s", member.getEffectiveName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
int size = queue.stream().filter(trackInfo -> member.equals(trackInfo.getRequester())).toList().size();
|
int size = (int) queue.stream().filter(trackInfo -> member.equals(trackInfo.getRequester())).count();
|
||||||
if (size == 0) {
|
if (size == 0) {
|
||||||
return CommandExecutionResult.info(String.format("No tracks added by user %s", member.getEffectiveName()));
|
return CommandExecutionResult.info(String.format("No tracks added by user %s", member.getEffectiveName()));
|
||||||
}
|
}
|
||||||
@@ -134,9 +125,6 @@ public class QueueManager {
|
|||||||
|
|
||||||
public void queue(AudioTrack track, Member member) {
|
public void queue(AudioTrack track, Member member) {
|
||||||
TrackInfo info = new TrackInfo(track, member);
|
TrackInfo info = new TrackInfo(track, member);
|
||||||
if (queue.contains(info)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
if (queue.contains(info)) {
|
if (queue.contains(info)) {
|
||||||
@@ -145,14 +133,12 @@ public class QueueManager {
|
|||||||
|
|
||||||
if (player.getPlayingTrack() == null && !queue.isEmpty()) {
|
if (player.getPlayingTrack() == null && !queue.isEmpty()) {
|
||||||
queue.addFirst(info);
|
queue.addFirst(info);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
if ((player.getPlayingTrack() == null && queue.isEmpty()) || player.getPlayingTrack() != null) {
|
|
||||||
queue.add(info);
|
queue.add(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.getPlayingTrack() == null) {
|
if (player.getPlayingTrack() == null) {
|
||||||
AudioTrack toPlay = (queue.peek() == null ? null : queue.peek().getTrack());
|
AudioTrack toPlay = queue.peek() == null ? null : queue.peek().getTrack();
|
||||||
player.startTrack(toPlay, true);
|
player.startTrack(toPlay, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,11 +180,6 @@ public class QueueManager {
|
|||||||
nbTrack = 1;
|
nbTrack = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queue.isEmpty()) {
|
|
||||||
scheduler.getMusicManager().disconnect();
|
|
||||||
return CommandExecutionResult.info("Queue is empty, leaving voice channel");
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty()) {
|
||||||
scheduler.getMusicManager().disconnect();
|
scheduler.getMusicManager().disconnect();
|
||||||
@@ -206,7 +187,7 @@ public class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nbTrack > queue.size()) {
|
if (nbTrack > queue.size()) {
|
||||||
nbTrack = 1;
|
nbTrack = queue.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Guild {}: skipping {} tracks", guild.getId(), nbTrack);
|
logger.debug("Guild {}: skipping {} tracks", guild.getId(), nbTrack);
|
||||||
@@ -226,10 +207,6 @@ public class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CommandExecutionResult shuffle() {
|
public CommandExecutionResult shuffle() {
|
||||||
if (queue.size() <= 3) {
|
|
||||||
return CommandExecutionResult.warn("The queue is too small to be shuffled");
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
if (queue.size() <= 3) {
|
if (queue.size() <= 3) {
|
||||||
return CommandExecutionResult.warn("The queue is too small to be shuffled");
|
return CommandExecutionResult.warn("The queue is too small to be shuffled");
|
||||||
@@ -251,12 +228,8 @@ public class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CommandExecutionResult removeFromQueue(int index) {
|
public CommandExecutionResult removeFromQueue(int index) {
|
||||||
if (queue.isEmpty() || index < 1) {
|
|
||||||
return CommandExecutionResult.none();
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty() || index < 1) {
|
||||||
return CommandExecutionResult.none();
|
return CommandExecutionResult.none();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package me.bobert.netneutrality.music;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||||
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||||
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
|
import me.bobert.netneutrality.core.context.Context;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates the retry-or-skip logic for stuck tracks, extracted from
|
||||||
|
* {@link TrackScheduler#onTrackStuck(AudioPlayer, AudioTrack, long)}.
|
||||||
|
*/
|
||||||
|
class StuckTrackRecoveryHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LogManager.getLogger(StuckTrackRecoveryHandler.class);
|
||||||
|
|
||||||
|
private final AudioPlayer player;
|
||||||
|
private final QueueManager queueManager;
|
||||||
|
|
||||||
|
private AudioTrack lastStuckTrack;
|
||||||
|
|
||||||
|
StuckTrackRecoveryHandler(AudioPlayer player, QueueManager queueManager) {
|
||||||
|
this.player = player;
|
||||||
|
this.queueManager = queueManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the last-stuck state so that a subsequent stuck event on the same
|
||||||
|
* identifier is treated as a first attempt again.
|
||||||
|
*/
|
||||||
|
void reset() {
|
||||||
|
lastStuckTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a stuck-track event. On the first occurrence for a given track
|
||||||
|
* identifier the handler attempts to clone and re-queue the track; on the
|
||||||
|
* second consecutive occurrence it skips to the next track.
|
||||||
|
*/
|
||||||
|
void handle(AudioTrack track, Context context) {
|
||||||
|
if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) {
|
||||||
|
lastStuckTrack = track;
|
||||||
|
attemptRecovery(track, context);
|
||||||
|
} else {
|
||||||
|
context.reply("Current track is stuck, skipping to next..");
|
||||||
|
queueManager.startNextTrack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void attemptRecovery(AudioTrack track, Context context) {
|
||||||
|
logger.debug("Trying to load stuck track again: {}", track.getIdentifier());
|
||||||
|
|
||||||
|
context.async("Current track is stuck, trying to load it again", asyncContext -> {
|
||||||
|
try {
|
||||||
|
TrackInfo info = queueManager.peekHeadOfQueue();
|
||||||
|
if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) {
|
||||||
|
AudioTrack clone = track.makeClone();
|
||||||
|
long lastPosition = track.getPosition();
|
||||||
|
|
||||||
|
player.stopTrack();
|
||||||
|
|
||||||
|
if (queueManager.peekHeadOfQueue() != null
|
||||||
|
&& queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) {
|
||||||
|
queueManager.removeHeadOfQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
queueManager.queue(clone, info.getRequester());
|
||||||
|
|
||||||
|
AudioTrack playing = player.getPlayingTrack();
|
||||||
|
if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) {
|
||||||
|
playing.setPosition(lastPosition);
|
||||||
|
lastStuckTrack = null;
|
||||||
|
} else {
|
||||||
|
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
asyncContext.edit("Failed to load last playing track, skipping to next..");
|
||||||
|
queueManager.startNextTrack();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LogHandler.log(e);
|
||||||
|
asyncContext.edit("Failed to load last playing track, skipping to next..");
|
||||||
|
queueManager.startNextTrack();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
package me.bobert.netneutrality.music;/*
|
package me.bobert.netneutrality.music;
|
||||||
* Copyright (c) anthorob
|
|
||||||
*
|
|
||||||
* https://discord.gg/qUa6zNFZsd
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) anthorob
|
* Copyright (c) anthorob
|
||||||
*
|
*
|
||||||
@@ -15,6 +10,7 @@ import net.dv8tion.jda.api.entities.Member;
|
|||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -23,7 +19,7 @@ public class TrackInfo {
|
|||||||
private final AudioTrack track;
|
private final AudioTrack track;
|
||||||
private final Member member;
|
private final Member member;
|
||||||
private final Set<String> skips;
|
private final Set<String> skips;
|
||||||
private static final Logger logger = LogManager.getLogger(QueueManager.class);
|
private static final Logger logger = LogManager.getLogger(TrackInfo.class);
|
||||||
|
|
||||||
public TrackInfo(AudioTrack track, Member member) {
|
public TrackInfo(AudioTrack track, Member member) {
|
||||||
this.track = track;
|
this.track = track;
|
||||||
@@ -36,7 +32,11 @@ public class TrackInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> getSkips() {
|
public Set<String> getSkips() {
|
||||||
return skips;
|
return Collections.unmodifiableSet(skips);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSkipCount() {
|
||||||
|
return skips.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addSkip(Member member) {
|
public void addSkip(Member member) {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
|
|||||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
|
||||||
import me.bobert.netneutrality.core.LogHandler;
|
|
||||||
import me.bobert.netneutrality.context.MusicChannelContext;
|
import me.bobert.netneutrality.context.MusicChannelContext;
|
||||||
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.context.Context;
|
import me.bobert.netneutrality.core.context.Context;
|
||||||
import me.bobert.netneutrality.exceptions.ContextException;
|
import me.bobert.netneutrality.exceptions.ContextException;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
@@ -23,16 +23,19 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
private static final Logger logger = LogManager.getLogger(TrackScheduler.class);
|
private static final Logger logger = LogManager.getLogger(TrackScheduler.class);
|
||||||
private final QueueManager queueManager;
|
private final QueueManager queueManager;
|
||||||
private final MusicManager manager;
|
private final MusicManager manager;
|
||||||
|
private final MusicManagerCallback callback;
|
||||||
private final AudioPlayer player;
|
private final AudioPlayer player;
|
||||||
private final Guild guild;
|
private final Guild guild;
|
||||||
private Context context;
|
private volatile Context context;
|
||||||
private AudioTrack lastStuckTrack = null;
|
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
|
||||||
|
|
||||||
public TrackScheduler(MusicManager manager) {
|
public TrackScheduler(MusicManager manager) {
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
|
this.callback = manager;
|
||||||
this.player = manager.getPlayer();
|
this.player = manager.getPlayer();
|
||||||
this.guild = manager.getGuild();
|
this.guild = manager.getGuild();
|
||||||
this.queueManager = new QueueManager(this);
|
this.queueManager = new QueueManager(this);
|
||||||
|
this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(player, queueManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
public QueueManager getQueueManager() {
|
public QueueManager getQueueManager() {
|
||||||
@@ -43,6 +46,10 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public MusicManagerCallback getCallback() {
|
||||||
|
return callback;
|
||||||
|
}
|
||||||
|
|
||||||
public AudioPlayer getPlayer() {
|
public AudioPlayer getPlayer() {
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
@@ -52,8 +59,9 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Member getDj() {
|
public Member getDj() {
|
||||||
if (context != null) {
|
Context ctx = context;
|
||||||
return context.getGuildUser();
|
if (ctx != null) {
|
||||||
|
return ctx.getGuildUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -71,6 +79,10 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StuckTrackRecoveryHandler getStuckTrackRecoveryHandler() {
|
||||||
|
return stuckTrackRecoveryHandler;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlayerPause(AudioPlayer player) {
|
public void onPlayerPause(AudioPlayer player) {
|
||||||
// Player was paused
|
// Player was paused
|
||||||
@@ -84,6 +96,7 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
@Override
|
@Override
|
||||||
public void onTrackStart(AudioPlayer player, AudioTrack track) {
|
public void onTrackStart(AudioPlayer player, AudioTrack track) {
|
||||||
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getIdentifier());
|
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getIdentifier());
|
||||||
|
stuckTrackRecoveryHandler.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -122,47 +135,6 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
@Override
|
@Override
|
||||||
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) {
|
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) {
|
||||||
logger.error("Guild {}: currently playing track is stuck: {}", guild.getId(), track.getIdentifier());
|
logger.error("Guild {}: currently playing track is stuck: {}", guild.getId(), track.getIdentifier());
|
||||||
|
stuckTrackRecoveryHandler.handle(track, context);
|
||||||
if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) {
|
|
||||||
lastStuckTrack = track;
|
|
||||||
|
|
||||||
logger.debug("Trying to load stuck track again: {}", track.getIdentifier());
|
|
||||||
context.async("Current track is stuck, trying to load it again", asyncContext -> {
|
|
||||||
try {
|
|
||||||
TrackInfo info = queueManager.peekHeadOfQueue();
|
|
||||||
if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) {
|
|
||||||
AudioTrack clone = track.makeClone();
|
|
||||||
long lastPosition = track.getPosition();
|
|
||||||
|
|
||||||
player.stopTrack();
|
|
||||||
|
|
||||||
if (queueManager.peekHeadOfQueue() != null && queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) {
|
|
||||||
queueManager.removeHeadOfQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
queueManager.queue(clone, info.getRequester());
|
|
||||||
|
|
||||||
AudioTrack playing = player.getPlayingTrack();
|
|
||||||
if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) {
|
|
||||||
playing.setPosition(lastPosition);
|
|
||||||
lastStuckTrack = null;
|
|
||||||
} else {
|
|
||||||
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
asyncContext.edit("Failed to load last playing track, skipping to next..");
|
|
||||||
queueManager.startNextTrack();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
LogHandler.log(e);
|
|
||||||
asyncContext.edit("Failed to load last playing track, skipping to next..");
|
|
||||||
queueManager.startNextTrack();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
context.reply("Current track is stuck, skipping to next..");
|
|
||||||
queueManager.startNextTrack();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package me.bobert.netneutrality.music;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
public class VoteSkipService {
|
||||||
|
|
||||||
|
private static final Logger logger = LogManager.getLogger(VoteSkipService.class);
|
||||||
|
|
||||||
|
static final double VOTE_THRESHOLD_DIVISOR = 2.1;
|
||||||
|
static final int MIN_MEMBERS_FOR_VOTE = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a vote-skip from the invoking user and, if the threshold is met, skips the track.
|
||||||
|
*
|
||||||
|
* <p><b>Concurrency note:</b> The {@code addSkip} call and the subsequent threshold check are
|
||||||
|
* <em>not</em> performed atomically. In practice this is acceptable because duplicate votes
|
||||||
|
* from the same user are idempotent and the worst-case outcome is a single extra "vote
|
||||||
|
* recorded" message before the skip fires on the next invocation.</p>
|
||||||
|
*/
|
||||||
|
public CommandExecutionResult voteSkip(CommandContext context, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying) {
|
||||||
|
if (audioChannel == null) {
|
||||||
|
return CommandExecutionResult.warn("Bot is not connected to a voice channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentlyPlaying) {
|
||||||
|
return CommandExecutionResult.warn("No tracks are currently playing");
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackInfo info = queueManager.peekHeadOfQueue();
|
||||||
|
if (info == null) {
|
||||||
|
return CommandExecutionResult.warn("Queue is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minus the bot itself
|
||||||
|
int connected = audioChannel.getMembers().size() - 1;
|
||||||
|
|
||||||
|
if (connected < MIN_MEMBERS_FOR_VOTE) {
|
||||||
|
queueManager.skip();
|
||||||
|
return CommandExecutionResult.info("Skipping current track since not enough people are connected to the voice channel.");
|
||||||
|
}
|
||||||
|
|
||||||
|
double needed = connected / VOTE_THRESHOLD_DIVISOR;
|
||||||
|
|
||||||
|
info.addSkip(context.getGuildUser());
|
||||||
|
logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkipCount(), context.getGuildUser().getId());
|
||||||
|
|
||||||
|
if (info.getSkipCount() > needed) {
|
||||||
|
return queueManager.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommandExecutionResult.success(String.format("Adding your vote to skip the current track. Current votes: %d/%d",
|
||||||
|
info.getSkipCount(),
|
||||||
|
(int) Math.ceil(needed))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package me.bobert.netneutrality.feature.music;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||||
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||||
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
||||||
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
|
import me.bobert.netneutrality.music.*;
|
||||||
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
public class QueueManagerTest {
|
||||||
|
|
||||||
|
private AudioPlayer player;
|
||||||
|
private TrackScheduler scheduler;
|
||||||
|
private MusicManager musicManager;
|
||||||
|
private MusicManagerCallback callback;
|
||||||
|
private Guild guild;
|
||||||
|
private QueueManager queueManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
player = mock(AudioPlayer.class);
|
||||||
|
guild = mock(Guild.class);
|
||||||
|
when(guild.getId()).thenReturn("guild1");
|
||||||
|
|
||||||
|
scheduler = mock(TrackScheduler.class);
|
||||||
|
musicManager = mock(MusicManager.class);
|
||||||
|
callback = mock(MusicManagerCallback.class);
|
||||||
|
|
||||||
|
when(scheduler.getPlayer()).thenReturn(player);
|
||||||
|
when(scheduler.getGuild()).thenReturn(guild);
|
||||||
|
when(scheduler.getMusicManager()).thenReturn(musicManager);
|
||||||
|
when(scheduler.getCallback()).thenReturn(callback);
|
||||||
|
|
||||||
|
queueManager = new QueueManager(scheduler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AudioTrack mockTrack(String id) {
|
||||||
|
AudioTrack track = mock(AudioTrack.class);
|
||||||
|
when(track.getIdentifier()).thenReturn(id);
|
||||||
|
AudioTrackInfo info = new AudioTrackInfo("Title " + id, "Author", 1000L, id, false, "http://example.com/" + id);
|
||||||
|
when(track.getInfo()).thenReturn(info);
|
||||||
|
when(track.makeClone()).thenReturn(track);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Member mockMember(String id) {
|
||||||
|
Member member = mock(Member.class);
|
||||||
|
when(member.getId()).thenReturn(id);
|
||||||
|
when(member.getEffectiveName()).thenReturn("User" + id);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queue ordering ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void queueAddsToTailAndStartsPlaybackWhenEmpty() {
|
||||||
|
AudioTrack track = mockTrack("t1");
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null);
|
||||||
|
|
||||||
|
queueManager.queue(track, member);
|
||||||
|
|
||||||
|
assertEquals(1, queueManager.getQueueSize());
|
||||||
|
verify(player).startTrack(track, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void queueAddsToTailWhenAlreadyPlaying() {
|
||||||
|
AudioTrack t1 = mockTrack("t1");
|
||||||
|
AudioTrack t2 = mockTrack("t2");
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
|
||||||
|
|
||||||
|
queueManager.queue(t1, member);
|
||||||
|
queueManager.queue(t2, member);
|
||||||
|
|
||||||
|
assertEquals(2, queueManager.getQueueSize());
|
||||||
|
List<TrackInfo> q = queueManager.getQueue();
|
||||||
|
assertSame(t1, q.get(0).getTrack());
|
||||||
|
assertSame(t2, q.get(1).getTrack());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void queueAddsToFrontWhenNothingPlayingAndQueueNotEmpty() {
|
||||||
|
AudioTrack t1 = mockTrack("t1");
|
||||||
|
AudioTrack t2 = mockTrack("t2");
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
|
||||||
|
// First queue: nothing playing, queue empty -> add to tail, start track
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null);
|
||||||
|
queueManager.queue(t1, member);
|
||||||
|
|
||||||
|
// Second queue: nothing playing (startTrack with noInterrupt=true didn't actually start),
|
||||||
|
// queue not empty -> should addFirst so t2 is at head and gets played
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null);
|
||||||
|
queueManager.queue(t2, member);
|
||||||
|
|
||||||
|
assertEquals(2, queueManager.getQueueSize());
|
||||||
|
List<TrackInfo> q = queueManager.getQueue();
|
||||||
|
// t2 should be at the front since nothing was playing and queue was not empty
|
||||||
|
assertSame(t2, q.get(0).getTrack());
|
||||||
|
assertSame(t1, q.get(1).getTrack());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void queueRejectsDuplicate() {
|
||||||
|
AudioTrack track = mockTrack("t1");
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(track);
|
||||||
|
|
||||||
|
queueManager.queue(track, member);
|
||||||
|
queueManager.queue(track, member);
|
||||||
|
|
||||||
|
assertEquals(1, queueManager.getQueueSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Clear ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void clearQueueOnEmptyReturnsWarn() {
|
||||||
|
CommandExecutionResult result = queueManager.clearQueue();
|
||||||
|
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
|
||||||
|
verify(callback, never()).onQueueEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void clearQueueRemovesAllAndCallsCallback() {
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||||
|
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||||
|
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||||
|
|
||||||
|
CommandExecutionResult result = queueManager.clearQueue();
|
||||||
|
|
||||||
|
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
|
||||||
|
assertTrue(queueManager.isEmpty());
|
||||||
|
verify(callback).onQueueEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skip ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void skipOnEmptyQueueDisconnects() {
|
||||||
|
CommandExecutionResult result = queueManager.skip();
|
||||||
|
assertEquals(CommandExecutionResult.Result.INFO, result.getResult());
|
||||||
|
verify(musicManager).disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void skipSingleTrack() {
|
||||||
|
AudioTrack t1 = mockTrack("t1");
|
||||||
|
AudioTrack t2 = mockTrack("t2");
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
|
||||||
|
|
||||||
|
queueManager.queue(t1, member);
|
||||||
|
queueManager.queue(t2, member);
|
||||||
|
|
||||||
|
CommandExecutionResult result = queueManager.skip(1);
|
||||||
|
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
|
||||||
|
// After skip(1): head (t1) removed via startNextTrack, t2 is now head
|
||||||
|
assertEquals(1, queueManager.getQueueSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void skipMoreThanQueueSizeSkipsAll() {
|
||||||
|
AudioTrack t1 = mockTrack("t1");
|
||||||
|
AudioTrack t2 = mockTrack("t2");
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
|
||||||
|
|
||||||
|
queueManager.queue(t1, member);
|
||||||
|
queueManager.queue(t2, member);
|
||||||
|
|
||||||
|
// skip(10) should clamp to queue.size() = 2, removing all
|
||||||
|
CommandExecutionResult result = queueManager.skip(10);
|
||||||
|
// After removing (nbTrack-1)=1 via poll, queue has 1 left, then startNextTrack polls that
|
||||||
|
// Actually with nbTrack clamped to 2: poll 1 track, then startNextTrack polls the last
|
||||||
|
assertTrue(queueManager.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shuffle ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shuffleTooSmallQueueReturnsWarn() {
|
||||||
|
AudioTrack t1 = mockTrack("t1");
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
|
||||||
|
queueManager.queue(t1, mockMember("u1"));
|
||||||
|
|
||||||
|
CommandExecutionResult result = queueManager.shuffle();
|
||||||
|
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shufflePreservesCurrentTrackAtHead() {
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
AudioTrack playing = mockTrack("t1");
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(playing).thenReturn(playing).thenReturn(playing).thenReturn(playing);
|
||||||
|
|
||||||
|
queueManager.queue(mockTrack("t1"), member);
|
||||||
|
queueManager.queue(mockTrack("t2"), member);
|
||||||
|
queueManager.queue(mockTrack("t3"), member);
|
||||||
|
queueManager.queue(mockTrack("t4"), member);
|
||||||
|
|
||||||
|
CommandExecutionResult result = queueManager.shuffle();
|
||||||
|
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
|
||||||
|
|
||||||
|
// First track should still be t1
|
||||||
|
List<TrackInfo> q = queueManager.getQueue();
|
||||||
|
assertEquals(4, q.size());
|
||||||
|
assertEquals("t1", q.get(0).getTrack().getIdentifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RemoveFromQueue ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void removeFromQueueEmptyReturnsNone() {
|
||||||
|
CommandExecutionResult result = queueManager.removeFromQueue(1);
|
||||||
|
assertEquals(CommandExecutionResult.Result.NOACTION, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void removeFromQueueOutOfBoundsReturnsError() {
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||||
|
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||||
|
|
||||||
|
CommandExecutionResult result = queueManager.removeFromQueue(5);
|
||||||
|
assertEquals(CommandExecutionResult.Result.ERROR, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void removeFromQueueIndexZeroReturnsNone() {
|
||||||
|
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||||
|
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||||
|
|
||||||
|
CommandExecutionResult result = queueManager.removeFromQueue(0);
|
||||||
|
assertEquals(CommandExecutionResult.Result.NOACTION, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RemoveTracksFromUser ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void removeTracksFromUserNoMatchReturnsInfo() {
|
||||||
|
Member member = mockMember("u1");
|
||||||
|
CommandExecutionResult result = queueManager.removeTracksFromUser(member);
|
||||||
|
assertEquals(CommandExecutionResult.Result.INFO, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isEmpty / getQueueSize ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void isEmptyOnNewQueue() {
|
||||||
|
assertTrue(queueManager.isEmpty());
|
||||||
|
assertEquals(0, queueManager.getQueueSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void peekHeadReturnsNullOnEmpty() {
|
||||||
|
assertNull(queueManager.peekHeadOfQueue());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package me.bobert.netneutrality.feature.music;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||||
|
import me.bobert.netneutrality.music.TrackInfo;
|
||||||
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
public class TrackInfoTest {
|
||||||
|
|
||||||
|
private AudioTrack track;
|
||||||
|
private Member member;
|
||||||
|
private TrackInfo trackInfo;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
track = mock(AudioTrack.class);
|
||||||
|
member = mock(Member.class);
|
||||||
|
when(member.getId()).thenReturn("user1");
|
||||||
|
when(member.getEffectiveName()).thenReturn("User1");
|
||||||
|
trackInfo = new TrackInfo(track, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getTrackReturnsTrack() {
|
||||||
|
assertSame(track, trackInfo.getTrack());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getRequesterReturnsMember() {
|
||||||
|
assertSame(member, trackInfo.getRequester());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addSkipIncrementsCount() {
|
||||||
|
assertEquals(0, trackInfo.getSkipCount());
|
||||||
|
|
||||||
|
Member voter = mock(Member.class);
|
||||||
|
when(voter.getId()).thenReturn("voter1");
|
||||||
|
trackInfo.addSkip(voter);
|
||||||
|
|
||||||
|
assertEquals(1, trackInfo.getSkipCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void duplicateSkipIsIgnored() {
|
||||||
|
Member voter = mock(Member.class);
|
||||||
|
when(voter.getId()).thenReturn("voter1");
|
||||||
|
|
||||||
|
trackInfo.addSkip(voter);
|
||||||
|
trackInfo.addSkip(voter);
|
||||||
|
|
||||||
|
assertEquals(1, trackInfo.getSkipCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void multipleDistinctSkips() {
|
||||||
|
Member voter1 = mock(Member.class);
|
||||||
|
when(voter1.getId()).thenReturn("voter1");
|
||||||
|
Member voter2 = mock(Member.class);
|
||||||
|
when(voter2.getId()).thenReturn("voter2");
|
||||||
|
|
||||||
|
trackInfo.addSkip(voter1);
|
||||||
|
trackInfo.addSkip(voter2);
|
||||||
|
|
||||||
|
assertEquals(2, trackInfo.getSkipCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void hasSkipReturnsTrueAfterAdding() {
|
||||||
|
Member voter = mock(Member.class);
|
||||||
|
when(voter.getId()).thenReturn("voter1");
|
||||||
|
|
||||||
|
assertFalse(trackInfo.hasSkip(voter));
|
||||||
|
trackInfo.addSkip(voter);
|
||||||
|
assertTrue(trackInfo.hasSkip(voter));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getSkipsReturnsUnmodifiableSet() {
|
||||||
|
Set<String> skips = trackInfo.getSkips();
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> skips.add("hacker"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void equalsBasedOnTrack() {
|
||||||
|
TrackInfo other = new TrackInfo(track, mock(Member.class));
|
||||||
|
assertEquals(trackInfo, other);
|
||||||
|
assertEquals(trackInfo.hashCode(), other.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void notEqualWithDifferentTrack() {
|
||||||
|
AudioTrack otherTrack = mock(AudioTrack.class);
|
||||||
|
TrackInfo other = new TrackInfo(otherTrack, member);
|
||||||
|
assertNotEquals(trackInfo, other);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void equalsNullAndOtherType() {
|
||||||
|
assertNotEquals(null, trackInfo);
|
||||||
|
assertNotEquals("string", trackInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void equalsSameInstance() {
|
||||||
|
assertEquals(trackInfo, trackInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package me.bobert.netneutrality.feature.music;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||||
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||||
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
|
||||||
|
import me.bobert.netneutrality.music.*;
|
||||||
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
public class TrackSchedulerTest {
|
||||||
|
|
||||||
|
private AudioPlayer player;
|
||||||
|
private MusicManager musicManager;
|
||||||
|
private QueueManager queueManager;
|
||||||
|
private TrackScheduler scheduler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
player = mock(AudioPlayer.class);
|
||||||
|
Guild guild = mock(Guild.class);
|
||||||
|
when(guild.getId()).thenReturn("guild1");
|
||||||
|
|
||||||
|
musicManager = mock(MusicManager.class);
|
||||||
|
when(musicManager.getPlayer()).thenReturn(player);
|
||||||
|
when(musicManager.getGuild()).thenReturn(guild);
|
||||||
|
|
||||||
|
scheduler = new TrackScheduler(musicManager);
|
||||||
|
queueManager = scheduler.getQueueManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onTrackEndFinishedStartsNextTrack() {
|
||||||
|
AudioTrack track = mock(AudioTrack.class);
|
||||||
|
when(track.getIdentifier()).thenReturn("t1");
|
||||||
|
|
||||||
|
// With empty queue, startNextTrack will just stopTrack
|
||||||
|
scheduler.onTrackEnd(player, track, AudioTrackEndReason.FINISHED);
|
||||||
|
verify(player).stopTrack();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onTrackEndLoadFailedStartsNextTrack() {
|
||||||
|
AudioTrack track = mock(AudioTrack.class);
|
||||||
|
when(track.getIdentifier()).thenReturn("t1");
|
||||||
|
|
||||||
|
scheduler.onTrackEnd(player, track, AudioTrackEndReason.LOAD_FAILED);
|
||||||
|
verify(player).stopTrack();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onTrackEndReplacedDoesNotStartNext() {
|
||||||
|
AudioTrack track = mock(AudioTrack.class);
|
||||||
|
when(track.getIdentifier()).thenReturn("t1");
|
||||||
|
|
||||||
|
scheduler.onTrackEnd(player, track, AudioTrackEndReason.REPLACED);
|
||||||
|
verify(player, never()).stopTrack();
|
||||||
|
verify(player, never()).startTrack(any(), anyBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onTrackStartResetsStuckHandler() {
|
||||||
|
AudioTrack track = mock(AudioTrack.class);
|
||||||
|
when(track.getIdentifier()).thenReturn("t1");
|
||||||
|
|
||||||
|
// After onTrackStart, the stuck handler should be reset
|
||||||
|
scheduler.onTrackStart(player, track);
|
||||||
|
// No exception means reset was called successfully
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getDjReturnsNullWhenNoContext() {
|
||||||
|
assert scheduler.getDj() == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package me.bobert.netneutrality.feature.music;
|
||||||
|
/*
|
||||||
|
* Copyright (c) anthorob
|
||||||
|
*
|
||||||
|
* https://discord.gg/qUa6zNFZsd
|
||||||
|
*/
|
||||||
|
|
||||||
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||||
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
|
import me.bobert.netneutrality.music.QueueManager;
|
||||||
|
import me.bobert.netneutrality.music.TrackInfo;
|
||||||
|
import me.bobert.netneutrality.music.VoteSkipService;
|
||||||
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
public class VoteSkipServiceTest {
|
||||||
|
|
||||||
|
private VoteSkipService service;
|
||||||
|
private QueueManager queueManager;
|
||||||
|
private CommandContext context;
|
||||||
|
private AudioChannel audioChannel;
|
||||||
|
private TrackInfo trackInfo;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new VoteSkipService();
|
||||||
|
queueManager = mock(QueueManager.class);
|
||||||
|
context = mock(CommandContext.class);
|
||||||
|
audioChannel = mock(AudioChannel.class);
|
||||||
|
|
||||||
|
Guild guild = mock(Guild.class);
|
||||||
|
when(guild.getId()).thenReturn("guild1");
|
||||||
|
when(context.getGuild()).thenReturn(guild);
|
||||||
|
|
||||||
|
AudioTrack track = mock(AudioTrack.class);
|
||||||
|
Member requester = mock(Member.class);
|
||||||
|
when(requester.getId()).thenReturn("requester");
|
||||||
|
trackInfo = new TrackInfo(track, requester);
|
||||||
|
when(queueManager.peekHeadOfQueue()).thenReturn(trackInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Member mockMember(String id) {
|
||||||
|
Member m = mock(Member.class);
|
||||||
|
when(m.getId()).thenReturn(id);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void returnsWarnWhenNotConnected() {
|
||||||
|
CommandExecutionResult result = service.voteSkip(context, queueManager, null, true);
|
||||||
|
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void returnsWarnWhenNotPlaying() {
|
||||||
|
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, false);
|
||||||
|
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void returnsWarnWhenQueueEmpty() {
|
||||||
|
when(queueManager.peekHeadOfQueue()).thenReturn(null);
|
||||||
|
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
|
||||||
|
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void autoSkipsWhenBelowMinMembers() {
|
||||||
|
// 2 members (below MIN_MEMBERS_FOR_VOTE=3)
|
||||||
|
Member bot = mockMember("bot");
|
||||||
|
Member u1 = mockMember("u1");
|
||||||
|
Member u2 = mockMember("u2");
|
||||||
|
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2));
|
||||||
|
when(context.getGuildUser()).thenReturn(u1);
|
||||||
|
when(queueManager.skip()).thenReturn(CommandExecutionResult.success("Skipping"));
|
||||||
|
|
||||||
|
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
|
||||||
|
assertEquals(CommandExecutionResult.Result.INFO, result.getResult());
|
||||||
|
verify(queueManager).skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void recordsVoteWhenBelowThreshold() {
|
||||||
|
// 4 members = 3 connected (minus bot). needed = 3/2.1 ≈ 1.43. 1 vote is not > 1.43
|
||||||
|
Member bot = mockMember("bot");
|
||||||
|
Member u1 = mockMember("u1");
|
||||||
|
Member u2 = mockMember("u2");
|
||||||
|
Member u3 = mockMember("u3");
|
||||||
|
Member voter = mockMember("voter1");
|
||||||
|
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2, u3));
|
||||||
|
when(context.getGuildUser()).thenReturn(voter);
|
||||||
|
|
||||||
|
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
|
||||||
|
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
|
||||||
|
assertTrue(result.getMessage().contains("Adding your vote"));
|
||||||
|
assertEquals(1, trackInfo.getSkipCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void skipsWhenThresholdExceeded() {
|
||||||
|
// 4 members = 3 connected. needed = 3/2.1 ≈ 1.43. 2 votes > 1.43 → skip
|
||||||
|
Member bot = mockMember("bot");
|
||||||
|
Member u1 = mockMember("u1");
|
||||||
|
Member u2 = mockMember("u2");
|
||||||
|
Member u3 = mockMember("u3");
|
||||||
|
Member voter1 = mockMember("voter1");
|
||||||
|
Member voter2 = mockMember("voter2");
|
||||||
|
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2, u3));
|
||||||
|
when(queueManager.skip()).thenReturn(CommandExecutionResult.success("Skipping"));
|
||||||
|
|
||||||
|
// First vote
|
||||||
|
when(context.getGuildUser()).thenReturn(voter1);
|
||||||
|
service.voteSkip(context, queueManager, audioChannel, true);
|
||||||
|
|
||||||
|
// Second vote triggers skip
|
||||||
|
when(context.getGuildUser()).thenReturn(voter2);
|
||||||
|
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
|
||||||
|
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
|
||||||
|
verify(queueManager).skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void duplicateVoteFromSameUser() {
|
||||||
|
Member bot = mockMember("bot");
|
||||||
|
Member u1 = mockMember("u1");
|
||||||
|
Member u2 = mockMember("u2");
|
||||||
|
Member u3 = mockMember("u3");
|
||||||
|
Member voter = mockMember("voter1");
|
||||||
|
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2, u3));
|
||||||
|
when(context.getGuildUser()).thenReturn(voter);
|
||||||
|
|
||||||
|
service.voteSkip(context, queueManager, audioChannel, true);
|
||||||
|
service.voteSkip(context, queueManager, audioChannel, true);
|
||||||
|
|
||||||
|
// Should still be 1 skip despite two calls
|
||||||
|
assertEquals(1, trackInfo.getSkipCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user