Remove Lavaplayer and migrate to Lavalink
Replaced the bot's audio handling system by removing Lavaplayer dependencies and migrating to a Lavalink v4 node. Updated `MusicManager`, `TrackScheduler`, and related classes to utilize the `LavalinkClient`. Refactored playlist and track loading logic, task integrations, and event dispatch to integrate with the remote node. Deprecated the custom Udio source, preserving references for a future plugin port. Documented configuration and deployment details for the new audio architecture.
This commit is contained in:
1 parent
01bfe22d22
commit
4cf7c47645
34 files changed
+1180
-472
No files matched your search
+7
-4
@@ -1,5 +1,4 @@
|
|||||||
NETNEUTRALITY_TAG=latest
|
NETNEUTRALITY_TAG=latest
|
||||||
|
|
||||||
NETNEUTRALITY_BOT_ENV=dev
|
NETNEUTRALITY_BOT_ENV=dev
|
||||||
NETNEUTRALITY_BOT_TOKEN=
|
NETNEUTRALITY_BOT_TOKEN=
|
||||||
NETNEUTRALITY_BOT_PREFIX=$
|
NETNEUTRALITY_BOT_PREFIX=$
|
||||||
@@ -12,13 +11,17 @@ MYSQL_PORT=3306
|
|||||||
MYSQL_USER=java
|
MYSQL_USER=java
|
||||||
MYSQL_PASSWORD=password
|
MYSQL_PASSWORD=password
|
||||||
MYSQL_DATABASE=netneutrality
|
MYSQL_DATABASE=netneutrality
|
||||||
MYSQL_ROOT_PASSWORD=password
|
|
||||||
MARIADB_AUTO_UPGRADE=1
|
|
||||||
|
|
||||||
|
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
|
||||||
|
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
|
||||||
|
NETNEUTRALITY_LAVALINK_NAME=dev-player
|
||||||
NETNEUTRALITY_PO_TOKEN=
|
NETNEUTRALITY_PO_TOKEN=
|
||||||
NETNEUTRALITY_VISITOR_DATA=
|
NETNEUTRALITY_VISITOR_DATA=
|
||||||
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
NETNEUTRALITY_OAUTH_TOKEN=
|
||||||
|
NETNEUTRALITY_CIPHER_URL=http://ejs-api:8001
|
||||||
NETNEUTRALITY_CIPHER_PASSWORD=
|
NETNEUTRALITY_CIPHER_PASSWORD=
|
||||||
|
NETNEUTRALITY_WEBPO_URL=http://webpo:8080
|
||||||
|
NETNEUTRALITY_WEBPO_PASSWORD=
|
||||||
|
|
||||||
NETNEUTRALITY_SENTRY_DSN=
|
NETNEUTRALITY_SENTRY_DSN=
|
||||||
GITLAB_HOST=
|
GITLAB_HOST=
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
---
|
||||||
|
sessionId: session-260923-114918-ce7w
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
### Overview & Goals
|
||||||
|
|
||||||
|
Replace the in-process **Lavaplayer** audio stack with a remote **Lavalink v4** node driven by the official `dev.arbjerg:lavalink-client`. Audio decoding, source resolution and voice sending move out of the bot process to the Lavalink node, which can then be extended with server-side plugins (YouTube source, LavaSrc, etc.) without touching the bot.
|
||||||
|
|
||||||
|
All dependency/plugin examples from the Lavalink docs are Gradle-based and will be **translated to Maven** (`pom.xml` `<repositories>` / `<dependencies>`); the Lavalink node itself is configured via a YAML file mounted into the container, not via any build script.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
**In Scope**
|
||||||
|
- Swap `dev.arbjerg:lavaplayer` + `dev.lavalink.youtube:v2` for `dev.arbjerg:lavalink-client` in `pom.xml`.
|
||||||
|
- Rewrite the `me.bobert.netneutrality.music` package against Lavalink: `MusicServiceImpl`, `MusicManager`, `TrackScheduler`, `QueueManager`, `TrackInfo`, `TrackLoadingHandler`, `StuckTrackRecoveryHandler`; delete `AudioPlayerSendHandler`.
|
||||||
|
- Route voice connections through Lavalink's `JDAVoiceUpdateListener` / `DirectAudioController` instead of `Guild#getAudioManager()`.
|
||||||
|
- Add a `lavalink` service plus `lavalink/application.yml` to `docker-compose.yml`, wired to the existing `ejs-api` (yt-cipher) container.
|
||||||
|
- Update `MessageFunction`, `LoadPlaylistTask`, `LoadTrackFromPlaylistTask` and the music command classes to the new track type.
|
||||||
|
- Update the affected tests and the docs under `docs/`.
|
||||||
|
|
||||||
|
**Out of Scope**
|
||||||
|
- Porting `music/source/UdioAudioSourceManager` to a Lavalink server plugin — captured as a documented follow-up. Udio playback is temporarily unavailable; the existing code stays in the repo as the porting reference.
|
||||||
|
- Any change to command UX, queue semantics, DJ rules or vote-skip behaviour.
|
||||||
|
- Audio filters/equalizer, node load balancing across multiple nodes, and session resuming (Lavalink supports these; not enabled here).
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
- As a bot operator, I want audio to be served by a separate Lavalink node so that I can add source plugins and restart the bot without interrupting the audio pipeline's configuration.
|
||||||
|
- As a user, I want `/play`, `/skip`, `/queue`, `/volume`, `/stop`, `/resume`, `/shuffle` and vote-skip to behave exactly as before the migration.
|
||||||
|
- As a maintainer, I want YouTube cipher/poToken configuration to live in the node's `application.yml` rather than in Java code.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
- All 13 commands in `command/music/` keep their current signatures, replies and `CommandExecutionResult` semantics.
|
||||||
|
- `SourceFinder` keeps classifying URLs and prefixing searches with `ytsearch:`; identifiers are passed to Lavalink's `loadItem` unchanged.
|
||||||
|
- Queue ordering, duplicate rejection, auto-start on first enqueue, auto-disconnect on empty queue and the two-strike stuck-track recovery are preserved.
|
||||||
|
- If the Lavalink node is unreachable at startup, the bot still starts; music commands reply with a clear error instead of crashing.
|
||||||
|
- Udio URLs reply with a "temporarily unsupported" message rather than failing silently.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
- The bot no longer ships native audio encoders: the `jdave-api` / `jdave-native-*` dependencies and the `AudioModuleConfig` DAVE wiring in `App.initJDA()` are removed, since DAVE is handled by the node (Lavalink ≥ 4.2.0).
|
||||||
|
- New settings are read through the existing `SettingsProvider` and documented in `bot.properties.example`.
|
||||||
|
- Checkstyle (`checkstyle.xml`, bound to the `test` phase) must keep passing.
|
||||||
|
|
||||||
|
# Technical Design
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
|
||||||
|
- `MusicServiceImpl` (priority-40 `Lifecycle`, registered in `App.buildContext()` line 140) owns a `DefaultAudioPlayerManager` and registers YouTube (with `setRemoteCipher(...)`), SoundCloud, Udio and HTTP source managers in `start()`; `stop()` clears queues and calls `player.destroy()`.
|
||||||
|
- `MusicManager` creates an `AudioPlayer` per guild, sets `AudioPlayerSendHandler` on `guild.getAudioManager()`, opens/closes the voice connection itself, and calls `playerManager.loadItemOrdered(this, identifier, new TrackLoadingHandler(...))`.
|
||||||
|
- `TrackScheduler extends AudioEventAdapter` and receives `onTrackStart/End/Exception/Stuck` callbacks directly from the player.
|
||||||
|
- `QueueManager` holds a `Deque<TrackInfo>` and drives playback with `player.startTrack(track.makeClone(), false)` / `player.stopTrack()`.
|
||||||
|
- `StuckTrackRecoveryHandler` relies on `AudioTrack#makeClone`, `getPosition()` and `setPosition()`.
|
||||||
|
- `com.sedmelluq.*` types appear in 14 main sources and 5 tests (`QueueManagerTest`, `TrackInfoTest`, `TrackSchedulerTest`, `VoteSkipServiceTest`, `UdioAudioSourceManagerTest`).
|
||||||
|
- `pom.xml` already declares the `https://maven.lavalink.dev/releases` repository (lines 136–140).
|
||||||
|
- `docker-compose.yml` runs `db`, `bot` and `ejs_signature_api` (yt-cipher) on the `netneutrality` network; there is no Lavalink node.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
1. **Official client** — `dev.arbjerg:lavalink-client` (v4-only). `VoiceDispatchInterceptor` is verified present in the bundled JDA 6.4.1 jar, so `JDAVoiceUpdateListener` attaches cleanly.
|
||||||
|
2. **Central event dispatcher** — `MusicServiceImpl` holds the single subscription to the client's event flux and routes each event to the owning guild's `TrackScheduler`. One `Disposable` to dispose in `stop()`.
|
||||||
|
3. **Synchronous facade** — `MusicManager` subscribes to the returned `Mono`s internally; command classes keep their current synchronous signatures. Failures are reported via `LogHandler` and the attached `MusicChannelContext`.
|
||||||
|
4. **Lavalink `Track` everywhere** — `dev.arbjerg.lavalink.protocol.v4.Track` replaces `AudioTrack` in `TrackInfo`, `QueueManager`, `MessageFunction` and the playlist tasks. `Track` is an immutable data class, so `makeClone()` disappears and `setPosition` becomes `player.setPosition(...)` on the Lavalink player.
|
||||||
|
5. **Node in compose** — a `lavalink` service with a mounted `application.yml`; YouTube cipher/poToken config moves out of `MusicServiceImpl.start()` into that YAML.
|
||||||
|
6. **Udio deferred** — `music/source/**` is excluded from compilation-relevant wiring and the `Udio` branch of `SourceFinder.getIdentifier` returns `null` with a friendly reply; the classes remain for the future plugin port.
|
||||||
|
|
||||||
|
### Proposed Changes
|
||||||
|
|
||||||
|
**Maven (`pom.xml`)** — translated from the docs' Gradle snippets:
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.arbjerg</groupId>
|
||||||
|
<artifactId>lavalink-client</artifactId>
|
||||||
|
<version>3.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
resolved from the already-present `arbjergDev-releases` repository. Removed: `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2`, `club.minnced:jdave-*`.
|
||||||
|
|
||||||
|
**`MusicServiceImpl`**
|
||||||
|
- Field `LavalinkClient client = new LavalinkClient(Helpers.getUserIdFromToken(token))`.
|
||||||
|
- `start()` registers one node (`client.addNode(...)` with URI, password and name from settings) and opens the dispatcher subscription:
|
||||||
|
`client.on(TrackStartEvent.class)`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`, `WebSocketClosedEvent` → look up `managers.get(event.getGuildId())` → forward to its `TrackScheduler`.
|
||||||
|
- `stop()` disposes the subscriptions, clears queues, destroys players and closes the client.
|
||||||
|
- Exposes `LavalinkClient getClient()` in place of `getPlayerManager()`.
|
||||||
|
|
||||||
|
**`App`**
|
||||||
|
- `initJDA()` adds `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` and drops `.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))`.
|
||||||
|
- Ordering: the `LavalinkClient` must exist before JDA is built, which the existing priorities already guarantee (music = 40, JDA = 60).
|
||||||
|
- Dev log levels switch from `com.sedmelluq.discord` / `dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
|
||||||
|
|
||||||
|
**`MusicManager`**
|
||||||
|
- Holds `LavalinkClient client` and resolves `Link link = client.getOrCreateLink(guildId)`.
|
||||||
|
- `connect(AudioChannel)` → `jda.getDirectAudioController().connect(channel)` + `setSelfDeafened`; `disconnect()` → `getDirectAudioController().disconnect(guild)` and `link.destroyPlayer()`.
|
||||||
|
- `isConnected()` / `isNotConnectedTo(...)` read `guild.getSelfMember().getVoiceState().getChannel()` — **`Guild#getAudioManager()` must not be used** with Lavalink.
|
||||||
|
- `play(...)` → `link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))`.
|
||||||
|
- `getCurrentlyPlaying()` returns the cached `Track` from the last `TrackStartEvent` (the REST player state is not synchronously readable).
|
||||||
|
- `setVolume` / `stopTrack` → `link.getPlayer().flatMap(p -> p.setVolume(v).setEncodedTrack(null))` subscribed internally.
|
||||||
|
|
||||||
|
**`TrackScheduler`**
|
||||||
|
- No longer extends `AudioEventAdapter`; exposes `onTrackStart(Track)`, `onTrackEnd(Track, AudioTrackEndReason)`, `onTrackException`, `onTrackStuck(Track, long)` called by the dispatcher.
|
||||||
|
- The existing `endReason` branching maps 1:1 onto Lavalink's `AudioTrackEndReason` (`FINISHED`, `LOAD_FAILED`, `STOPPED`, `REPLACED`, `CLEANUP`).
|
||||||
|
|
||||||
|
**`QueueManager`**
|
||||||
|
- `player.getPlayingTrack() == null` becomes a `volatile Track currentTrack` maintained by scheduler events (avoids a blocking REST call inside the `synchronized (queue)` blocks).
|
||||||
|
- `startTrack(track, noInterrupt)` becomes `link.createOrUpdatePlayer().setTrack(track).setNoReplace(...)`; `makeClone()` calls are dropped since `Track` is immutable.
|
||||||
|
|
||||||
|
**`TrackLoadingHandler`** — implements lavalink-client's `AbstractAudioLoadResultHandler`: `ontrackLoaded` / `onPlaylistLoaded` / `onSearchResultLoaded` / `noMatches` / `loadFailed(LoadFailed)`. `onSearchResultLoaded` takes the first result, matching today's `ytsearch:` behaviour.
|
||||||
|
|
||||||
|
**`StuckTrackRecoveryHandler`** — re-queues the same immutable `Track` and restores position via `player.setPosition(lastPosition)`; the second consecutive strike still skips.
|
||||||
|
|
||||||
|
### Data Models / Contracts
|
||||||
|
```java
|
||||||
|
// MusicManager (synchronous facade, unchanged call sites)
|
||||||
|
public synchronized void play(String query, CommandContext ctx, boolean silent);
|
||||||
|
public synchronized void stopTrack();
|
||||||
|
public synchronized void setVolume(int volume);
|
||||||
|
public synchronized Track getCurrentlyPlaying();
|
||||||
|
|
||||||
|
// TrackScheduler (invoked by the dispatcher)
|
||||||
|
void onTrackStart(Track track);
|
||||||
|
void onTrackEnd(Track track, AudioTrackEndReason endReason);
|
||||||
|
void onTrackStuck(Track track, long thresholdMs);
|
||||||
|
|
||||||
|
// TrackInfo
|
||||||
|
public record TrackInfo(Track track, Member requester) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
New settings (read via `SettingsProvider`, documented in `bot.properties.example`):
|
||||||
|
`NETNEUTRALITY_LAVALINK_URI` (default `ws://lavalink:2333`), `NETNEUTRALITY_LAVALINK_PASSWORD`, `NETNEUTRALITY_LAVALINK_NAME`.
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|---|---|
|
||||||
|
| `pom.xml` | swap lavaplayer/youtube-v2/jdave → `lavalink-client` |
|
||||||
|
| `docker-compose.yml` | add `lavalink` service |
|
||||||
|
| `lavalink/application.yml` | **new** — node + youtube-source plugin + yt-cipher config |
|
||||||
|
| `bot.properties.example` | new Lavalink settings |
|
||||||
|
| `music/MusicServiceImpl.java` | client lifecycle + event dispatcher |
|
||||||
|
| `music/MusicManager.java` | `Link`/`DirectAudioController` based |
|
||||||
|
| `music/TrackScheduler.java` | plain event sink |
|
||||||
|
| `music/QueueManager.java`, `TrackInfo.java`, `TrackLoadingHandler.java`, `StuckTrackRecoveryHandler.java` | `Track` type + REST player calls |
|
||||||
|
| `music/AudioPlayerSendHandler.java` | **deleted** |
|
||||||
|
| `music/SourceFinder.java` | Udio → unsupported |
|
||||||
|
| `App.java` | voice interceptor, drop DAVE wiring, log levels |
|
||||||
|
| `shared/util/MessageFunction.java`, `tasks/music/LoadPlaylistTask.java`, `tasks/music/LoadTrackFromPlaylistTask.java` | `Track` type |
|
||||||
|
| `src/test/.../music/*` | updated fixtures |
|
||||||
|
| `docs/music.md`, `docs/architecture.md`, `docs/README.md` | rewritten sections |
|
||||||
|
|
||||||
|
### Architecture Diagram
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
JDA[JDA gateway] -->|voice state| VIL[JDAVoiceUpdateListener]
|
||||||
|
VIL --> C[LavalinkClient]
|
||||||
|
C -->|REST and WS| N[Lavalink node]
|
||||||
|
N --> V[Discord voice]
|
||||||
|
CMD[music commands] --> MM[MusicManager]
|
||||||
|
MM -->|Link ops| C
|
||||||
|
MM --> QM[QueueManager]
|
||||||
|
C -->|event flux| DISP[MusicServiceImpl dispatcher]
|
||||||
|
DISP --> TS[TrackScheduler]
|
||||||
|
TS --> QM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
- **Voice API confusion**: any remaining `guild.getAudioManager()` call silently breaks playback; all call sites (`MusicManager`, `MusicUtils`, `BotState`) must be swept.
|
||||||
|
- **Blocking on reactor**: subscribing inside `synchronized` blocks in `QueueManager` risks deadlock — hence the cached `currentTrack` instead of a REST read.
|
||||||
|
- **Node availability**: `MusicServiceImpl.start()` must not throw when the node is down, or `App.startup()` exits with code 1.
|
||||||
|
- **Feature regressions**: Udio support pauses; direct-MP3 and SoundCloud must be re-verified against the node's enabled sources.
|
||||||
|
- **DAVE**: removing JDave requires Lavalink ≥ 4.2.0 — the compose image must be pinned accordingly.
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
- `./mvnw -q verify` must compile and pass Checkstyle plus the updated unit tests.
|
||||||
|
- Unit tests keep using mocks: `QueueManagerTest` and `TrackSchedulerTest` mock the Lavalink `Link`/`LavalinkPlayer` and assert on the interactions instead of on `AudioPlayer`.
|
||||||
|
- A grep sweep confirms no `com.sedmelluq` / `dev.lavalink.youtube` import survives outside `music/source/**`.
|
||||||
|
- Manual smoke run with `docker compose up` to confirm the bot reaches the node and plays a YouTube URL.
|
||||||
|
|
||||||
|
### Key Scenarios
|
||||||
|
- Enqueue on an empty queue starts playback immediately; enqueue while playing appends.
|
||||||
|
- `TrackEndEvent(FINISHED)` advances to the next track; an empty queue triggers `onQueueEmpty()` → disconnect.
|
||||||
|
- `skip(n)`, `shuffle`, `clearQueue`, `removeTracksFromUser` and `removeFromQueue(index)` return the same `CommandExecutionResult` messages as before.
|
||||||
|
- `ytsearch:` queries resolve through `onSearchResultLoaded` and queue the first hit.
|
||||||
|
- Volume from `GSettings.MUSIC_VOLUME` is applied on connect, with the 50 fallback on a malformed value.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- Lavalink node unreachable at startup → bot boots, music commands reply with an error.
|
||||||
|
- Node disconnects mid-playback → `WebSocketClosedEvent` is logged and the guild state is cleaned up.
|
||||||
|
- Stuck track: first event re-queues and seeks, second consecutive event skips.
|
||||||
|
- Udio URL → explicit "temporarily unsupported" reply.
|
||||||
|
- Duplicate track enqueue is still rejected by `TrackInfo` equality.
|
||||||
|
|
||||||
|
### Test Changes
|
||||||
|
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest`, `VoteSkipServiceTest` to the Lavalink `Track` type and mocked players.
|
||||||
|
- Keep `UdioAudioSourceManagerTest` as-is while the source classes remain in the repo for the future plugin port.
|
||||||
|
|
||||||
|
# Delivery Steps
|
||||||
|
|
||||||
|
### ✓ Step 1: Swap Maven dependencies and stand up a Lavalink node
|
||||||
|
The project builds against `lavalink-client` and `docker compose up` starts a reachable Lavalink v4 node.
|
||||||
|
|
||||||
|
- Replace `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2` and the four `club.minnced:jdave-*` entries in `pom.xml` with `dev.arbjerg:lavalink-client` (Gradle docs snippet translated to Maven, using the existing `arbjergDev-releases` repository).
|
||||||
|
- Add a `lavalink` service to `docker-compose.yml` on the `netneutrality` network, pinned to an image with DAVE support (≥ 4.2.0), with the bot depending on it.
|
||||||
|
- Create `lavalink/application.yml` mounted into the node: server port/password, the youtube-source plugin, and the remote cipher pointed at the existing `ejs-api` container plus poToken/visitorData.
|
||||||
|
- Add `NETNEUTRALITY_LAVALINK_URI`, `_PASSWORD` and `_NAME` to `bot.properties.example`.
|
||||||
|
|
||||||
|
### ✓ Step 2: Wire LavalinkClient into the bootstrap and JDA voice handling
|
||||||
|
The bot connects to the node on startup and routes Discord voice through Lavalink instead of JDA's audio manager.
|
||||||
|
|
||||||
|
- Rework `MusicServiceImpl` to own a `LavalinkClient`, registering the configured node in `start()` without throwing when it is unreachable.
|
||||||
|
- Dispose the client and its subscriptions in `stop()`, keeping the priority-40 `Lifecycle` contract used by `App.buildContext()`.
|
||||||
|
- Add `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` in `App.initJDA()` and remove the `AudioModuleConfig`/`JDaveSessionFactory` wiring.
|
||||||
|
- Update the dev-mode log levels in `App.main` from `com.sedmelluq.discord`/`dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
|
||||||
|
- Replace `getPlayerManager()` with `getClient()` and fix `BotState`'s usage.
|
||||||
|
|
||||||
|
### ✓ Step 3: Rebuild MusicManager on Links and the DirectAudioController
|
||||||
|
Per-guild connect, disconnect and player control go through Lavalink while command signatures stay unchanged.
|
||||||
|
|
||||||
|
- Replace the `AudioPlayer` field in `MusicManager` with a `Link` obtained from `client.getOrCreateLink(guildId)`.
|
||||||
|
- Implement `connect`/`disconnect` via `jda.getDirectAudioController()` and `link.destroyPlayer()`, deleting the `guild.getAudioManager()` calls and the `AudioPlayerSendHandler` class.
|
||||||
|
- Base `isConnected()` and `isNotConnectedTo(...)` on `getSelfMember().getVoiceState().getChannel()`, and update `MusicUtils.connect`/`checkIfConnected` accordingly.
|
||||||
|
- Keep the synchronous facade: `stopTrack`, `setVolume` and `play` subscribe to the returned `Mono`s internally and report failures through `LogHandler`.
|
||||||
|
- Apply the `GSettings.MUSIC_VOLUME` base volume on connect with the existing default-50 fallback.
|
||||||
|
|
||||||
|
### ✓ Step 4: Migrate queue, track loading and message rendering to the Lavalink Track type
|
||||||
|
Tracks are represented by `dev.arbjerg.lavalink.protocol.v4.Track` end to end and loading uses Lavalink's REST resolver.
|
||||||
|
|
||||||
|
- Change `TrackInfo` to wrap `Track` + `Member`, preserving equality-based duplicate rejection.
|
||||||
|
- Update `QueueManager` to drive playback with `createOrUpdatePlayer().setTrack(...)`/`setNoReplace(...)`, dropping `makeClone()` and replacing `player.getPlayingTrack()` checks with a scheduler-maintained `volatile Track currentTrack`.
|
||||||
|
- Reimplement `TrackLoadingHandler` on `AbstractAudioLoadResultHandler`, mapping `onSearchResultLoaded` to the first hit to preserve the `ytsearch:` behaviour.
|
||||||
|
- Update `MessageFunction`, `LoadPlaylistTask` and `LoadTrackFromPlaylistTask` to the new track type.
|
||||||
|
- Make `SourceFinder` return no identifier for Udio URLs and have `MusicManager.play` reply that Udio is temporarily unsupported.
|
||||||
|
|
||||||
|
### ✓ Step 5: Route Lavalink events to TrackScheduler and restore stuck-track recovery
|
||||||
|
Track lifecycle events from the node drive queue advancement, error reporting and stuck recovery exactly as before.
|
||||||
|
|
||||||
|
- Add a central dispatcher in `MusicServiceImpl` subscribing once to `TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent` and `WebSocketClosedEvent`, routing by guild id to the cached `MusicManager`.
|
||||||
|
- Convert `TrackScheduler` from an `AudioEventAdapter` subclass into a plain sink with `onTrackStart`/`onTrackEnd`/`onTrackException`/`onTrackStuck`, keeping the existing `FINISHED`/`LOAD_FAILED`/`STOPPED` branching and `MusicChannelContext` attachment.
|
||||||
|
- Rework `StuckTrackRecoveryHandler` to re-queue the immutable `Track` and seek with `player.setPosition(...)`, retaining the retry-then-skip rule.
|
||||||
|
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest` and `VoteSkipServiceTest` to mock `Link`/`LavalinkPlayer`, and verify `./mvnw verify` passes Checkstyle and the suite.
|
||||||
|
|
||||||
|
### ✓ Step 6: Update documentation and record the Udio plugin follow-up
|
||||||
|
The docs describe the Lavalink-based architecture and the deferred Udio work is explicit.
|
||||||
|
|
||||||
|
- Rewrite the Lavaplayer sections of `docs/music.md` and `docs/architecture.md` to cover the node, the client, the dispatcher and the new settings.
|
||||||
|
- Refresh the music mentions in `docs/README.md`, `docs/threading.md` and `docs/utilities-and-bootstrap.md`.
|
||||||
|
- Document how to run the node locally and how to add server-side plugins via `lavalink/application.yml`.
|
||||||
|
- Add a follow-up note stating that `music/source/UdioAudioSourceManager` and `UdioAudioTrack` remain only as the reference for a future Lavalink server plugin and that Udio playback is disabled until then.
|
||||||
@@ -20,6 +20,10 @@ NETNEUTRALITY_VISITOR_DATA=
|
|||||||
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
|
||||||
NETNEUTRALITY_CIPHER_PASSWORD=
|
NETNEUTRALITY_CIPHER_PASSWORD=
|
||||||
|
|
||||||
|
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
|
||||||
|
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
|
||||||
|
NETNEUTRALITY_LAVALINK_NAME=main
|
||||||
|
|
||||||
NETNEUTRALITY_SENTRY_DSN=
|
NETNEUTRALITY_SENTRY_DSN=
|
||||||
GITLAB_HOST=
|
GITLAB_HOST=
|
||||||
GITLAB_TOKEN=
|
GITLAB_TOKEN=
|
||||||
|
|||||||
+47
-5
@@ -23,14 +23,39 @@ services:
|
|||||||
- netneutrality
|
- netneutrality
|
||||||
depends_on:
|
depends_on:
|
||||||
- netneutrality-db
|
- netneutrality-db
|
||||||
|
- lavalink
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
MYSQL_HOST: netneutrality-db
|
- MYSQL_HOST=${MYSQL_HOST:-netneutrality-db}
|
||||||
MYSQL_PORT: 3306
|
- MYSQL_PORT=${MYSQL_PORT:-3306}
|
||||||
MYSQL_USER: java
|
- MYSQL_USER=${MYSQL_USER:-java}
|
||||||
MYSQL_PASSWORD: password
|
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-password}
|
||||||
MYSQL_DATABASE: netneutrality
|
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
|
||||||
|
- API_PORT=${NETNEUTRALITY_API_PORT:-9000}
|
||||||
|
|
||||||
|
# Lavalink >= 4.2.0 is required: the node handles DAVE end-to-end encryption,
|
||||||
|
# so the bot no longer ships native encoders.
|
||||||
|
lavalink:
|
||||||
|
image: ghcr.io/lavalink-devs/lavalink:4.2
|
||||||
|
container_name: lavalink
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- netneutrality
|
||||||
|
depends_on:
|
||||||
|
- ejs-api
|
||||||
|
- webpo
|
||||||
|
volumes:
|
||||||
|
- ./lavalink/application.yml:/opt/Lavalink/application.yml:ro
|
||||||
|
environment:
|
||||||
|
- SERVER_PASSWORD=${NETNEUTRALITY_LAVALINK_PASSWORD:-youshallnotpass}
|
||||||
|
- NETNEUTRALITY_CIPHER_URL=${NETNEUTRALITY_CIPHER_URL:-http://ejs-api:8001}
|
||||||
|
- NETNEUTRALITY_CIPHER_PASSWORD=${NETNEUTRALITY_CIPHER_PASSWORD:-}
|
||||||
|
- NETNEUTRALITY_OAUTH_TOKEN=${NETNEUTRALITY_OAUTH_TOKEN:-}
|
||||||
|
- NETNEUTRALITY_POT_URL=${NETNEUTRALITY_POT_URL:-http://webpo:8080}
|
||||||
|
- NETNEUTRALITY_PO_TOKEN=${NETNEUTRALITY_PO_TOKEN:-}
|
||||||
|
- NETNEUTRALITY_VISITOR_DATA=${NETNEUTRALITY_VISITOR_DATA:-}
|
||||||
|
|
||||||
ejs-api:
|
ejs-api:
|
||||||
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449
|
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449
|
||||||
container_name: ejs_signature_api
|
container_name: ejs_signature_api
|
||||||
@@ -39,8 +64,25 @@ services:
|
|||||||
- netneutrality
|
- netneutrality
|
||||||
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
|
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
|
||||||
# - API_TOKEN=test
|
# - API_TOKEN=test
|
||||||
|
|
||||||
|
webpo:
|
||||||
|
image: ghcr.io/ashton045/webpo-generator
|
||||||
|
container_name: webpo-generator
|
||||||
|
environment:
|
||||||
|
- HOST=${HOST:-::}
|
||||||
|
- PORT=${PORT:-8080}
|
||||||
|
- WORKERS=${WORKERS:-1}
|
||||||
|
- QUEUE_SIZE=${QUEUE_SIZE:-32}
|
||||||
|
- MAX_PENDING_REQUESTS=${MAX_PENDING_REQUESTS:-256}
|
||||||
|
- REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30000}
|
||||||
|
- CACHE_SIZE=${CACHE_SIZE:-100}
|
||||||
|
- CACHE_TTL=${CACHE_TTL:-300000}
|
||||||
|
- VISITOR_TTL=${VISITOR_TTL:-600000}
|
||||||
|
- API_TOKEN=
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
netneutrality-db: null
|
netneutrality-db: null
|
||||||
|
netneutrality-lavalink-plugins: null
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
netneutrality: null
|
netneutrality: null
|
||||||
+1
-1
@@ -16,7 +16,7 @@ subsystem, how they fit together, and the end-to-end flows that tie them.
|
|||||||
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
|
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
|
||||||
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
|
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
|
||||||
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
|
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
|
||||||
| [music.md](music.md) | Music — Lavaplayer pipeline, queue, custom Udio source |
|
| [music.md](music.md) | Music — Lavalink node & client, queue, event dispatch |
|
||||||
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
|
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
|
||||||
| [threading.md](threading.md) | Threading & scheduling infrastructure |
|
| [threading.md](threading.md) | Threading & scheduling infrastructure |
|
||||||
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
|
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
|
||||||
|
|||||||
+11
-10
@@ -37,7 +37,7 @@ execution paths:
|
|||||||
directly and no context is routed onward. See [events.md](events.md).
|
directly and no context is routed onward. See [events.md](events.md).
|
||||||
|
|
||||||
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
|
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
|
||||||
[music](music.md) (Lavaplayer), the [web API](web-api.md), and the [threading/scheduling](threading.md)
|
[music](music.md) (a remote Lavalink node), the [web API](web-api.md), and the [threading/scheduling](threading.md)
|
||||||
infrastructure.
|
infrastructure.
|
||||||
|
|
||||||
## Technology stack
|
## Technology stack
|
||||||
@@ -45,9 +45,8 @@ infrastructure.
|
|||||||
| Concern | Library | Version |
|
| Concern | Library | Version |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
|
||||||
| Voice / DAVE (E2EE) | `club.minnced:jdave-*` | 0.1.8 |
|
| Audio engine | Lavalink v4 node (`ghcr.io/lavalink-devs/lavalink`) | 4.2 |
|
||||||
| Audio engine | `dev.arbjerg:lavaplayer` | 2.2.6 |
|
| Audio client | `dev.arbjerg:lavalink-client` | 3.4.0 |
|
||||||
| YouTube source | `dev.lavalink.youtube:v2` | 1.18.2 |
|
|
||||||
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
|
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
|
||||||
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
|
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
|
||||||
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
|
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
|
||||||
@@ -77,7 +76,7 @@ component in ascending priority order.
|
|||||||
also registered into the context as `SettingsProvider` for constructor injection).
|
also registered into the context as `SettingsProvider` for constructor injection).
|
||||||
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
|
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
|
||||||
(environment, release = `VERSION`, traces sample rate 1.0).
|
(environment, release = `VERSION`, traces sample rate 1.0).
|
||||||
3. **Log levels** — in `dev`, raise JDA / Lavalink-YouTube / sedmelluq logging to `DEBUG`.
|
3. **Log levels** — in `dev`, raise JDA and Lavalink client logging to `DEBUG`.
|
||||||
4. `startup()`:
|
4. `startup()`:
|
||||||
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
|
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
|
||||||
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
|
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
|
||||||
@@ -92,7 +91,7 @@ component in ascending priority order.
|
|||||||
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
|
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
|
||||||
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
|
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
|
||||||
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
|
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
|
||||||
| 40 | `MusicServiceImpl` (`MusicService`) | Initialise the shared Lavaplayer `AudioPlayerManager`. |
|
| 40 | `MusicServiceImpl` (`MusicService`) | Create the shared `LavalinkClient`, register the node and subscribe to its events. |
|
||||||
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
|
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
|
||||||
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
|
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
|
||||||
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
|
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
|
||||||
@@ -106,8 +105,10 @@ context (falling back to a no-arg constructor when none match).
|
|||||||
### Gateway intents
|
### Gateway intents
|
||||||
|
|
||||||
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
|
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
|
||||||
`MESSAGE_CONTENT`. It also installs the DAVE (end-to-end encrypted voice) session factory via
|
`MESSAGE_CONTENT`. It also installs `new JDAVoiceUpdateListener(music.getClient())` as the voice
|
||||||
`AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())`.
|
dispatch interceptor, so voice server/state updates are forwarded to Lavalink instead of JDA's own
|
||||||
|
audio stack. DAVE (end-to-end encrypted voice) is handled by the node, which is why the bot no
|
||||||
|
longer ships native audio encoders.
|
||||||
|
|
||||||
## Shutdown lifecycle
|
## Shutdown lifecycle
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ block the others. Effectively this means:
|
|||||||
2. Post-JDA adapter — no-op.
|
2. Post-JDA adapter — no-op.
|
||||||
3. JDA adapter — `jda.shutdown()`.
|
3. JDA adapter — `jda.shutdown()`.
|
||||||
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
|
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
|
||||||
5. `MusicServiceImpl.stop()` — disconnect players / leave voice channels.
|
5. `MusicServiceImpl.stop()` — dispose the event subscriptions, destroy the players and close the Lavalink client.
|
||||||
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
|
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
|
||||||
7. `ThreadManager.stop()` — shut down the shared executors.
|
7. `ThreadManager.stop()` — shut down the shared executors.
|
||||||
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
|
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
|
||||||
@@ -155,7 +156,7 @@ block the others. Effectively this means:
|
|||||||
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
|
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
|
||||||
| `…hooks` | `EventsHandler` |
|
| `…hooks` | `EventsHandler` |
|
||||||
| `…http` | Internal web API and HTTP client module |
|
| `…http` | Internal web API and HTTP client module |
|
||||||
| `…music` | Music domain (service, manager, queue, sources, tracks) |
|
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
|
||||||
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
|
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
|
||||||
| `…shared.util` | Cross-cutting helpers |
|
| `…shared.util` | Cross-cutting helpers |
|
||||||
| `…exceptions` | Custom exception hierarchy |
|
| `…exceptions` | Custom exception hierarchy |
|
||||||
+115
-38
@@ -1,35 +1,51 @@
|
|||||||
# Music
|
# Music
|
||||||
|
|
||||||
**Package:** `me.bobert.netneutrality.feature.music`
|
**Package:** `me.bobert.netneutrality.music`
|
||||||
**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube
|
**Engine:** a remote [Lavalink](https://lavalink.dev) v4 node driven by
|
||||||
source 1.18.2.
|
[`dev.arbjerg:lavalink-client`](https://github.com/lavalink-devs/Lavalink-Client) 3.4.0.
|
||||||
|
|
||||||
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
|
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
|
||||||
sources, queueing, scheduling, and streaming audio into a Discord voice channel.
|
sources, queueing, scheduling, and streaming audio into a Discord voice channel. Since the
|
||||||
|
migration to Lavalink, **decoding, source resolution and voice sending happen on the node**, not in
|
||||||
|
the bot process. The bot only sends REST/WebSocket commands and reacts to the node's events, which
|
||||||
|
means new sources (YouTube, LavaSrc, …) are added by configuring server-side plugins rather than by
|
||||||
|
changing Java code.
|
||||||
|
|
||||||
## Key classes
|
## Key classes
|
||||||
|
|
||||||
| Class | Responsibility |
|
| Class | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `MusicManager` | Central per-guild coordinator; owns the `AudioPlayer` and `TrackScheduler`. |
|
| `MusicServiceImpl` | Owns the shared `LavalinkClient`, registers the node, and dispatches its event flux. |
|
||||||
|
| `MusicManager` | Central per-guild coordinator; resolves the guild's `Link` and exposes a synchronous facade. |
|
||||||
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
|
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
|
||||||
| `TrackScheduler` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). |
|
| `TrackScheduler` | Event sink for `onTrackStart` / `onTrackEnd` / `onTrackException` / `onTrackStuck`; caches the current track. |
|
||||||
| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. |
|
| `TrackLoadingHandler` | `AbstractAudioLoadResultHandler`; handles track/playlist/search/no-match/failure results. |
|
||||||
| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). |
|
| `TrackInfo` | Value object pairing a Lavalink `Track` with its requester (`Member`). |
|
||||||
| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). |
|
| `SourceFinder` | Identifies the source/type of a query (YouTube, SoundCloud, HTTP, …). |
|
||||||
| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. |
|
| `StuckTrackRecoveryHandler` | Retry-then-skip logic for tracks the node reports as stuck. |
|
||||||
| `source/UdioAudioSourceManager` | Custom Lavaplayer source manager for udio.com. |
|
|
||||||
| `source/UdioAudioTrack` | Udio track; delegates playback to `Mp3AudioTrack`. |
|
|
||||||
|
|
||||||
`MusicManager` keeps a static `Map<String, MusicManager>` of managers keyed by guild id, so each guild
|
`MusicServiceImpl` keeps a `Map<String, MusicManager>` of managers keyed by guild id, so each guild
|
||||||
has its own player, scheduler, and queue.
|
has its own link, scheduler, and queue.
|
||||||
|
|
||||||
## Startup
|
## Startup
|
||||||
|
|
||||||
`MusicManager.initPlayerManager()` (called from
|
`MusicServiceImpl` is a priority-40 [`Lifecycle`](architecture.md#startup-lifecycle) component. Its
|
||||||
[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer
|
constructor creates the `LavalinkClient` (the bot user id is derived from the token) and `start()`
|
||||||
`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom
|
registers the configured node. **A node that is unreachable never prevents the bot from booting**:
|
||||||
[Udio source](#custom-udio-source), and HTTP).
|
the client retries in the background and music commands reply with an error in the meantime.
|
||||||
|
|
||||||
|
Because the client must exist before JDA is built, `App.initJDA()` (priority 60) installs
|
||||||
|
`new JDAVoiceUpdateListener(music.getClient())` as the gateway's voice dispatch interceptor. JDA's
|
||||||
|
`AudioManager` is therefore **not** used for playback; voice connections are opened and closed
|
||||||
|
through `JDA#getDirectAudioController()`.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
| Setting | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `NETNEUTRALITY_LAVALINK_URI` | `ws://lavalink:2333` | WebSocket URI of the node |
|
||||||
|
| `NETNEUTRALITY_LAVALINK_PASSWORD` | — | Node password; music is disabled when missing |
|
||||||
|
| `NETNEUTRALITY_LAVALINK_NAME` | `main` | Node name used in logs |
|
||||||
|
|
||||||
## Audio pipeline (end-to-end)
|
## Audio pipeline (end-to-end)
|
||||||
|
|
||||||
@@ -38,50 +54,111 @@ has its own player, scheduler, and queue.
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
MusicManager.play(query, ctx)
|
MusicManager.play(query, ctx)
|
||||||
│ SourceFinder identifies the source
|
│ SourceFinder identifies the source and builds the identifier
|
||||||
▼
|
▼
|
||||||
audioPlayerManager.loadItemOrdered(guild, identifier, new TrackLoadingHandler(...))
|
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
|
||||||
│
|
│
|
||||||
├─ trackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
|
├─ ontrackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
|
||||||
├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons)
|
├─ onPlaylistLoaded / onSearchResultLoaded ──▶ install a LoadPlaylistTask (choice buttons)
|
||||||
├─ noMatches ──▶ reply "nothing found"
|
├─ noMatches ──▶ reply "nothing found"
|
||||||
└─ loadFailed ──▶ reply / log error
|
└─ loadFailed ──▶ reply / log error
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
QueueManager: if the player is idle, start immediately; else append to the deque
|
QueueManager: if nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
AudioPlayerSendHandler streams PCM/Opus frames to the voice channel
|
Lavalink node decodes and streams the audio to the Discord voice gateway
|
||||||
│
|
│
|
||||||
▼ (track ends)
|
▼ (track ends)
|
||||||
TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
|
node event ──▶ MusicServiceImpl dispatcher ──▶ TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Event dispatch** — `MusicServiceImpl` holds a single subscription per event type
|
||||||
|
(`TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`,
|
||||||
|
`WebSocketClosedEvent`) and routes each event to the owning guild's `TrackScheduler`. All
|
||||||
|
subscriptions are disposed in `stop()`.
|
||||||
|
- **Synchronous facade** — every Lavalink operation returns a reactive `Mono`. `MusicManager`
|
||||||
|
subscribes internally so the 13 music commands keep their existing synchronous signatures;
|
||||||
|
failures are reported through `LogHandler` and the attached `MusicChannelContext`.
|
||||||
|
- **Current track** — the node's player state is only readable via REST, so the last
|
||||||
|
`TrackStartEvent` is cached on the scheduler and used by `getCurrentlyPlaying()` and by the queue
|
||||||
|
to decide whether playback must be started.
|
||||||
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
|
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
|
||||||
now-playing/queue displays and vote-skip.
|
now-playing/queue displays and vote-skip. Duplicate rejection compares the encoded track payload.
|
||||||
- **Playlists / search** — playlist and multi-result loads produce a
|
- **Playlists / search** — playlist and search loads produce a
|
||||||
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
|
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
|
||||||
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
|
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
|
||||||
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
|
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
|
||||||
- **Stuck tracks** — `TrackScheduler.onTrackStuck` attempts recovery (reload/skip).
|
- **Stuck tracks** — the first `TrackStuckEvent` re-queues the track and seeks back to the last
|
||||||
|
known position; a second consecutive event skips to the next track.
|
||||||
- **Output** — playback messages are posted through a
|
- **Output** — playback messages are posted through a
|
||||||
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
|
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
|
||||||
music channel.
|
music channel.
|
||||||
|
|
||||||
## Custom Udio source
|
## Running the node
|
||||||
|
|
||||||
`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist,
|
`docker-compose.yml` defines a `lavalink` service (image `ghcr.io/lavalink-devs/lavalink:4.2`, the
|
||||||
duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the
|
minimum version that handles DAVE end-to-end encrypted voice) on the `netneutrality` network, with
|
||||||
page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the
|
`lavalink/application.yml` mounted read-only at `/opt/Lavalink/application.yml`. The bot depends on
|
||||||
`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's
|
it and the node depends on the existing `ejs-api` (yt-cipher) container.
|
||||||
`Mp3AudioTrack`.
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d lavalink
|
||||||
|
docker compose logs -f lavalink # wait for "Lavalink is ready to accept connections"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a server-side plugin
|
||||||
|
|
||||||
|
Plugins are declared in `lavalink/application.yml` and downloaded by the node on boot into the
|
||||||
|
`netneutrality-lavalink-plugins` volume — no bot rebuild is required:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
- dependency: "dev.lavalink.youtube:youtube-plugin:1.18.2"
|
||||||
|
snapshot: false
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin that is not on the default Lavalink repository can be pulled from any Maven repository by
|
||||||
|
adding `repository:` to the entry. The node currently runs a JitPack build of the SABR-enabled
|
||||||
|
YouTube fork ([`ashton045/youtube-source`](https://jitpack.io/#ashton045/youtube-source), branch
|
||||||
|
`feat/sabr-support`) — no local build of the fork is required, JitPack publishes a ready Lavalink
|
||||||
|
plugin jar:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:17619654b1"
|
||||||
|
repository: "https://jitpack.io"
|
||||||
|
snapshot: false
|
||||||
|
```
|
||||||
|
|
||||||
|
The node logs `Failed to check for updates for youtube-plugin: Not Found` on boot; this is harmless,
|
||||||
|
JitPack publishes no `maven-metadata.xml` for commit-pinned versions.
|
||||||
|
|
||||||
|
Plugin-specific configuration goes under the top-level `plugins:` key. The YouTube plugin is
|
||||||
|
configured there with the remote cipher URL, poToken and visitorData, which used to live in
|
||||||
|
`MusicServiceImpl.start()`.
|
||||||
|
|
||||||
|
## Udio support (deferred)
|
||||||
|
|
||||||
|
Udio playback is **temporarily unavailable**. The previous implementation was a client-side
|
||||||
|
Lavaplayer `AudioSourceManager`, and Lavalink can only resolve sources that live on the server.
|
||||||
|
`SourceFinder` still recognises Udio URLs but returns no identifier, and `MusicManager.play` replies
|
||||||
|
that Udio is temporarily unsupported.
|
||||||
|
|
||||||
|
The original `UdioAudioSourceManager`, `UdioAudioTrack` and their test have been preserved verbatim
|
||||||
|
under [`docs/reference/udio-lavalink-plugin/`](reference/udio-lavalink-plugin) — they are **not
|
||||||
|
compiled** — as the reference for a future Lavalink **server plugin** port. That plugin is out of
|
||||||
|
scope of this migration and is tracked as a follow-up.
|
||||||
|
|
||||||
## Guild lifecycle & cleanup
|
## Guild lifecycle & cleanup
|
||||||
|
|
||||||
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the player has been idle.
|
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the channel is empty or the queue
|
||||||
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the
|
has run out; the connected channel is read from the bot's gateway voice state.
|
||||||
static map.
|
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances.
|
||||||
- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice
|
- On shutdown, `MusicServiceImpl.stop()` disposes the event subscriptions, clears the queues,
|
||||||
|
destroys every player and closes the client
|
||||||
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
|
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
|
||||||
|
|
||||||
## Related commands
|
## Related commands
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Udio source — follow-up reference
|
||||||
|
|
||||||
|
These files are **not part of the build**. They are the original client-side Lavaplayer
|
||||||
|
implementation of the udio.com source, kept verbatim as the reference for a future port.
|
||||||
|
|
||||||
|
| File | Was |
|
||||||
|
|---|---|
|
||||||
|
| `UdioAudioSourceManager.java` | `me.bobert.netneutrality.music.source.UdioAudioSourceManager` |
|
||||||
|
| `UdioAudioTrack.java` | `me.bobert.netneutrality.music.source.UdioAudioTrack` |
|
||||||
|
| `UdioAudioSourceManagerTest.java` | its unit test |
|
||||||
|
|
||||||
|
## Why they were moved
|
||||||
|
|
||||||
|
The bot no longer decodes audio itself: after the [migration to Lavalink](../../music.md), source
|
||||||
|
resolution happens on the node. A Lavaplayer `AudioSourceManager` living in the bot process can
|
||||||
|
never be used by Lavalink, and these classes cannot even compile now that the `dev.arbjerg:lavaplayer`
|
||||||
|
dependency has been dropped from `pom.xml`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Udio playback is disabled.** `SourceFinder` still classifies Udio URLs, but
|
||||||
|
`SourceFinder.getIdentifier` returns no identifier for them and `MusicManager.play` replies that
|
||||||
|
Udio is temporarily unsupported.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
Port `UdioAudioSourceManager` / `UdioAudioTrack` into a standalone **Lavalink server plugin**
|
||||||
|
(its own build and published artifact), then enable it by adding the dependency to
|
||||||
|
`lavalink/application.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
- dependency: "<group>:udio-plugin:<version>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the node resolves `https://www.udio.com/songs/...` identifiers, the only bot-side change is to
|
||||||
|
restore `Udio` in the `SourceFinder.getIdentifier` switch and drop the "temporarily unsupported"
|
||||||
|
reply in `MusicManager.play`.
|
||||||
|
|
||||||
|
The scraping logic itself still applies: the manager extracts metadata and a direct playback URL
|
||||||
|
from the Next.js payload (`self.__next_f.push(...)`) of the track page, and the URL must be
|
||||||
|
re-fetched right before playback because it expires.
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-1
@@ -37,7 +37,8 @@ startup by [`TaskManager.init()`](tasks.md#taskmanager)):
|
|||||||
- `cancelAll()` — cancels every scheduled task; called first in
|
- `cancelAll()` — cancels every scheduled task; called first in
|
||||||
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
|
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
|
||||||
|
|
||||||
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect`, and
|
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect` (which reads the
|
||||||
|
bot's gateway voice state rather than JDA's audio manager, see [music.md](music.md)), and
|
||||||
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
|
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
|
||||||
|
|
||||||
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
|
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ An example configuration file is `bot.properties.example`. Key configuration val
|
|||||||
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
|
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
|
||||||
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
|
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
|
||||||
| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). |
|
| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). |
|
||||||
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube playback auth/cipher settings for the [music](music.md) source. |
|
| `NETNEUTRALITY_LAVALINK_URI` / `_PASSWORD` / `_NAME` | Connection settings for the [Lavalink node](music.md#settings). |
|
||||||
|
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. |
|
||||||
|
|
||||||
## Utility helpers
|
## Utility helpers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
server:
|
||||||
|
port: 2333
|
||||||
|
address: 0.0.0.0
|
||||||
|
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
# Replacement for the in-process dev.lavalink.youtube:v2 source manager.
|
||||||
|
# Custom SABR-enabled fork (ashton045/youtube-source, feat/sabr-support) built by JitPack.
|
||||||
|
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:33d50abd5f"
|
||||||
|
repository: "https://jitpack.io"
|
||||||
|
snapshot: false
|
||||||
|
server:
|
||||||
|
password: "${SERVER_PASSWORD:youshallnotpass}"
|
||||||
|
sources:
|
||||||
|
# Handled by the youtube-plugin below, must stay disabled here.
|
||||||
|
youtube: false
|
||||||
|
soundcloud: true
|
||||||
|
bandcamp: false
|
||||||
|
twitch: false
|
||||||
|
vimeo: false
|
||||||
|
nico: false
|
||||||
|
http: true
|
||||||
|
local: false
|
||||||
|
filters:
|
||||||
|
volume: true
|
||||||
|
bufferDurationMs: 400
|
||||||
|
frameBufferDurationMs: 5000
|
||||||
|
opusEncodingQuality: 10
|
||||||
|
resamplingQuality: LOW
|
||||||
|
trackStuckThresholdMs: 10000
|
||||||
|
useSeekGhosting: true
|
||||||
|
youtubePlaylistLoadLimit: 6
|
||||||
|
playerUpdateInterval: 5
|
||||||
|
youtubeSearchEnabled: true
|
||||||
|
soundcloudSearchEnabled: true
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
youtube:
|
||||||
|
enabled: true
|
||||||
|
allowSearch: true
|
||||||
|
allowDirectVideoIds: true
|
||||||
|
allowDirectPlaylistIds: true
|
||||||
|
clients:
|
||||||
|
- TV
|
||||||
|
- MWEB
|
||||||
|
- WEB
|
||||||
|
- MUSIC
|
||||||
|
- WEBEMBEDDED
|
||||||
|
- ANDROID_VR
|
||||||
|
- TVHTML5_SIMPLY
|
||||||
|
oauth:
|
||||||
|
enabled: true
|
||||||
|
refreshToken: "${NETNEUTRALITY_OAUTH_TOKEN}"
|
||||||
|
remoteCipher:
|
||||||
|
url: "${NETNEUTRALITY_CIPHER_URL:http://ejs-api:8001}"
|
||||||
|
password: "${NETNEUTRALITY_CIPHER_PASSWORD:}"
|
||||||
|
remotePot:
|
||||||
|
url: "${NETNEUTRALITY_POT_URL:http://webpo:8080}"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
lavalink: DEBUG
|
||||||
|
# Set to INFO to see the OAuth flow instructions (device code / verification URL) in the terminal.
|
||||||
|
dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO
|
||||||
@@ -146,35 +146,11 @@
|
|||||||
<artifactId>JDA</artifactId>
|
<artifactId>JDA</artifactId>
|
||||||
<version>6.4.1</version>
|
<version>6.4.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Lavalink v4 client; audio decoding happens on the remote node. -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.arbjerg</groupId>
|
<groupId>dev.arbjerg</groupId>
|
||||||
<artifactId>lavaplayer</artifactId>
|
<artifactId>lavalink-client</artifactId>
|
||||||
<version>2.2.7</version>
|
<version>3.4.0</version>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.lavalink.youtube</groupId>
|
|
||||||
<artifactId>v2</artifactId>
|
|
||||||
<version>1.18.2</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-api</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-native-linux-x86-64</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-native-win-x86-64</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>club.minnced</groupId>
|
|
||||||
<artifactId>jdave-native-darwin</artifactId>
|
|
||||||
<version>0.1.8</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ package me.bobert.netneutrality;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
|
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
|
||||||
import io.sentry.Sentry;
|
import io.sentry.Sentry;
|
||||||
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
||||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||||
@@ -33,7 +33,6 @@ import me.bobert.netneutrality.utils.Settings;
|
|||||||
import me.bobert.netneutrality.http.WebModule;
|
import me.bobert.netneutrality.http.WebModule;
|
||||||
import net.dv8tion.jda.api.JDA;
|
import net.dv8tion.jda.api.JDA;
|
||||||
import net.dv8tion.jda.api.JDABuilder;
|
import net.dv8tion.jda.api.JDABuilder;
|
||||||
import net.dv8tion.jda.api.audio.AudioModuleConfig;
|
|
||||||
import net.dv8tion.jda.api.entities.Activity;
|
import net.dv8tion.jda.api.entities.Activity;
|
||||||
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
|
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||||
@@ -47,6 +46,7 @@ public class App {
|
|||||||
private static App instance;
|
private static App instance;
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private JDA jda;
|
private JDA jda;
|
||||||
|
private MusicService music;
|
||||||
private final ApplicationContext context = new ApplicationContext();
|
private final ApplicationContext context = new ApplicationContext();
|
||||||
|
|
||||||
static void main() {
|
static void main() {
|
||||||
@@ -71,8 +71,7 @@ public class App {
|
|||||||
|
|
||||||
if ("dev".equals(env)) {
|
if ("dev".equals(env)) {
|
||||||
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
|
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
|
||||||
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
|
Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG);
|
||||||
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instance = new App(settings);
|
instance = new App(settings);
|
||||||
@@ -137,7 +136,7 @@ public class App {
|
|||||||
JpaUtils.bindGuildServiceLookup(guildServices::get);
|
JpaUtils.bindGuildServiceLookup(guildServices::get);
|
||||||
|
|
||||||
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
|
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
|
||||||
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks));
|
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
|
||||||
BotState.bind(guildServices, music);
|
BotState.bind(guildServices, music);
|
||||||
|
|
||||||
// JDA gateway; disconnected on shutdown.
|
// JDA gateway; disconnected on shutdown.
|
||||||
@@ -200,8 +199,9 @@ public class App {
|
|||||||
GatewayIntent.GUILD_MESSAGE_REACTIONS,
|
GatewayIntent.GUILD_MESSAGE_REACTIONS,
|
||||||
GatewayIntent.GUILD_VOICE_STATES,
|
GatewayIntent.GUILD_VOICE_STATES,
|
||||||
GatewayIntent.MESSAGE_CONTENT)
|
GatewayIntent.MESSAGE_CONTENT)
|
||||||
.setAudioModuleConfig(
|
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
|
||||||
new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
|
// DAVE encryption is handled by the node.
|
||||||
|
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
|
||||||
.setActivity(Activity.watching("me load..."))
|
.setActivity(Activity.watching("me load..."))
|
||||||
.build();
|
.build();
|
||||||
jda.awaitReady();
|
jda.awaitReady();
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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.playback.AudioFrame;
|
|
||||||
import net.dv8tion.jda.api.audio.AudioSendHandler;
|
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a wrapper around AudioPlayer which makes it behave as an AudioSendHandler for JDA. As JDA calls canProvide
|
|
||||||
* before every call to provide20MsAudio(), we pull the frame in canProvide() and use the frame we already pulled in
|
|
||||||
* provide20MsAudio().
|
|
||||||
*/
|
|
||||||
public class AudioPlayerSendHandler implements AudioSendHandler {
|
|
||||||
private final AudioPlayer audioPlayer;
|
|
||||||
private AudioFrame lastFrame;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param audioPlayer Audio player to wrap.
|
|
||||||
*/
|
|
||||||
public AudioPlayerSendHandler(AudioPlayer audioPlayer) {
|
|
||||||
this.audioPlayer = audioPlayer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean canProvide() {
|
|
||||||
lastFrame = audioPlayer.provide();
|
|
||||||
return lastFrame != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ByteBuffer provide20MsAudio() {
|
|
||||||
return ByteBuffer.wrap(lastFrame.getData());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOpus() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,51 +6,57 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import dev.arbjerg.lavalink.client.LavalinkClient;
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
import dev.arbjerg.lavalink.client.Link;
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
|
import dev.arbjerg.lavalink.client.player.LavalinkPlayer;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import dev.arbjerg.lavalink.client.player.PlayerUpdateBuilder;
|
||||||
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
import me.bobert.netneutrality.db.GSettings;
|
import me.bobert.netneutrality.db.GSettings;
|
||||||
import me.bobert.netneutrality.db.service.GuildService;
|
import me.bobert.netneutrality.db.service.GuildService;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
|
import net.dv8tion.jda.api.entities.GuildVoiceState;
|
||||||
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||||
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.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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>Playback is delegated to the Lavalink node through the {@link Link} of this guild; the
|
||||||
|
* shared {@link LavalinkClient} and the manager cache live on {@link MusicServiceImpl}.
|
||||||
* Obtain instances via {@link MusicService#get(Guild)}.
|
* Obtain instances via {@link MusicService#get(Guild)}.
|
||||||
|
*
|
||||||
|
* <p>All Lavalink operations return reactive {@code Mono}s. This class subscribes to them
|
||||||
|
* internally so that the command layer keeps its synchronous call style; failures are reported
|
||||||
|
* through {@link LogHandler}.
|
||||||
*/
|
*/
|
||||||
public class MusicManager implements MusicManagerCallback {
|
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;
|
static final int DEFAULT_VOLUME = 50;
|
||||||
private final Guild guild;
|
private final Guild guild;
|
||||||
private final AudioPlayerManager playerManager;
|
private final LavalinkClient client;
|
||||||
private final AudioPlayer player;
|
|
||||||
private final TrackScheduler scheduler;
|
private final TrackScheduler scheduler;
|
||||||
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;
|
private final VoteSkipService voteSkipService;
|
||||||
|
|
||||||
MusicManager(Guild guild, AudioPlayerManager playerManager, GuildService guildService, TaskManager taskManager) {
|
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager) {
|
||||||
this.guild = guild;
|
this.guild = guild;
|
||||||
this.playerManager = playerManager;
|
this.client = client;
|
||||||
this.player = playerManager.createPlayer();
|
|
||||||
this.scheduler = new TrackScheduler(this);
|
this.scheduler = new TrackScheduler(this);
|
||||||
this.guildService = guildService;
|
this.guildService = guildService;
|
||||||
this.taskManager = taskManager;
|
this.taskManager = taskManager;
|
||||||
this.voteSkipService = new VoteSkipService();
|
this.voteSkipService = new VoteSkipService();
|
||||||
this.baseVolume = parseBaseVolume(guildService);
|
this.baseVolume = parseBaseVolume(guildService);
|
||||||
|
|
||||||
guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player));
|
|
||||||
player.addListener(scheduler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public GuildService getGuildService() {
|
public GuildService getGuildService() {
|
||||||
@@ -69,50 +75,101 @@ public class MusicManager implements MusicManagerCallback {
|
|||||||
return scheduler.getQueueManager();
|
return scheduler.getQueueManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
public AudioPlayer getPlayer() {
|
|
||||||
return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guild getGuild() {
|
public Guild getGuild() {
|
||||||
return guild;
|
return guild;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the {@link Link} binding this guild to a Lavalink node, or {@code null} when no
|
||||||
|
* node is currently available.
|
||||||
|
*
|
||||||
|
* @return the link or {@code null}
|
||||||
|
*/
|
||||||
|
public Link getLink() {
|
||||||
|
try {
|
||||||
|
return client.getOrCreateLink(guild.getIdLong());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Guild {}: no Lavalink node available", guild.getId(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the voice channel the bot is currently connected to in this guild.
|
||||||
|
*
|
||||||
|
* <p>With Lavalink the voice connection is owned by the node, so JDA's audio manager cannot
|
||||||
|
* be consulted; the gateway voice state of the self member is the source of truth.
|
||||||
|
*
|
||||||
|
* @return the connected channel or {@code null}
|
||||||
|
*/
|
||||||
|
public AudioChannel getConnectedChannel() {
|
||||||
|
GuildVoiceState state = guild.getSelfMember().getVoiceState();
|
||||||
|
return state == null ? null : state.getChannel();
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized boolean isNotConnectedTo(AudioChannel vChannel) {
|
public synchronized boolean isNotConnectedTo(AudioChannel vChannel) {
|
||||||
AudioChannel current = guild.getAudioManager().getConnectedChannel();
|
AudioChannel current = getConnectedChannel();
|
||||||
return vChannel == null || current == null || !vChannel.getId().equals(current.getId());
|
return vChannel == null || current == null || !vChannel.getId().equals(current.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void connect(AudioChannel audioChannel) {
|
public synchronized void connect(AudioChannel audioChannel) {
|
||||||
if (audioChannel != null) {
|
if (audioChannel == null) {
|
||||||
logger.debug("Guild {}: connecting to voice channel {}", audioChannel.getGuild().getId(), audioChannel.getId());
|
|
||||||
guild.getAudioManager().openAudioConnection(audioChannel);
|
|
||||||
guild.getAudioManager().setSelfDeafened(true);
|
|
||||||
player.setVolume(baseVolume);
|
|
||||||
} else {
|
|
||||||
throw new IllegalArgumentException("audioChannel must not be null");
|
throw new IllegalArgumentException("audioChannel must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug("Guild {}: connecting to voice channel {}", audioChannel.getGuild().getId(), audioChannel.getId());
|
||||||
|
// Only the self-deaf flag is read from the audio manager; the connection itself is opened
|
||||||
|
// through the direct audio controller so that Lavalink handles the voice websocket.
|
||||||
|
guild.getAudioManager().setSelfDeafened(true);
|
||||||
|
guild.getJDA().getDirectAudioController().connect(audioChannel);
|
||||||
|
|
||||||
|
setVolume(baseVolume);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void disconnect() {
|
public synchronized void disconnect() {
|
||||||
if (guild.getAudioManager().getConnectedChannel() != null) {
|
if (getConnectedChannel() != null) {
|
||||||
logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), guild.getAudioManager().getConnectedChannel().getId());
|
logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), getConnectedChannel().getId());
|
||||||
player.stopTrack();
|
stopTrack();
|
||||||
guild.getAudioManager().closeAudioConnection();
|
guild.getJDA().getDirectAudioController().disconnect(guild);
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduler.detachContext();
|
scheduler.detachContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized boolean isConnected() {
|
/**
|
||||||
return guild.getAudioManager().getConnectedChannel() != null;
|
* Destroys the Lavalink player of this guild, used on shutdown.
|
||||||
|
*/
|
||||||
|
synchronized void destroy() {
|
||||||
|
Link link = getLink();
|
||||||
|
if (link != null) {
|
||||||
|
link.destroy().subscribe(
|
||||||
|
ignored -> { },
|
||||||
|
error -> logger.error("Guild {}: failed to destroy player", guild.getId(), error)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized AudioTrack getCurrentlyPlaying() {
|
public synchronized boolean isConnected() {
|
||||||
return player.getPlayingTrack();
|
return getConnectedChannel() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Track getCurrentlyPlaying() {
|
||||||
|
return scheduler.getCurrentTrack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the playback position of the currently playing track, in milliseconds.
|
||||||
|
*
|
||||||
|
* @return the position, or {@code 0} when nothing is playing
|
||||||
|
*/
|
||||||
|
public long getCurrentPosition() {
|
||||||
|
Link link = getLink();
|
||||||
|
LavalinkPlayer player = link == null ? null : link.getCachedPlayer();
|
||||||
|
return player == null ? 0L : player.getPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized boolean isCurrentlyPlaying() {
|
public synchronized boolean isCurrentlyPlaying() {
|
||||||
return player.getPlayingTrack() != null;
|
return scheduler.getCurrentTrack() != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void play(String query, CommandContext context) {
|
public synchronized void play(String query, CommandContext context) {
|
||||||
@@ -122,21 +179,41 @@ public class MusicManager implements MusicManagerCallback {
|
|||||||
public synchronized void play(String query, CommandContext context, boolean silent) {
|
public synchronized void play(String query, CommandContext context, boolean silent) {
|
||||||
logger.debug("Guild {}: request to play '{}' by user {}", guild.getId(), query, context.getGuildUser().getId());
|
logger.debug("Guild {}: request to play '{}' by user {}", guild.getId(), query, context.getGuildUser().getId());
|
||||||
|
|
||||||
if (MusicUtils.canConnect(context, this)) {
|
if (!MusicUtils.canConnect(context, this)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
SourceFinder.AudioSourceType type = SourceFinder.getSource(query);
|
SourceFinder.AudioSourceType type = SourceFinder.getSource(query);
|
||||||
|
if (type == SourceFinder.AudioSourceType.Udio) {
|
||||||
|
context.reply("Udio playback is temporarily unsupported since the migration to Lavalink");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String identifier = SourceFinder.getIdentifier(query);
|
String identifier = SourceFinder.getIdentifier(query);
|
||||||
if (identifier == null) {
|
if (identifier == null) {
|
||||||
context.reply("No source found to play the given url");
|
context.reply("No source found to play the given url");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackLoadingHandler loadingHandler = new TrackLoadingHandler(context, this, identifier, type, silent);
|
Link link = getLink();
|
||||||
playerManager.loadItemOrdered(this, identifier, loadingHandler);
|
if (link == null) {
|
||||||
|
context.reply("The audio node is currently unavailable, please try again later");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
link.loadItem(identifier).subscribe(
|
||||||
|
new TrackLoadingHandler(context, this, identifier, type, silent),
|
||||||
|
error -> {
|
||||||
|
LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error));
|
||||||
|
if (!silent) {
|
||||||
|
context.reply("Failed to load the requested track");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void stopTrack() {
|
public synchronized void stopTrack() {
|
||||||
player.stopTrack();
|
updatePlayer("stop track", builder -> builder.stopTrack());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized CommandExecutionResult resume() {
|
public synchronized CommandExecutionResult resume() {
|
||||||
@@ -144,12 +221,11 @@ public class MusicManager implements MusicManagerCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
|
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
|
||||||
AudioChannel audioChannel = guild.getAudioManager().getConnectedChannel();
|
return voteSkipService.voteSkip(context, getQueueManager(), getConnectedChannel(), isCurrentlyPlaying());
|
||||||
return voteSkipService.voteSkip(context, getQueueManager(), audioChannel, isCurrentlyPlaying());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setVolume(int volume) {
|
public void setVolume(int volume) {
|
||||||
player.setVolume(volume);
|
updatePlayer("set volume", builder -> builder.setVolume(volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getQueueSize() {
|
public int getQueueSize() {
|
||||||
@@ -161,6 +237,26 @@ public class MusicManager implements MusicManagerCallback {
|
|||||||
disconnect();
|
disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a player update on the node and subscribes to it, logging any failure.
|
||||||
|
*
|
||||||
|
* @param description what the update does, used for logging
|
||||||
|
* @param mutation the builder mutation to apply
|
||||||
|
*/
|
||||||
|
void updatePlayer(String description, Consumer<PlayerUpdateBuilder> mutation) {
|
||||||
|
Link link = getLink();
|
||||||
|
if (link == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerUpdateBuilder builder = link.createOrUpdatePlayer();
|
||||||
|
mutation.accept(builder);
|
||||||
|
builder.subscribe(
|
||||||
|
ignored -> { },
|
||||||
|
error -> logger.error("Guild {}: failed to {}", guild.getId(), description, error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static int parseBaseVolume(GuildService guildService) {
|
private static int parseBaseVolume(GuildService guildService) {
|
||||||
try {
|
try {
|
||||||
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
|
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import dev.arbjerg.lavalink.client.LavalinkClient;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the shared audio player manager and the per-guild {@link MusicManager} cache.
|
* Owns the shared {@link LavalinkClient} and the per-guild {@link MusicManager} cache.
|
||||||
*
|
*
|
||||||
* <p>As a {@link Lifecycle} component it registers audio sources on {@link #start()} and
|
* <p>As a {@link Lifecycle} component it registers the Lavalink node on {@link #start()} and
|
||||||
* disconnects every player on {@link #stop()}.
|
* destroys every player on {@link #stop()}.
|
||||||
*/
|
*/
|
||||||
public interface MusicService extends Lifecycle {
|
public interface MusicService extends Lifecycle {
|
||||||
|
|
||||||
@@ -32,4 +33,11 @@ public interface MusicService extends Lifecycle {
|
|||||||
* @return the managers
|
* @return the managers
|
||||||
*/
|
*/
|
||||||
Collection<MusicManager> getManagers();
|
Collection<MusicManager> getManagers();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the shared Lavalink client used for voice dispatch interception and player control.
|
||||||
|
*
|
||||||
|
* @return the client
|
||||||
|
*/
|
||||||
|
LavalinkClient getClient();
|
||||||
}
|
}
|
||||||
@@ -6,41 +6,66 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
|
import dev.arbjerg.lavalink.client.Helpers;
|
||||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
|
import dev.arbjerg.lavalink.client.LavalinkClient;
|
||||||
import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager;
|
import dev.arbjerg.lavalink.client.LavalinkNode;
|
||||||
import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager;
|
import dev.arbjerg.lavalink.client.NodeOptions;
|
||||||
import dev.lavalink.youtube.YoutubeAudioSourceManager;
|
import dev.arbjerg.lavalink.client.event.ReadyEvent;
|
||||||
import dev.lavalink.youtube.YoutubeSourceOptions;
|
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
|
||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent;
|
||||||
|
import dev.arbjerg.lavalink.client.event.TrackStartEvent;
|
||||||
|
import dev.arbjerg.lavalink.client.event.TrackStuckEvent;
|
||||||
|
import dev.arbjerg.lavalink.client.event.WebSocketClosedEvent;
|
||||||
|
import dev.arbjerg.lavalink.internal.LavalinkSocket;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
import me.bobert.netneutrality.music.source.UdioAudioSourceManager;
|
import me.bobert.netneutrality.core.thread.ExecutorProvider;
|
||||||
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
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 reactor.core.Disposable;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instance-based {@link MusicService} owning the shared {@link AudioPlayerManager} and the
|
* Instance-based {@link MusicService} owning the shared {@link LavalinkClient} and the
|
||||||
* per-guild {@link MusicManager} cache.
|
* per-guild {@link MusicManager} cache.
|
||||||
|
*
|
||||||
|
* <p>Audio decoding and voice sending happen on the remote Lavalink node; this class only
|
||||||
|
* registers the node and dispatches the node's event flux to the owning guild's
|
||||||
|
* {@link TrackScheduler}.
|
||||||
*/
|
*/
|
||||||
public class MusicServiceImpl implements MusicService {
|
public class MusicServiceImpl implements MusicService {
|
||||||
private static final Logger logger = LogManager.getLogger(MusicServiceImpl.class);
|
private static final Logger logger = LogManager.getLogger(MusicServiceImpl.class);
|
||||||
|
|
||||||
|
private static final String DEFAULT_URI = "ws://lavalink:2333";
|
||||||
|
private static final String DEFAULT_NAME = "main";
|
||||||
|
private static final long RECONNECT_WATCHDOG_SECONDS = 5;
|
||||||
|
|
||||||
private final SettingsProvider settings;
|
private final SettingsProvider settings;
|
||||||
private final GuildServiceProvider guildServices;
|
private final GuildServiceProvider guildServices;
|
||||||
private final TaskManager taskManager;
|
private final TaskManager taskManager;
|
||||||
|
private final ExecutorProvider executors;
|
||||||
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
|
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
|
||||||
private final AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
|
private final List<Disposable> subscriptions = new ArrayList<>();
|
||||||
|
private final LavalinkClient client;
|
||||||
|
private ScheduledFuture<?> reconnectWatchdog;
|
||||||
|
|
||||||
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager) {
|
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
|
||||||
|
ExecutorProvider executors) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.guildServices = guildServices;
|
this.guildServices = guildServices;
|
||||||
this.taskManager = taskManager;
|
this.taskManager = taskManager;
|
||||||
|
this.executors = executors;
|
||||||
|
this.client = new LavalinkClient(Helpers.getUserIdFromToken(settings.get("NETNEUTRALITY_BOT_TOKEN")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -50,41 +75,38 @@ public class MusicServiceImpl implements MusicService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start() {
|
public void start() {
|
||||||
logger.debug("Initializing music player sources..");
|
subscribeToEvents();
|
||||||
|
registerNode();
|
||||||
String cipherUrl = settings.get("NETNEUTRALITY_CIPHER_URL");
|
startReconnectWatchdog();
|
||||||
String cipherPassword = settings.get("NETNEUTRALITY_CIPHER_PASSWORD");
|
|
||||||
YoutubeSourceOptions options = new YoutubeSourceOptions()
|
|
||||||
.setAllowSearch(true)
|
|
||||||
.setAllowDirectVideoIds(true)
|
|
||||||
.setAllowDirectPlaylistIds(true)
|
|
||||||
.setRemoteCipher(cipherUrl, cipherPassword, "");
|
|
||||||
playerManager.registerSourceManager(new YoutubeAudioSourceManager(options, YoutubeAudioSourceManager.DEFAULT_CLIENTS));
|
|
||||||
playerManager.registerSourceManager(SoundCloudAudioSourceManager.createDefault());
|
|
||||||
playerManager.registerSourceManager(new UdioAudioSourceManager());
|
|
||||||
playerManager.registerSourceManager(new HttpAudioSourceManager());
|
|
||||||
|
|
||||||
String po = settings.get("NETNEUTRALITY_PO_TOKEN");
|
|
||||||
String visitor = settings.get("NETNEUTRALITY_VISITOR_DATA");
|
|
||||||
dev.lavalink.youtube.clients.Web.setPoTokenAndVisitorData(po, visitor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
for (MusicManager mm : managers.values()) {
|
if (reconnectWatchdog != null) {
|
||||||
mm.getQueueManager().clearQueue();
|
reconnectWatchdog.cancel(false);
|
||||||
mm.getPlayer().destroy();
|
reconnectWatchdog = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (Disposable subscription : subscriptions) {
|
||||||
|
subscription.dispose();
|
||||||
|
}
|
||||||
|
subscriptions.clear();
|
||||||
|
|
||||||
|
for (MusicManager manager : managers.values()) {
|
||||||
|
manager.getQueueManager().clearQueue();
|
||||||
|
manager.destroy();
|
||||||
|
}
|
||||||
|
managers.clear();
|
||||||
|
|
||||||
|
client.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MusicManager get(Guild guild) {
|
public MusicManager get(Guild guild) {
|
||||||
if (managers.containsKey(guild.getId())) {
|
return managers.computeIfAbsent(
|
||||||
return managers.get(guild.getId());
|
guild.getId(),
|
||||||
}
|
id -> new MusicManager(guild, client, guildServices.get(guild), taskManager)
|
||||||
MusicManager manager = new MusicManager(guild, playerManager, guildServices.get(guild), taskManager);
|
);
|
||||||
managers.put(guild.getId(), manager);
|
|
||||||
return manager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -93,11 +115,106 @@ public class MusicServiceImpl implements MusicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the shared Lavaplayer {@link AudioPlayerManager}.
|
* Returns the shared Lavalink client.
|
||||||
*
|
*
|
||||||
* @return the player manager
|
* @return the client
|
||||||
*/
|
*/
|
||||||
public AudioPlayerManager getPlayerManager() {
|
@Override
|
||||||
return playerManager;
|
public LavalinkClient getClient() {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the configured node. A node that is unreachable at startup is retried by the
|
||||||
|
* client itself, so this never prevents the bot from booting.
|
||||||
|
*/
|
||||||
|
private void registerNode() {
|
||||||
|
String uri = settings.get("NETNEUTRALITY_LAVALINK_URI", DEFAULT_URI);
|
||||||
|
String password = settings.get("NETNEUTRALITY_LAVALINK_PASSWORD");
|
||||||
|
String name = settings.get("NETNEUTRALITY_LAVALINK_NAME", DEFAULT_NAME);
|
||||||
|
|
||||||
|
if (password == null) {
|
||||||
|
logger.error("No Lavalink password configured (NETNEUTRALITY_LAVALINK_PASSWORD), music will be unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.addNode(new NodeOptions.Builder()
|
||||||
|
.setName(name)
|
||||||
|
.setServerUri(uri)
|
||||||
|
.setPassword(password)
|
||||||
|
.build());
|
||||||
|
logger.info("Registered Lavalink node '{}' at {}", name, uri);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Failed to register Lavalink node '{}' at {}, music will be unavailable", name, uri, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-arms the client's own reconnect loop for nodes whose socket was closed.
|
||||||
|
*
|
||||||
|
* <p>The Lavalink client disables reconnection permanently when the node closes the socket
|
||||||
|
* with a normal close code (1000/1001), which is exactly what a graceful node restart does.
|
||||||
|
* Without this watchdog the bot would stay disconnected until it is restarted itself.
|
||||||
|
*/
|
||||||
|
private void startReconnectWatchdog() {
|
||||||
|
reconnectWatchdog = executors.getScheduledExecutor().scheduleWithFixedDelay(
|
||||||
|
this::rearmReconnects,
|
||||||
|
RECONNECT_WATCHDOG_SECONDS,
|
||||||
|
RECONNECT_WATCHDOG_SECONDS,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rearmReconnects() {
|
||||||
|
try {
|
||||||
|
for (LavalinkNode node : client.getNodes()) {
|
||||||
|
LavalinkSocket socket = node.getWs();
|
||||||
|
if (!socket.getOpen() && !socket.getMayReconnect()) {
|
||||||
|
logger.warn("Lavalink node '{}' closed the connection normally, re-arming reconnects", node.getName());
|
||||||
|
socket.setMayReconnect(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Failed to re-arm Lavalink reconnects", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the single subscription to the client event flux and routes every guild-scoped event
|
||||||
|
* to the owning guild's {@link TrackScheduler}.
|
||||||
|
*/
|
||||||
|
private void subscribeToEvents() {
|
||||||
|
subscriptions.add(client.on(ReadyEvent.class)
|
||||||
|
.subscribe(event -> logger.info("Lavalink node '{}' is ready (session {})", event.getNode().getName(), event.getSessionId())));
|
||||||
|
|
||||||
|
subscriptions.add(client.on(TrackStartEvent.class)
|
||||||
|
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStart(event.getTrack()))));
|
||||||
|
|
||||||
|
subscriptions.add(client.on(TrackEndEvent.class)
|
||||||
|
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackEnd(event.getTrack(), event.getEndReason()))));
|
||||||
|
|
||||||
|
subscriptions.add(client.on(TrackExceptionEvent.class)
|
||||||
|
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackException(event.getTrack(), event.getException()))));
|
||||||
|
|
||||||
|
subscriptions.add(client.on(TrackStuckEvent.class)
|
||||||
|
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs()))));
|
||||||
|
|
||||||
|
subscriptions.add(client.on(WebSocketClosedEvent.class)
|
||||||
|
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(long guildId, Consumer<TrackScheduler> action) {
|
||||||
|
MusicManager manager = managers.get(String.valueOf(guildId));
|
||||||
|
if (manager == null) {
|
||||||
|
logger.debug("Received a Lavalink event for unknown guild {}", guildId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
action.accept(manager.getScheduler());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("Guild {}: failed to handle Lavalink event", guildId, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
|
||||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
@@ -23,13 +22,11 @@ import java.util.function.Predicate;
|
|||||||
public class QueueManager {
|
public class QueueManager {
|
||||||
private final Logger logger = LogManager.getLogger(QueueManager.class);
|
private final Logger logger = LogManager.getLogger(QueueManager.class);
|
||||||
private final TrackScheduler scheduler;
|
private final TrackScheduler scheduler;
|
||||||
private final AudioPlayer player;
|
|
||||||
private final Guild guild;
|
private final Guild guild;
|
||||||
private final Deque<TrackInfo> queue;
|
private final Deque<TrackInfo> queue;
|
||||||
|
|
||||||
public QueueManager(TrackScheduler scheduler) {
|
public QueueManager(TrackScheduler scheduler) {
|
||||||
this.scheduler = scheduler;
|
this.scheduler = scheduler;
|
||||||
this.player = scheduler.getPlayer();
|
|
||||||
this.guild = scheduler.getGuild();
|
this.guild = scheduler.getGuild();
|
||||||
|
|
||||||
this.queue = new LinkedBlockingDeque<>();
|
this.queue = new LinkedBlockingDeque<>();
|
||||||
@@ -110,20 +107,20 @@ public class QueueManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AudioTrack> getAllQueuedTracks() {
|
public List<Track> getAllQueuedTracks() {
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
ArrayList<AudioTrack> tracks = new ArrayList<>();
|
ArrayList<Track> tracks = new ArrayList<>();
|
||||||
|
|
||||||
queue.forEach(trackInfo -> tracks.add(trackInfo.getTrack()));
|
queue.forEach(trackInfo -> tracks.add(trackInfo.getTrack()));
|
||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void queue(List<AudioTrack> tracks, Member member) {
|
public void queue(List<Track> tracks, Member member) {
|
||||||
tracks.forEach(track -> queue(track, member));
|
tracks.forEach(track -> queue(track, member));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void queue(AudioTrack track, Member member) {
|
public void queue(Track track, Member member) {
|
||||||
TrackInfo info = new TrackInfo(track, member);
|
TrackInfo info = new TrackInfo(track, member);
|
||||||
|
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
@@ -131,42 +128,67 @@ public class QueueManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.getPlayingTrack() == null && !queue.isEmpty()) {
|
boolean idle = scheduler.getCurrentTrack() == null;
|
||||||
|
|
||||||
|
if (idle && !queue.isEmpty()) {
|
||||||
queue.addFirst(info);
|
queue.addFirst(info);
|
||||||
} else {
|
} else {
|
||||||
queue.add(info);
|
queue.add(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.getPlayingTrack() == null) {
|
if (idle) {
|
||||||
AudioTrack toPlay = queue.peek() == null ? null : queue.peek().getTrack();
|
TrackInfo head = queue.peek();
|
||||||
player.startTrack(toPlay, true);
|
if (head != null) {
|
||||||
|
startTrack(head.getTrack(), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a play request to the node. The cached current track is updated eagerly so that a
|
||||||
|
* burst of enqueues does not start several tracks at once while waiting for the node's
|
||||||
|
* {@code TrackStartEvent}.
|
||||||
|
*
|
||||||
|
* @param track the track to play
|
||||||
|
* @param noReplace whether an already playing track must not be interrupted
|
||||||
|
*/
|
||||||
|
private void startTrack(Track track, boolean noReplace) {
|
||||||
|
scheduler.setCurrentTrack(track);
|
||||||
|
scheduler.getMusicManager().updatePlayer(
|
||||||
|
"start track " + track.getInfo().getIdentifier(),
|
||||||
|
builder -> builder.setTrack(track).setNoReplace(noReplace)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopTrack() {
|
||||||
|
scheduler.setCurrentTrack(null);
|
||||||
|
scheduler.getMusicManager().stopTrack();
|
||||||
|
}
|
||||||
|
|
||||||
public CommandExecutionResult resume() {
|
public CommandExecutionResult resume() {
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
TrackInfo next = queue.peek();
|
TrackInfo next = queue.peek();
|
||||||
|
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
player.startTrack(next.getTrack().makeClone(), false);
|
startTrack(next.getTrack().makeClone(), false);
|
||||||
return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().title);
|
return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().getTitle());
|
||||||
}
|
}
|
||||||
|
|
||||||
player.stopTrack();
|
stopTrack();
|
||||||
return CommandExecutionResult.info("Queue is empty, leaving voice channel");
|
return CommandExecutionResult.info("Queue is empty, leaving voice channel");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void startNextTrack() {
|
public void startNextTrack() {
|
||||||
synchronized (queue) {
|
synchronized (queue) {
|
||||||
TrackInfo last = queue.poll();
|
queue.poll();
|
||||||
TrackInfo next = queue.peek();
|
TrackInfo next = queue.peek();
|
||||||
|
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
player.startTrack(next.getTrack().makeClone(), false);
|
startTrack(next.getTrack().makeClone(), false);
|
||||||
} else {
|
} else {
|
||||||
player.stopTrack();
|
stopTrack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ public class SourceFinder {
|
|||||||
String identifier;
|
String identifier;
|
||||||
|
|
||||||
identifier = switch (getSource(url)) {
|
identifier = switch (getSource(url)) {
|
||||||
case Youtube, YTMusic, Soundcloud, Udio, DirectLinkMp3 -> url;
|
case Youtube, YTMusic, Soundcloud, DirectLinkMp3 -> url;
|
||||||
case YTSearch -> "ytsearch:" + url;
|
case YTSearch -> "ytsearch:" + url;
|
||||||
|
// Udio is not resolvable by the Lavalink node until the source is ported to a
|
||||||
|
// server-side plugin, see docs/reference/udio-lavalink-plugin.
|
||||||
default -> null;
|
default -> null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
|
||||||
import me.bobert.netneutrality.core.LogHandler;
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.context.Context;
|
import me.bobert.netneutrality.core.context.Context;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
@@ -14,19 +13,19 @@ import org.apache.logging.log4j.Logger;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates the retry-or-skip logic for stuck tracks, extracted from
|
* Encapsulates the retry-or-skip logic for stuck tracks, extracted from
|
||||||
* {@link TrackScheduler#onTrackStuck(AudioPlayer, AudioTrack, long)}.
|
* {@link TrackScheduler#onTrackStuck(Track, long)}.
|
||||||
*/
|
*/
|
||||||
class StuckTrackRecoveryHandler {
|
class StuckTrackRecoveryHandler {
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(StuckTrackRecoveryHandler.class);
|
private static final Logger logger = LogManager.getLogger(StuckTrackRecoveryHandler.class);
|
||||||
|
|
||||||
private final AudioPlayer player;
|
private final MusicManager manager;
|
||||||
private final QueueManager queueManager;
|
private final QueueManager queueManager;
|
||||||
|
|
||||||
private AudioTrack lastStuckTrack;
|
private Track lastStuckTrack;
|
||||||
|
|
||||||
StuckTrackRecoveryHandler(AudioPlayer player, QueueManager queueManager) {
|
StuckTrackRecoveryHandler(MusicManager manager, QueueManager queueManager) {
|
||||||
this.player = player;
|
this.manager = manager;
|
||||||
this.queueManager = queueManager;
|
this.queueManager = queueManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +39,11 @@ class StuckTrackRecoveryHandler {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles a stuck-track event. On the first occurrence for a given track
|
* 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
|
* identifier the handler attempts to re-queue the track; on the
|
||||||
* second consecutive occurrence it skips to the next track.
|
* second consecutive occurrence it skips to the next track.
|
||||||
*/
|
*/
|
||||||
void handle(AudioTrack track, Context context) {
|
void handle(Track track, Context context) {
|
||||||
if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) {
|
if (lastStuckTrack == null || !identifierOf(lastStuckTrack).equals(identifierOf(track))) {
|
||||||
lastStuckTrack = track;
|
lastStuckTrack = track;
|
||||||
attemptRecovery(track, context);
|
attemptRecovery(track, context);
|
||||||
} else {
|
} else {
|
||||||
@@ -53,28 +52,26 @@ class StuckTrackRecoveryHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void attemptRecovery(AudioTrack track, Context context) {
|
private void attemptRecovery(Track track, Context context) {
|
||||||
logger.debug("Trying to load stuck track again: {}", track.getIdentifier());
|
logger.debug("Trying to load stuck track again: {}", identifierOf(track));
|
||||||
|
|
||||||
context.async("Current track is stuck, trying to load it again", asyncContext -> {
|
context.async("Current track is stuck, trying to load it again", asyncContext -> {
|
||||||
try {
|
try {
|
||||||
TrackInfo info = queueManager.peekHeadOfQueue();
|
TrackInfo info = queueManager.peekHeadOfQueue();
|
||||||
if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) {
|
if (info != null && identifierOf(info.getTrack()).equals(identifierOf(track))) {
|
||||||
AudioTrack clone = track.makeClone();
|
long lastPosition = manager.getCurrentPosition();
|
||||||
long lastPosition = track.getPosition();
|
|
||||||
|
|
||||||
player.stopTrack();
|
manager.stopTrack();
|
||||||
|
|
||||||
if (queueManager.peekHeadOfQueue() != null
|
TrackInfo head = queueManager.peekHeadOfQueue();
|
||||||
&& queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) {
|
if (head != null && identifierOf(head.getTrack()).equals(identifierOf(track))) {
|
||||||
queueManager.removeHeadOfQueue();
|
queueManager.removeHeadOfQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
queueManager.queue(clone, info.getRequester());
|
queueManager.queue(track.makeClone(), info.getRequester());
|
||||||
|
|
||||||
AudioTrack playing = player.getPlayingTrack();
|
if (lastPosition > 0) {
|
||||||
if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) {
|
manager.updatePlayer("seek to " + lastPosition, builder -> builder.setPosition(lastPosition));
|
||||||
playing.setPosition(lastPosition);
|
|
||||||
lastStuckTrack = null;
|
lastStuckTrack = null;
|
||||||
} else {
|
} else {
|
||||||
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
|
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
|
||||||
@@ -90,4 +87,8 @@ class StuckTrackRecoveryHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String identifierOf(Track track) {
|
||||||
|
return track.getInfo().getIdentifier();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
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;
|
||||||
@@ -16,18 +16,18 @@ import java.util.Objects;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public class TrackInfo {
|
public class TrackInfo {
|
||||||
private final AudioTrack track;
|
private final Track 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(TrackInfo.class);
|
private static final Logger logger = LogManager.getLogger(TrackInfo.class);
|
||||||
|
|
||||||
public TrackInfo(AudioTrack track, Member member) {
|
public TrackInfo(Track track, Member member) {
|
||||||
this.track = track;
|
this.track = track;
|
||||||
this.member = member;
|
this.member = member;
|
||||||
this.skips = new HashSet<>();
|
this.skips = new HashSet<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public AudioTrack getTrack() {
|
public Track getTrack() {
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,11 @@ public class TrackInfo {
|
|||||||
return member;
|
return member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two entries are equal when they wrap the same encoded track, which is what makes the queue
|
||||||
|
* reject duplicates. The Lavalink {@link Track} type has no value equality, so the encoded
|
||||||
|
* payload is compared instead.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean equals(Object o) {
|
public boolean equals(Object o) {
|
||||||
if (this == o) {
|
if (this == o) {
|
||||||
@@ -63,18 +68,18 @@ public class TrackInfo {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
TrackInfo trackInfo = (TrackInfo) o;
|
TrackInfo trackInfo = (TrackInfo) o;
|
||||||
return track.equals(trackInfo.track);
|
return track.getEncoded().equals(trackInfo.track.getEncoded());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return Objects.hash(track);
|
return Objects.hash(track.getEncoded());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return "TrackInfo{" +
|
return "TrackInfo{" +
|
||||||
"track=" + track.getInfo().title +
|
"track=" + track.getInfo().getTitle() +
|
||||||
", member=" + member.getEffectiveName() +
|
", member=" + member.getEffectiveName() +
|
||||||
", skips=" + skips +
|
", skips=" + skips +
|
||||||
'}';
|
'}';
|
||||||
|
|||||||
@@ -6,20 +6,27 @@ package me.bobert.netneutrality.music;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
import me.bobert.netneutrality.utils.MusicUtils;
|
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
import dev.arbjerg.lavalink.client.player.LoadFailed;
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
import dev.arbjerg.lavalink.client.player.SearchResult;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
|
import dev.arbjerg.lavalink.client.player.TrackLoaded;
|
||||||
import me.bobert.netneutrality.core.LogHandler;
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.tasks.music.LoadPlaylistTask;
|
|
||||||
import me.bobert.netneutrality.shared.util.MessageFunction;
|
import me.bobert.netneutrality.shared.util.MessageFunction;
|
||||||
|
import me.bobert.netneutrality.tasks.music.LoadPlaylistTask;
|
||||||
|
import me.bobert.netneutrality.utils.MusicUtils;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
public class TrackLoadingHandler implements AudioLoadResultHandler {
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a Lavalink load result into queue operations and user feedback.
|
||||||
|
*/
|
||||||
|
public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
|
||||||
private static final Logger logger = LogManager.getLogger(TrackLoadingHandler.class);
|
private static final Logger logger = LogManager.getLogger(TrackLoadingHandler.class);
|
||||||
|
|
||||||
private final String identifier;
|
private final String identifier;
|
||||||
@@ -42,24 +49,42 @@ public class TrackLoadingHandler implements AudioLoadResultHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void trackLoaded(AudioTrack audioTrack) {
|
public void ontrackLoaded(TrackLoaded result) {
|
||||||
logger.debug("Guild {}: track loaded: {}", guild.getId(), audioTrack.getIdentifier());
|
Track track = result.getTrack();
|
||||||
|
logger.debug("Guild {}: track loaded: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||||
|
|
||||||
context.deleteMessage();
|
context.deleteMessage();
|
||||||
if (MusicUtils.connect(context, manager)) {
|
if (MusicUtils.connect(context, manager)) {
|
||||||
queueManager.queue(audioTrack, context.getGuildUser());
|
queueManager.queue(track, context.getGuildUser());
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
context.reply(MessageFunction.buildAddingTrackMessage(audioTrack, context.getGuildUser(), type));
|
context.reply(MessageFunction.buildAddingTrackMessage(track, context.getGuildUser(), type));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void playlistLoaded(AudioPlaylist playlist) {
|
public void onPlaylistLoaded(PlaylistLoaded result) {
|
||||||
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
|
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
|
||||||
|
|
||||||
new LoadPlaylistTask(playlist, type, manager).insert(context);
|
new LoadPlaylistTask(result.getInfo().getName(), result.getTracks(), false, type, manager).insert(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search results keep the pre-migration behaviour: the user is offered the first few hits
|
||||||
|
* through the same playlist task that lavaplayer's search playlists used to produce.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onSearchResultLoaded(SearchResult result) {
|
||||||
|
List<Track> tracks = result.getTracks();
|
||||||
|
logger.debug("Guild {}: search returned {} results for {}", guild.getId(), tracks.size(), identifier);
|
||||||
|
|
||||||
|
if (tracks.isEmpty()) {
|
||||||
|
noMatches();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new LoadPlaylistTask("Search results", tracks, true, type, manager).insert(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -72,11 +97,11 @@ public class TrackLoadingHandler implements AudioLoadResultHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void loadFailed(FriendlyException e) {
|
public void loadFailed(LoadFailed result) {
|
||||||
logger.debug("Guild {}: load failed for identifier {}", guild.getId(), identifier);
|
logger.debug("Guild {}: load failed for identifier {}", guild.getId(), identifier);
|
||||||
|
|
||||||
//TODO: better error messages
|
//TODO: better error messages
|
||||||
RuntimeException exception = new RuntimeException("Failed to load track: " + e.getMessage(), e);
|
RuntimeException exception = new RuntimeException("Failed to load track: " + result.getException().getMessage());
|
||||||
LogHandler.log(exception);
|
LogHandler.log(exception);
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
|
import dev.arbjerg.lavalink.client.player.TrackException;
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
|
|
||||||
import me.bobert.netneutrality.context.MusicChannelContext;
|
import me.bobert.netneutrality.context.MusicChannelContext;
|
||||||
import me.bobert.netneutrality.core.LogHandler;
|
import me.bobert.netneutrality.core.LogHandler;
|
||||||
import me.bobert.netneutrality.core.context.Context;
|
import me.bobert.netneutrality.core.context.Context;
|
||||||
@@ -19,23 +17,28 @@ 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;
|
||||||
|
|
||||||
public class TrackScheduler extends AudioEventAdapter {
|
/**
|
||||||
|
* Sink for the track lifecycle events emitted by the Lavalink node.
|
||||||
|
*
|
||||||
|
* <p>Events are delivered by the central dispatcher in {@link MusicServiceImpl}, which routes
|
||||||
|
* them to the scheduler of the guild they belong to.
|
||||||
|
*/
|
||||||
|
public class TrackScheduler {
|
||||||
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 MusicManagerCallback callback;
|
||||||
private final AudioPlayer player;
|
|
||||||
private final Guild guild;
|
private final Guild guild;
|
||||||
private volatile Context context;
|
private volatile Context context;
|
||||||
|
private volatile Track currentTrack;
|
||||||
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
|
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
|
||||||
|
|
||||||
public TrackScheduler(MusicManager manager) {
|
public TrackScheduler(MusicManager manager) {
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
this.callback = manager;
|
this.callback = manager;
|
||||||
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);
|
this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(manager, queueManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
public QueueManager getQueueManager() {
|
public QueueManager getQueueManager() {
|
||||||
@@ -50,14 +53,26 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
return callback;
|
return callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AudioPlayer getPlayer() {
|
|
||||||
return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guild getGuild() {
|
public Guild getGuild() {
|
||||||
return guild;
|
return guild;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the track the node is currently playing.
|
||||||
|
*
|
||||||
|
* <p>The Lavalink player state is only readable through a REST call, so the last known track
|
||||||
|
* is cached here and kept up to date by the node's events.
|
||||||
|
*
|
||||||
|
* @return the current track or {@code null}
|
||||||
|
*/
|
||||||
|
public Track getCurrentTrack() {
|
||||||
|
return currentTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCurrentTrack(Track track) {
|
||||||
|
this.currentTrack = track;
|
||||||
|
}
|
||||||
|
|
||||||
public Member getDj() {
|
public Member getDj() {
|
||||||
Context ctx = context;
|
Context ctx = context;
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
@@ -83,36 +98,30 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
return stuckTrackRecoveryHandler;
|
return stuckTrackRecoveryHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public void onTrackStart(Track track) {
|
||||||
public void onPlayerPause(AudioPlayer player) {
|
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||||
// Player was paused
|
currentTrack = track;
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPlayerResume(AudioPlayer player) {
|
|
||||||
// Player was resumed
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onTrackStart(AudioPlayer player, AudioTrack track) {
|
|
||||||
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getIdentifier());
|
|
||||||
stuckTrackRecoveryHandler.reset();
|
stuckTrackRecoveryHandler.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
|
||||||
public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) {
|
logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||||
logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getIdentifier());
|
|
||||||
|
|
||||||
if (endReason == AudioTrackEndReason.LOAD_FAILED) {
|
if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) {
|
||||||
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getIdentifier());
|
currentTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
|
||||||
|
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if mayStartNext
|
// check if mayStartNext
|
||||||
if (endReason == AudioTrackEndReason.FINISHED || endReason == AudioTrackEndReason.LOAD_FAILED) {
|
if (endReason == TrackEndEvent.AudioTrackEndReason.FINISHED
|
||||||
|
|| endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
|
||||||
queueManager.startNextTrack();
|
queueManager.startNextTrack();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endReason == AudioTrackEndReason.STOPPED) {
|
if (endReason == TrackEndEvent.AudioTrackEndReason.STOPPED) {
|
||||||
// maybe skip removing head to make resuming easier?
|
// maybe skip removing head to make resuming easier?
|
||||||
queueManager.removeHeadOfQueue();
|
queueManager.removeHeadOfQueue();
|
||||||
}
|
}
|
||||||
@@ -125,16 +134,23 @@ public class TrackScheduler extends AudioEventAdapter {
|
|||||||
// clone of this back to your queue
|
// clone of this back to your queue
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public void onTrackException(Track track, TrackException exception) {
|
||||||
public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyException exception) {
|
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||||
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getIdentifier());
|
|
||||||
|
|
||||||
LogHandler.log(new ContextException(String.format("Error while playing track: %s", track.getInfo().title), getContext(), exception));
|
LogHandler.log(new ContextException(
|
||||||
|
String.format("Error while playing track: %s", track.getInfo().getTitle()),
|
||||||
|
getContext(),
|
||||||
|
new RuntimeException(exception.getMessage())
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public void onTrackStuck(Track track, long thresholdMs) {
|
||||||
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) {
|
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs);
|
||||||
logger.error("Guild {}: currently playing track is stuck: {}", guild.getId(), track.getIdentifier());
|
|
||||||
stuckTrackRecoveryHandler.handle(track, context);
|
stuckTrackRecoveryHandler.handle(track, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void onWebSocketClosed(int code, String reason, boolean byRemote) {
|
||||||
|
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
|
||||||
|
currentTrack = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -50,7 +50,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void processManager(MusicManager manager) throws InterruptedException {
|
private void processManager(MusicManager manager) throws InterruptedException {
|
||||||
AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel();
|
AudioChannel channel = manager.getConnectedChannel();
|
||||||
|
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", manager.getGuild().getId());
|
logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", manager.getGuild().getId());
|
||||||
@@ -68,7 +68,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean conditionsMetToDisconnect(MusicManager manager) {
|
private boolean conditionsMetToDisconnect(MusicManager manager) {
|
||||||
AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel();
|
AudioChannel channel = manager.getConnectedChannel();
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
List<Member> members = channel.getMembers().stream().filter(member -> !member.getId().equals(channel.getJDA().getSelfUser().getId())).toList();
|
List<Member> members = channel.getMembers().stream().filter(member -> !member.getId().equals(channel.getJDA().getSelfUser().getId())).toList();
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ package me.bobert.netneutrality.shared.util;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import dev.arbjerg.lavalink.protocol.v4.TrackInfo;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
|
||||||
import io.sentry.Sentry;
|
import io.sentry.Sentry;
|
||||||
import me.bobert.netneutrality.core.command.Command;
|
import me.bobert.netneutrality.core.command.Command;
|
||||||
import me.bobert.netneutrality.core.command.CommandCategory;
|
import me.bobert.netneutrality.core.command.CommandCategory;
|
||||||
@@ -20,7 +19,6 @@ import me.bobert.netneutrality.core.context.TaskContext;
|
|||||||
import me.bobert.netneutrality.exceptions.ContextException;
|
import me.bobert.netneutrality.exceptions.ContextException;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.music.SourceFinder;
|
import me.bobert.netneutrality.music.SourceFinder;
|
||||||
import me.bobert.netneutrality.music.TrackInfo;
|
|
||||||
import net.dv8tion.jda.api.EmbedBuilder;
|
import net.dv8tion.jda.api.EmbedBuilder;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
@@ -40,32 +38,30 @@ public class MessageFunction {
|
|||||||
private static final Logger logger = LogManager.getLogger(MessageFunction.class);
|
private static final Logger logger = LogManager.getLogger(MessageFunction.class);
|
||||||
|
|
||||||
public static MessageEmbed buildCurrentlyPlayingMessage(MusicManager manager) {
|
public static MessageEmbed buildCurrentlyPlayingMessage(MusicManager manager) {
|
||||||
AudioTrack current = manager.getCurrentlyPlaying();
|
Track current = manager.getCurrentlyPlaying();
|
||||||
|
TrackInfo currentInfo = current.getInfo();
|
||||||
|
|
||||||
EmbedBuilder message = new EmbedBuilder();
|
EmbedBuilder message = new EmbedBuilder();
|
||||||
message.setAuthor("Link to track (click me)", String.format("https://youtu.be/%s", current.getIdentifier()));
|
message.setAuthor("Link to track (click me)", String.format("https://youtu.be/%s", currentInfo.getIdentifier()));
|
||||||
message.setThumbnail(String.format("https://img.youtube.com/vi/%s/default.jpg", current.getIdentifier()));
|
message.setThumbnail(String.format("https://img.youtube.com/vi/%s/default.jpg", currentInfo.getIdentifier()));
|
||||||
//message.setTitle("Currently playing:");
|
//message.setTitle("Currently playing:");
|
||||||
message.setColor(Color.BLUE);
|
message.setColor(Color.BLUE);
|
||||||
message.addField("Currently playing: ", current.getInfo().title, true);
|
message.addField("Currently playing: ", currentInfo.getTitle(), true);
|
||||||
message.addField("Duration: ", DurationFormatUtils.formatDuration(current.getPosition(), "HH:mm:ss", true) + " / " + DurationFormatUtils.formatDuration(current.getDuration(), "HH:mm:ss", true), true);
|
message.addField("Duration: ", DurationFormatUtils.formatDuration(manager.getCurrentPosition(), "HH:mm:ss", true) + " / " + DurationFormatUtils.formatDuration(currentInfo.getLength(), "HH:mm:ss", true), true);
|
||||||
message.setTimestamp(OffsetDateTime.now());
|
message.setTimestamp(OffsetDateTime.now());
|
||||||
|
|
||||||
return message.build();
|
return message.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static MessageEmbed buildAddingTrackMessage(AudioTrack track, Member member, SourceFinder.AudioSourceType type) {
|
public static MessageEmbed buildAddingTrackMessage(Track track, Member member, SourceFinder.AudioSourceType type) {
|
||||||
EmbedBuilder message = new EmbedBuilder();
|
EmbedBuilder message = new EmbedBuilder();
|
||||||
String url = track.getIdentifier();
|
TrackInfo info = track.getInfo();
|
||||||
String thumbnail = null;
|
String url = info.getIdentifier();
|
||||||
|
String thumbnail = info.getArtworkUrl();
|
||||||
|
|
||||||
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch || type == SourceFinder.AudioSourceType.YTMusic) {
|
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch || type == SourceFinder.AudioSourceType.YTMusic) {
|
||||||
url = String.format("https://youtu.be/%s", track.getIdentifier());
|
url = String.format("https://youtu.be/%s", info.getIdentifier());
|
||||||
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", track.getIdentifier());
|
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", info.getIdentifier());
|
||||||
}
|
|
||||||
|
|
||||||
if (type == SourceFinder.AudioSourceType.Udio) {
|
|
||||||
thumbnail = track.getInfo().artworkUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thumbnail != null) {
|
if (thumbnail != null) {
|
||||||
@@ -73,26 +69,26 @@ public class MessageFunction {
|
|||||||
}
|
}
|
||||||
message.setTitle("Adding track to queue..");
|
message.setTitle("Adding track to queue..");
|
||||||
message.setColor(Color.BLUE);
|
message.setColor(Color.BLUE);
|
||||||
message.addField("Track title: ", String.format("[**`%s`**](%s)", track.getInfo().title, url), false);
|
message.addField("Track title: ", String.format("[**`%s`**](%s)", info.getTitle(), url), false);
|
||||||
message.addField("Duration: ", DurationFormatUtils.formatDuration(track.getDuration(), "**HH:mm:ss**", true), false);
|
message.addField("Duration: ", DurationFormatUtils.formatDuration(info.getLength(), "**HH:mm:ss**", true), false);
|
||||||
message.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
|
message.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
|
||||||
message.setTimestamp(OffsetDateTime.now());
|
message.setTimestamp(OffsetDateTime.now());
|
||||||
|
|
||||||
return message.build();
|
return message.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static MessageEmbed buildAddingPlaylistMessage(List<AudioTrack> tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) {
|
public static MessageEmbed buildAddingPlaylistMessage(List<Track> tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) {
|
||||||
EmbedBuilder message = new EmbedBuilder();
|
EmbedBuilder message = new EmbedBuilder();
|
||||||
String url = "";
|
String url = "";
|
||||||
String thumbnail = null;
|
String thumbnail = null;
|
||||||
|
|
||||||
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch) {
|
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch) {
|
||||||
url = String.format("https://youtu.be/%s", tracks.get(0).getIdentifier());
|
url = String.format("https://youtu.be/%s", tracks.get(0).getInfo().getIdentifier());
|
||||||
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getIdentifier());
|
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == SourceFinder.AudioSourceType.YTMusic) {
|
if (type == SourceFinder.AudioSourceType.YTMusic) {
|
||||||
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getIdentifier());
|
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thumbnail != null) {
|
if (thumbnail != null) {
|
||||||
@@ -109,13 +105,13 @@ public class MessageFunction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String buildViewQueueMessage(MusicManager manager) {
|
public static String buildViewQueueMessage(MusicManager manager) {
|
||||||
List<AudioTrack> tracks = manager.getQueueManager().getAllQueuedTracks();
|
List<Track> tracks = manager.getQueueManager().getAllQueuedTracks();
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
return "Queue is empty!";
|
return "Queue is empty!";
|
||||||
}
|
}
|
||||||
|
|
||||||
int limit = 10;
|
int limit = 10;
|
||||||
AudioTrack current = manager.getCurrentlyPlaying();
|
Track current = manager.getCurrentlyPlaying();
|
||||||
int numberofTracksShown = Math.min(tracks.size(), limit);
|
int numberofTracksShown = Math.min(tracks.size(), limit);
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
StringBuilder message = new StringBuilder();
|
||||||
@@ -127,15 +123,15 @@ public class MessageFunction {
|
|||||||
.append(" items):\n");
|
.append(" items):\n");
|
||||||
|
|
||||||
int index = 0;
|
int index = 0;
|
||||||
for (AudioTrack track : tracks) {
|
for (Track track : tracks) {
|
||||||
index++;
|
index++;
|
||||||
AudioTrackInfo info = track.getInfo();
|
TrackInfo info = track.getInfo();
|
||||||
|
|
||||||
message.append("\n");
|
message.append("\n");
|
||||||
if (current != null && current.getInfo().title.equals(info.title)) {
|
if (current != null && current.getInfo().getTitle().equals(info.getTitle())) {
|
||||||
message.append("- ").append("**").append(info.title).append("**");
|
message.append("- ").append("**").append(info.getTitle()).append("**");
|
||||||
} else {
|
} else {
|
||||||
message.append("- ").append(info.title);
|
message.append("- ").append(info.getTitle());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index == limit) {
|
if (index == limit) {
|
||||||
@@ -147,8 +143,8 @@ public class MessageFunction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static MessageEmbed buildUserTracksMessage(MusicManager manager, Member member) {
|
public static MessageEmbed buildUserTracksMessage(MusicManager manager, Member member) {
|
||||||
List<TrackInfo> tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester()));
|
List<me.bobert.netneutrality.music.TrackInfo> tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester()));
|
||||||
AudioTrack current = manager.getCurrentlyPlaying();
|
Track current = manager.getCurrentlyPlaying();
|
||||||
int numberofTracksShown = Math.min(tracks.size(), 10);
|
int numberofTracksShown = Math.min(tracks.size(), 10);
|
||||||
|
|
||||||
EmbedBuilder message = new EmbedBuilder();
|
EmbedBuilder message = new EmbedBuilder();
|
||||||
@@ -156,13 +152,13 @@ public class MessageFunction {
|
|||||||
message.setColor(Color.BLUE);
|
message.setColor(Color.BLUE);
|
||||||
|
|
||||||
int index = 0;
|
int index = 0;
|
||||||
for (TrackInfo track : tracks) {
|
for (me.bobert.netneutrality.music.TrackInfo track : tracks) {
|
||||||
AudioTrackInfo info = track.getTrack().getInfo();
|
TrackInfo info = track.getTrack().getInfo();
|
||||||
|
|
||||||
if (current != null && current.getInfo().title.equals(info.title)) {
|
if (current != null && current.getInfo().getTitle().equals(info.getTitle())) {
|
||||||
message.addField("Track #" + ++index + " (Currently playing)", info.title, false);
|
message.addField("Track #" + ++index + " (Currently playing)", info.getTitle(), false);
|
||||||
} else {
|
} else {
|
||||||
message.addField("Track #" + ++index, info.title, false);
|
message.addField("Track #" + ++index, info.getTitle(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index == 10) {
|
if (index == 10) {
|
||||||
@@ -174,7 +170,7 @@ public class MessageFunction {
|
|||||||
return message.build();
|
return message.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static MessageEmbed buildSearchResultsMessage(List<AudioTrack> tracks, Member member) {
|
public static MessageEmbed buildSearchResultsMessage(List<Track> tracks, Member member) {
|
||||||
EmbedBuilder embed = new EmbedBuilder();
|
EmbedBuilder embed = new EmbedBuilder();
|
||||||
|
|
||||||
embed.setTitle("Search results");
|
embed.setTitle("Search results");
|
||||||
@@ -182,8 +178,8 @@ public class MessageFunction {
|
|||||||
embed.setColor(Color.MAGENTA);
|
embed.setColor(Color.MAGENTA);
|
||||||
|
|
||||||
int i = 0;
|
int i = 0;
|
||||||
for (AudioTrack track : tracks) {
|
for (Track track : tracks) {
|
||||||
embed.addField(++i + ": ", track.getInfo().title, false);
|
embed.addField(++i + ": ", track.getInfo().getTitle(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
|
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
|
||||||
@@ -192,11 +188,11 @@ public class MessageFunction {
|
|||||||
return embed.build();
|
return embed.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static MessageEmbed buildPlaylistLoadMessage(AudioPlaylist playlist, Member member) {
|
public static MessageEmbed buildPlaylistLoadMessage(String playlistName, int trackCount, Member member) {
|
||||||
EmbedBuilder embed = new EmbedBuilder();
|
EmbedBuilder embed = new EmbedBuilder();
|
||||||
|
|
||||||
embed.setTitle(String.format("Loading playlist: %s", playlist.getName()));
|
embed.setTitle(String.format("Loading playlist: %s", playlistName));
|
||||||
embed.setDescription(String.format("You are about to load %d tracks from this playlist, are you sure?", playlist.getTracks().size()));
|
embed.setDescription(String.format("You are about to load %d tracks from this playlist, are you sure?", trackCount));
|
||||||
embed.setColor(Color.CYAN);
|
embed.setColor(Color.CYAN);
|
||||||
|
|
||||||
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
|
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.tasks.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.core.tasks.Task;
|
import me.bobert.netneutrality.core.tasks.Task;
|
||||||
import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
|
import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
|
||||||
@@ -28,13 +27,18 @@ import java.util.List;
|
|||||||
|
|
||||||
public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable {
|
public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable {
|
||||||
private final List<Task> childrens = new ArrayList<>();
|
private final List<Task> childrens = new ArrayList<>();
|
||||||
private final AudioPlaylist playlist;
|
private final String playlistName;
|
||||||
|
private final List<Track> playlistTracks;
|
||||||
|
private final boolean searchResult;
|
||||||
private final SourceFinder.AudioSourceType sourceType;
|
private final SourceFinder.AudioSourceType sourceType;
|
||||||
private final MusicManager musicManager;
|
private final MusicManager musicManager;
|
||||||
|
|
||||||
public LoadPlaylistTask(AudioPlaylist playlist, SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
|
public LoadPlaylistTask(String playlistName, List<Track> playlistTracks, boolean searchResult,
|
||||||
|
SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
|
||||||
super(musicManager.getTaskManager(), null, null);
|
super(musicManager.getTaskManager(), null, null);
|
||||||
this.playlist = playlist;
|
this.playlistName = playlistName;
|
||||||
|
this.playlistTracks = playlistTracks;
|
||||||
|
this.searchResult = searchResult;
|
||||||
this.sourceType = sourceType;
|
this.sourceType = sourceType;
|
||||||
this.musicManager = musicManager;
|
this.musicManager = musicManager;
|
||||||
}
|
}
|
||||||
@@ -51,7 +55,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void replyToInteraction(CommandContext context) {
|
public void replyToInteraction(CommandContext context) {
|
||||||
if (playlist.getTracks().isEmpty()) {
|
if (playlistTracks.isEmpty()) {
|
||||||
context.reply("No tracks found");
|
context.reply("No tracks found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -62,12 +66,12 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
|
|||||||
.getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE));
|
.getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE));
|
||||||
|
|
||||||
// Search results
|
// Search results
|
||||||
if (playlist.isSearchResult()) {
|
if (searchResult) {
|
||||||
List<AudioTrack> searchResults = playlist.getTracks().stream().limit(searchResultsLimit).toList();
|
List<Track> searchResults = playlistTracks.stream().limit(searchResultsLimit).toList();
|
||||||
List<Task> loadTasks = new ArrayList<>();
|
List<Task> loadTasks = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < searchResults.size(); i++) {
|
for (int i = 0; i < searchResults.size(); i++) {
|
||||||
AudioTrack track = searchResults.get(i);
|
Track track = searchResults.get(i);
|
||||||
loadTasks.add(new LoadTrackFromPlaylistTask(this, String.valueOf(i + 1), track, musicManager));
|
loadTasks.add(new LoadTrackFromPlaylistTask(this, String.valueOf(i + 1), track, musicManager));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +83,11 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AudioTrack> tracks = playlist.getTracks();
|
List<Track> tracks = playlistTracks;
|
||||||
if (tracks.size() > maxPlaylistSizeBeforeAsking) {
|
if (tracks.size() > maxPlaylistSizeBeforeAsking) {
|
||||||
ActionRow firstRow = commonButtons(playlist.getTracks(), "I'm ready", "Please don't");
|
ActionRow firstRow = commonButtons(tracks, "I'm ready", "Please don't");
|
||||||
|
|
||||||
context.edit(MessageFunction.buildPlaylistLoadMessage(playlist, context.getGuildUser()), List.of(firstRow));
|
context.edit(MessageFunction.buildPlaylistLoadMessage(playlistName, tracks.size(), context.getGuildUser()), List.of(firstRow));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,17 +95,17 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
|
|||||||
if (MusicUtils.connect(context, musicManager)) {
|
if (MusicUtils.connect(context, musicManager)) {
|
||||||
musicManager.getQueueManager().queue(tracks, context.getGuildUser());
|
musicManager.getQueueManager().queue(tracks, context.getGuildUser());
|
||||||
|
|
||||||
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), context.getGuildUser(), sourceType));
|
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, context.getGuildUser(), sourceType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActionRow commonButtons(List<AudioTrack> tracks, String addAllText, String cancelText) {
|
private ActionRow commonButtons(List<Track> tracks, String addAllText, String cancelText) {
|
||||||
Task accept = AcceptButton.createSuccessButton(taskManager(), this, addAllText, context -> {
|
Task accept = AcceptButton.createSuccessButton(taskManager(), this, addAllText, context -> {
|
||||||
Member member = context.getGuildUser();
|
Member member = context.getGuildUser();
|
||||||
|
|
||||||
if (MusicUtils.connect(context, musicManager)) {
|
if (MusicUtils.connect(context, musicManager)) {
|
||||||
musicManager.getQueueManager().queue(tracks, member);
|
musicManager.getQueueManager().queue(tracks, member);
|
||||||
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), member, SourceFinder.AudioSourceType.Youtube));
|
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, member, SourceFinder.AudioSourceType.Youtube));
|
||||||
|
|
||||||
return Lifecycle.REMOVE_SELF_AND_CHILDREN;
|
return Lifecycle.REMOVE_SELF_AND_CHILDREN;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.tasks.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import me.bobert.netneutrality.music.MusicManager;
|
import me.bobert.netneutrality.music.MusicManager;
|
||||||
import me.bobert.netneutrality.music.SourceFinder;
|
import me.bobert.netneutrality.music.SourceFinder;
|
||||||
import me.bobert.netneutrality.core.tasks.generic.AcceptButton;
|
import me.bobert.netneutrality.core.tasks.generic.AcceptButton;
|
||||||
@@ -15,9 +15,9 @@ import net.dv8tion.jda.api.components.buttons.ButtonStyle;
|
|||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
|
|
||||||
public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
|
public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
|
||||||
private final AudioTrack track;
|
private final Track track;
|
||||||
|
|
||||||
public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, AudioTrack track, MusicManager musicManager) {
|
public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, Track track, MusicManager musicManager) {
|
||||||
super(musicManager.getTaskManager(), parentTask, ButtonStyle.PRIMARY, buttonLabel, context -> {
|
super(musicManager.getTaskManager(), parentTask, ButtonStyle.PRIMARY, buttonLabel, context -> {
|
||||||
Member member = context.getGuildUser();
|
Member member = context.getGuildUser();
|
||||||
|
|
||||||
@@ -36,6 +36,6 @@ public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return String.format("load-track-%s", track.getIdentifier());
|
return String.format("load-track-%s", track.getInfo().getIdentifier());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,7 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
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.core.command.CommandExecutionResult;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
@@ -21,22 +19,20 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
public class QueueManagerTest {
|
public class QueueManagerTest {
|
||||||
|
|
||||||
private AudioPlayer player;
|
private TrackScheduler scheduler;
|
||||||
private MusicManager musicManager;
|
private MusicManager musicManager;
|
||||||
private MusicManagerCallback callback;
|
private MusicManagerCallback callback;
|
||||||
private QueueManager queueManager;
|
private QueueManager queueManager;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
player = mock(AudioPlayer.class);
|
|
||||||
Guild guild = mock(Guild.class);
|
Guild guild = mock(Guild.class);
|
||||||
when(guild.getId()).thenReturn("guild1");
|
when(guild.getId()).thenReturn("guild1");
|
||||||
|
|
||||||
TrackScheduler scheduler = mock(TrackScheduler.class);
|
scheduler = mock(TrackScheduler.class);
|
||||||
musicManager = mock(MusicManager.class);
|
musicManager = mock(MusicManager.class);
|
||||||
callback = mock(MusicManagerCallback.class);
|
callback = mock(MusicManagerCallback.class);
|
||||||
|
|
||||||
when(scheduler.getPlayer()).thenReturn(player);
|
|
||||||
when(scheduler.getGuild()).thenReturn(guild);
|
when(scheduler.getGuild()).thenReturn(guild);
|
||||||
when(scheduler.getMusicManager()).thenReturn(musicManager);
|
when(scheduler.getMusicManager()).thenReturn(musicManager);
|
||||||
when(scheduler.getCallback()).thenReturn(callback);
|
when(scheduler.getCallback()).thenReturn(callback);
|
||||||
@@ -44,11 +40,14 @@ public class QueueManagerTest {
|
|||||||
queueManager = new QueueManager(scheduler);
|
queueManager = new QueueManager(scheduler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AudioTrack mockTrack(String id) {
|
static Track mockTrack(String id) {
|
||||||
AudioTrack track = mock(AudioTrack.class);
|
Track track = mock(Track.class);
|
||||||
when(track.getIdentifier()).thenReturn(id);
|
dev.arbjerg.lavalink.protocol.v4.TrackInfo info = mock(dev.arbjerg.lavalink.protocol.v4.TrackInfo.class);
|
||||||
AudioTrackInfo info = new AudioTrackInfo("Title " + id, "Author", 1000L, id, false, "http://example.com/" + id);
|
when(info.getIdentifier()).thenReturn(id);
|
||||||
|
when(info.getTitle()).thenReturn("Title " + id);
|
||||||
|
when(info.getLength()).thenReturn(1000L);
|
||||||
when(track.getInfo()).thenReturn(info);
|
when(track.getInfo()).thenReturn(info);
|
||||||
|
when(track.getEncoded()).thenReturn("encoded-" + id);
|
||||||
when(track.makeClone()).thenReturn(track);
|
when(track.makeClone()).thenReturn(track);
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
@@ -64,23 +63,24 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void queueAddsToTailAndStartsPlaybackWhenEmpty() {
|
public void queueAddsToTailAndStartsPlaybackWhenEmpty() {
|
||||||
AudioTrack track = mockTrack("t1");
|
Track track = mockTrack("t1");
|
||||||
Member member = mockMember("u1");
|
Member member = mockMember("u1");
|
||||||
when(player.getPlayingTrack()).thenReturn(null);
|
when(scheduler.getCurrentTrack()).thenReturn(null);
|
||||||
|
|
||||||
queueManager.queue(track, member);
|
queueManager.queue(track, member);
|
||||||
|
|
||||||
assertEquals(1, queueManager.getQueueSize());
|
assertEquals(1, queueManager.getQueueSize());
|
||||||
verify(player).startTrack(track, true);
|
verify(scheduler).setCurrentTrack(track);
|
||||||
|
verify(musicManager).updatePlayer(contains("t1"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void queueAddsToTailWhenAlreadyPlaying() {
|
public void queueAddsToTailWhenAlreadyPlaying() {
|
||||||
AudioTrack t1 = mockTrack("t1");
|
Track t1 = mockTrack("t1");
|
||||||
AudioTrack t2 = mockTrack("t2");
|
Track t2 = mockTrack("t2");
|
||||||
Member member = mockMember("u1");
|
Member member = mockMember("u1");
|
||||||
|
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||||
|
|
||||||
queueManager.queue(t1, member);
|
queueManager.queue(t1, member);
|
||||||
queueManager.queue(t2, member);
|
queueManager.queue(t2, member);
|
||||||
@@ -93,31 +93,28 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void queueAddsToFrontWhenNothingPlayingAndQueueNotEmpty() {
|
public void queueAddsToFrontWhenNothingPlayingAndQueueNotEmpty() {
|
||||||
AudioTrack t1 = mockTrack("t1");
|
Track t1 = mockTrack("t1");
|
||||||
AudioTrack t2 = mockTrack("t2");
|
Track t2 = mockTrack("t2");
|
||||||
Member member = mockMember("u1");
|
Member member = mockMember("u1");
|
||||||
|
|
||||||
// First queue: nothing playing, queue empty -> add to tail, start track
|
// Nothing playing on both calls: the second track jumps to the head so that it is the
|
||||||
when(player.getPlayingTrack()).thenReturn(null);
|
// one actually started.
|
||||||
queueManager.queue(t1, member);
|
when(scheduler.getCurrentTrack()).thenReturn(null);
|
||||||
|
|
||||||
// Second queue: nothing playing (startTrack with noInterrupt=true didn't actually start),
|
queueManager.queue(t1, member);
|
||||||
// queue not empty -> should addFirst so t2 is at head and gets played
|
|
||||||
when(player.getPlayingTrack()).thenReturn(null);
|
|
||||||
queueManager.queue(t2, member);
|
queueManager.queue(t2, member);
|
||||||
|
|
||||||
assertEquals(2, queueManager.getQueueSize());
|
assertEquals(2, queueManager.getQueueSize());
|
||||||
List<TrackInfo> q = queueManager.getQueue();
|
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(t2, q.get(0).getTrack());
|
||||||
assertSame(t1, q.get(1).getTrack());
|
assertSame(t1, q.get(1).getTrack());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void queueRejectsDuplicate() {
|
public void queueRejectsDuplicate() {
|
||||||
AudioTrack track = mockTrack("t1");
|
Track track = mockTrack("t1");
|
||||||
Member member = mockMember("u1");
|
Member member = mockMember("u1");
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(track);
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(track);
|
||||||
|
|
||||||
queueManager.queue(track, member);
|
queueManager.queue(track, member);
|
||||||
queueManager.queue(track, member);
|
queueManager.queue(track, member);
|
||||||
@@ -136,7 +133,7 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void clearQueueRemovesAllAndCallsCallback() {
|
public void clearQueueRemovesAllAndCallsCallback() {
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||||
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||||
|
|
||||||
@@ -158,10 +155,10 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void skipSingleTrack() {
|
public void skipSingleTrack() {
|
||||||
AudioTrack t1 = mockTrack("t1");
|
Track t1 = mockTrack("t1");
|
||||||
AudioTrack t2 = mockTrack("t2");
|
Track t2 = mockTrack("t2");
|
||||||
Member member = mockMember("u1");
|
Member member = mockMember("u1");
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||||
|
|
||||||
queueManager.queue(t1, member);
|
queueManager.queue(t1, member);
|
||||||
queueManager.queue(t2, member);
|
queueManager.queue(t2, member);
|
||||||
@@ -174,18 +171,16 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void skipMoreThanQueueSizeSkipsAll() {
|
public void skipMoreThanQueueSizeSkipsAll() {
|
||||||
AudioTrack t1 = mockTrack("t1");
|
Track t1 = mockTrack("t1");
|
||||||
AudioTrack t2 = mockTrack("t2");
|
Track t2 = mockTrack("t2");
|
||||||
Member member = mockMember("u1");
|
Member member = mockMember("u1");
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||||
|
|
||||||
queueManager.queue(t1, member);
|
queueManager.queue(t1, member);
|
||||||
queueManager.queue(t2, member);
|
queueManager.queue(t2, member);
|
||||||
|
|
||||||
// skip(10) should clamp to queue.size() = 2, removing all
|
// skip(10) should clamp to queue.size() = 2, removing all
|
||||||
CommandExecutionResult result = queueManager.skip(10);
|
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());
|
assertTrue(queueManager.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,8 +188,8 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shuffleTooSmallQueueReturnsWarn() {
|
public void shuffleTooSmallQueueReturnsWarn() {
|
||||||
AudioTrack t1 = mockTrack("t1");
|
Track t1 = mockTrack("t1");
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||||
queueManager.queue(t1, mockMember("u1"));
|
queueManager.queue(t1, mockMember("u1"));
|
||||||
|
|
||||||
CommandExecutionResult result = queueManager.shuffle();
|
CommandExecutionResult result = queueManager.shuffle();
|
||||||
@@ -204,10 +199,10 @@ public class QueueManagerTest {
|
|||||||
@Test
|
@Test
|
||||||
public void shufflePreservesCurrentTrackAtHead() {
|
public void shufflePreservesCurrentTrackAtHead() {
|
||||||
Member member = mockMember("u1");
|
Member member = mockMember("u1");
|
||||||
AudioTrack playing = mockTrack("t1");
|
Track playing = mockTrack("t1");
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(playing).thenReturn(playing).thenReturn(playing).thenReturn(playing);
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(playing);
|
||||||
|
|
||||||
queueManager.queue(mockTrack("t1"), member);
|
queueManager.queue(playing, member);
|
||||||
queueManager.queue(mockTrack("t2"), member);
|
queueManager.queue(mockTrack("t2"), member);
|
||||||
queueManager.queue(mockTrack("t3"), member);
|
queueManager.queue(mockTrack("t3"), member);
|
||||||
queueManager.queue(mockTrack("t4"), member);
|
queueManager.queue(mockTrack("t4"), member);
|
||||||
@@ -218,7 +213,7 @@ public class QueueManagerTest {
|
|||||||
// First track should still be t1
|
// First track should still be t1
|
||||||
List<TrackInfo> q = queueManager.getQueue();
|
List<TrackInfo> q = queueManager.getQueue();
|
||||||
assertEquals(4, q.size());
|
assertEquals(4, q.size());
|
||||||
assertEquals("t1", q.get(0).getTrack().getIdentifier());
|
assertEquals("t1", q.get(0).getTrack().getInfo().getIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RemoveFromQueue ---
|
// --- RemoveFromQueue ---
|
||||||
@@ -231,7 +226,7 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void removeFromQueueOutOfBoundsReturnsError() {
|
public void removeFromQueueOutOfBoundsReturnsError() {
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||||
|
|
||||||
CommandExecutionResult result = queueManager.removeFromQueue(5);
|
CommandExecutionResult result = queueManager.removeFromQueue(5);
|
||||||
@@ -240,7 +235,7 @@ public class QueueManagerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void removeFromQueueIndexZeroReturnsNone() {
|
public void removeFromQueueIndexZeroReturnsNone() {
|
||||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||||
|
|
||||||
CommandExecutionResult result = queueManager.removeFromQueue(0);
|
CommandExecutionResult result = queueManager.removeFromQueue(0);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -18,13 +18,13 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
public class TrackInfoTest {
|
public class TrackInfoTest {
|
||||||
|
|
||||||
private AudioTrack track;
|
private Track track;
|
||||||
private Member member;
|
private Member member;
|
||||||
private TrackInfo trackInfo;
|
private TrackInfo trackInfo;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
track = mock(AudioTrack.class);
|
track = QueueManagerTest.mockTrack("t1");
|
||||||
member = mock(Member.class);
|
member = mock(Member.class);
|
||||||
when(member.getId()).thenReturn("user1");
|
when(member.getId()).thenReturn("user1");
|
||||||
when(member.getEffectiveName()).thenReturn("User1");
|
when(member.getEffectiveName()).thenReturn("User1");
|
||||||
@@ -101,7 +101,7 @@ public class TrackInfoTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void notEqualWithDifferentTrack() {
|
public void notEqualWithDifferentTrack() {
|
||||||
AudioTrack otherTrack = mock(AudioTrack.class);
|
Track otherTrack = QueueManagerTest.mockTrack("t2");
|
||||||
TrackInfo other = new TrackInfo(otherTrack, member);
|
TrackInfo other = new TrackInfo(otherTrack, member);
|
||||||
assertNotEquals(trackInfo, other);
|
assertNotEquals(trackInfo, other);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,27 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
import dev.arbjerg.lavalink.client.player.Track;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
|
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
public class TrackSchedulerTest {
|
public class TrackSchedulerTest {
|
||||||
|
|
||||||
private AudioPlayer player;
|
private MusicManager musicManager;
|
||||||
private TrackScheduler scheduler;
|
private TrackScheduler scheduler;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
player = mock(AudioPlayer.class);
|
|
||||||
Guild guild = mock(Guild.class);
|
Guild guild = mock(Guild.class);
|
||||||
when(guild.getId()).thenReturn("guild1");
|
when(guild.getId()).thenReturn("guild1");
|
||||||
|
|
||||||
MusicManager musicManager = mock(MusicManager.class);
|
musicManager = mock(MusicManager.class);
|
||||||
when(musicManager.getPlayer()).thenReturn(player);
|
|
||||||
when(musicManager.getGuild()).thenReturn(guild);
|
when(musicManager.getGuild()).thenReturn(guild);
|
||||||
|
|
||||||
scheduler = new TrackScheduler(musicManager);
|
scheduler = new TrackScheduler(musicManager);
|
||||||
@@ -34,45 +33,51 @@ public class TrackSchedulerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void onTrackEndFinishedStartsNextTrack() {
|
public void onTrackEndFinishedStartsNextTrack() {
|
||||||
AudioTrack track = mock(AudioTrack.class);
|
Track track = QueueManagerTest.mockTrack("t1");
|
||||||
when(track.getIdentifier()).thenReturn("t1");
|
|
||||||
|
|
||||||
// With empty queue, startNextTrack will just stopTrack
|
// With empty queue, startNextTrack will just stop playback on the node
|
||||||
scheduler.onTrackEnd(player, track, AudioTrackEndReason.FINISHED);
|
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.FINISHED);
|
||||||
verify(player).stopTrack();
|
verify(musicManager).stopTrack();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void onTrackEndLoadFailedStartsNextTrack() {
|
public void onTrackEndLoadFailedStartsNextTrack() {
|
||||||
AudioTrack track = mock(AudioTrack.class);
|
Track track = QueueManagerTest.mockTrack("t1");
|
||||||
when(track.getIdentifier()).thenReturn("t1");
|
|
||||||
|
|
||||||
scheduler.onTrackEnd(player, track, AudioTrackEndReason.LOAD_FAILED);
|
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.LOAD_FAILED);
|
||||||
verify(player).stopTrack();
|
verify(musicManager).stopTrack();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void onTrackEndReplacedDoesNotStartNext() {
|
public void onTrackEndReplacedDoesNotStartNext() {
|
||||||
AudioTrack track = mock(AudioTrack.class);
|
Track track = QueueManagerTest.mockTrack("t1");
|
||||||
when(track.getIdentifier()).thenReturn("t1");
|
|
||||||
|
|
||||||
scheduler.onTrackEnd(player, track, AudioTrackEndReason.REPLACED);
|
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.REPLACED);
|
||||||
verify(player, never()).stopTrack();
|
verify(musicManager, never()).stopTrack();
|
||||||
verify(player, never()).startTrack(any(), anyBoolean());
|
verify(musicManager, never()).updatePlayer(anyString(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void onTrackStartResetsStuckHandler() {
|
public void onTrackStartCachesCurrentTrack() {
|
||||||
AudioTrack track = mock(AudioTrack.class);
|
Track track = QueueManagerTest.mockTrack("t1");
|
||||||
when(track.getIdentifier()).thenReturn("t1");
|
|
||||||
|
|
||||||
// After onTrackStart, the stuck handler should be reset
|
scheduler.onTrackStart(track);
|
||||||
scheduler.onTrackStart(player, track);
|
|
||||||
// No exception means reset was called successfully
|
assertSame(track, scheduler.getCurrentTrack());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onTrackEndClearsCurrentTrack() {
|
||||||
|
Track track = QueueManagerTest.mockTrack("t1");
|
||||||
|
scheduler.onTrackStart(track);
|
||||||
|
|
||||||
|
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.STOPPED);
|
||||||
|
|
||||||
|
assertNull(scheduler.getCurrentTrack());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void getDjReturnsNullWhenNoContext() {
|
public void getDjReturnsNullWhenNoContext() {
|
||||||
assert scheduler.getDj() == null;
|
assertNull(scheduler.getDj());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.music;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* https://discord.gg/qUa6zNFZsd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
|
||||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
@@ -39,7 +38,7 @@ public class VoteSkipServiceTest {
|
|||||||
when(guild.getId()).thenReturn("guild1");
|
when(guild.getId()).thenReturn("guild1");
|
||||||
when(context.getGuild()).thenReturn(guild);
|
when(context.getGuild()).thenReturn(guild);
|
||||||
|
|
||||||
AudioTrack track = mock(AudioTrack.class);
|
dev.arbjerg.lavalink.client.player.Track track = QueueManagerTest.mockTrack("t1");
|
||||||
Member requester = mock(Member.class);
|
Member requester = mock(Member.class);
|
||||||
when(requester.getId()).thenReturn("requester");
|
when(requester.getId()).thenReturn("requester");
|
||||||
trackInfo = new TrackInfo(track, requester);
|
trackInfo = new TrackInfo(track, requester);
|
||||||
|
|||||||
Reference in new issue
Block a user