From 4cf7c476454ca5112d3e8122dbe8d86dac3dae73 Mon Sep 17 00:00:00 2001 From: anthorob Date: Wed, 23 Sep 2026 15:08:06 -0400 Subject: [PATCH] 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. --- .env.example | 11 +- .junie/plans/migrate-music-to-lavalink.md | 249 ++++++++++++++++++ bot.properties.example | 4 + docker-compose.yml | 52 +++- docs/README.md | 2 +- docs/architecture.md | 21 +- docs/music.md | 153 ++++++++--- docs/reference/udio-lavalink-plugin/README.md | 43 +++ .../UdioAudioSourceManager.java | 0 .../UdioAudioSourceManagerTest.java | 0 .../udio-lavalink-plugin}/UdioAudioTrack.java | 0 docs/threading.md | 3 +- docs/utilities-and-bootstrap.md | 3 +- lavalink/application.yml | 65 +++++ pom.xml | 30 +-- .../java/me/bobert/netneutrality/App.java | 14 +- .../music/AudioPlayerSendHandler.java | 45 ---- .../netneutrality/music/MusicManager.java | 192 ++++++++++---- .../netneutrality/music/MusicService.java | 14 +- .../netneutrality/music/MusicServiceImpl.java | 199 +++++++++++--- .../netneutrality/music/QueueManager.java | 58 ++-- .../netneutrality/music/SourceFinder.java | 4 +- .../music/StuckTrackRecoveryHandler.java | 45 ++-- .../bobert/netneutrality/music/TrackInfo.java | 19 +- .../music/TrackLoadingHandler.java | 55 ++-- .../netneutrality/music/TrackScheduler.java | 96 ++++--- .../music/MusicPlayerAutoDisconnect.java | 4 +- .../shared/util/MessageFunction.java | 84 +++--- .../tasks/music/LoadPlaylistTask.java | 34 +-- .../music/LoadTrackFromPlaylistTask.java | 8 +- .../netneutrality/music/QueueManagerTest.java | 87 +++--- .../netneutrality/music/TrackInfoTest.java | 8 +- .../music/TrackSchedulerTest.java | 61 +++-- .../music/VoteSkipServiceTest.java | 3 +- 34 files changed, 1187 insertions(+), 479 deletions(-) create mode 100644 .junie/plans/migrate-music-to-lavalink.md create mode 100644 docs/reference/udio-lavalink-plugin/README.md rename {src/main/java/me/bobert/netneutrality/music/source => docs/reference/udio-lavalink-plugin}/UdioAudioSourceManager.java (100%) rename {src/test/java/me/bobert/netneutrality/music/source => docs/reference/udio-lavalink-plugin}/UdioAudioSourceManagerTest.java (100%) rename {src/main/java/me/bobert/netneutrality/music/source => docs/reference/udio-lavalink-plugin}/UdioAudioTrack.java (100%) create mode 100644 lavalink/application.yml delete mode 100644 src/main/java/me/bobert/netneutrality/music/AudioPlayerSendHandler.java diff --git a/.env.example b/.env.example index 184ef84..054b8e8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ NETNEUTRALITY_TAG=latest - NETNEUTRALITY_BOT_ENV=dev NETNEUTRALITY_BOT_TOKEN= NETNEUTRALITY_BOT_PREFIX=$ @@ -12,13 +11,17 @@ MYSQL_PORT=3306 MYSQL_USER=java MYSQL_PASSWORD=password 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_VISITOR_DATA= -NETNEUTRALITY_CIPHER_URL=http://localhost:8001 +NETNEUTRALITY_OAUTH_TOKEN= +NETNEUTRALITY_CIPHER_URL=http://ejs-api:8001 NETNEUTRALITY_CIPHER_PASSWORD= +NETNEUTRALITY_WEBPO_URL=http://webpo:8080 +NETNEUTRALITY_WEBPO_PASSWORD= NETNEUTRALITY_SENTRY_DSN= GITLAB_HOST= diff --git a/.junie/plans/migrate-music-to-lavalink.md b/.junie/plans/migrate-music-to-lavalink.md new file mode 100644 index 0000000..c02164c --- /dev/null +++ b/.junie/plans/migrate-music-to-lavalink.md @@ -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` `` / ``); 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` 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 + + dev.arbjerg + lavalink-client + 3.4.0 + +``` +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. \ No newline at end of file diff --git a/bot.properties.example b/bot.properties.example index 184ef84..075412f 100644 --- a/bot.properties.example +++ b/bot.properties.example @@ -20,6 +20,10 @@ NETNEUTRALITY_VISITOR_DATA= NETNEUTRALITY_CIPHER_URL=http://localhost:8001 NETNEUTRALITY_CIPHER_PASSWORD= +NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333 +NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass +NETNEUTRALITY_LAVALINK_NAME=main + NETNEUTRALITY_SENTRY_DSN= GITLAB_HOST= GITLAB_TOKEN= diff --git a/docker-compose.yml b/docker-compose.yml index 8462c37..a1b8c1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,14 +23,39 @@ services: - netneutrality depends_on: - netneutrality-db + - lavalink volumes: - ./logs:/app/logs environment: - MYSQL_HOST: netneutrality-db - MYSQL_PORT: 3306 - MYSQL_USER: java - MYSQL_PASSWORD: password - MYSQL_DATABASE: netneutrality + - MYSQL_HOST=${MYSQL_HOST:-netneutrality-db} + - MYSQL_PORT=${MYSQL_PORT:-3306} + - MYSQL_USER=${MYSQL_USER:-java} + - MYSQL_PASSWORD=${MYSQL_PASSWORD:-password} + - 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: image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449 container_name: ejs_signature_api @@ -39,8 +64,25 @@ services: - netneutrality # environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD # - 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: netneutrality-db: null + netneutrality-lavalink-plugins: null networks: netneutrality: null diff --git a/docs/README.md b/docs/README.md index e531917..8358252 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | | [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 | -| [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 | | [threading.md](threading.md) | Threading & scheduling infrastructure | | [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions | diff --git a/docs/architecture.md b/docs/architecture.md index 4cc6dcf..9f20731 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ execution paths: directly and no context is routed onward. See [events.md](events.md). 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. ## Technology stack @@ -45,9 +45,8 @@ infrastructure. | Concern | Library | Version | |---|---|---| | Discord API | `net.dv8tion:JDA` | 6.4.1 | -| Voice / DAVE (E2EE) | `club.minnced:jdave-*` | 0.1.8 | -| Audio engine | `dev.arbjerg:lavaplayer` | 2.2.6 | -| YouTube source | `dev.lavalink.youtube:v2` | 1.18.2 | +| Audio engine | Lavalink v4 node (`ghcr.io/lavalink-devs/lavalink`) | 4.2 | +| Audio client | `dev.arbjerg:lavalink-client` | 3.4.0 | | ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final | | Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final | | 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). 2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK (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()`: 1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing). 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`. | | 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. | -| 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). | | 60 | JDA adapter | Build `JDA` with gateway intents and await ready. | | 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 `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 -`AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())`. +`MESSAGE_CONTENT`. It also installs `new JDAVoiceUpdateListener(music.getClient())` as the voice +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 @@ -120,7 +121,7 @@ block the others. Effectively this means: 2. Post-JDA adapter — no-op. 3. JDA adapter — `jda.shutdown()`. 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. 7. `ThreadManager.stop()` — shut down the shared executors. 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 | | `…hooks` | `EventsHandler` | | `…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` | | `…shared.util` | Cross-cutting helpers | | `…exceptions` | Custom exception hierarchy | diff --git a/docs/music.md b/docs/music.md index f0d064f..ba2c258 100644 --- a/docs/music.md +++ b/docs/music.md @@ -1,35 +1,51 @@ # Music -**Package:** `me.bobert.netneutrality.feature.music` -**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube -source 1.18.2. +**Package:** `me.bobert.netneutrality.music` +**Engine:** a remote [Lavalink](https://lavalink.dev) v4 node driven by +[`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 -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 | 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`); enqueue/skip/shuffle. | -| `TrackScheduler` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). | -| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. | -| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). | -| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). | -| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. | -| `source/UdioAudioSourceManager` | Custom Lavaplayer source manager for udio.com. | -| `source/UdioAudioTrack` | Udio track; delegates playback to `Mp3AudioTrack`. | +| `TrackScheduler` | Event sink for `onTrackStart` / `onTrackEnd` / `onTrackException` / `onTrackStuck`; caches the current track. | +| `TrackLoadingHandler` | `AbstractAudioLoadResultHandler`; handles track/playlist/search/no-match/failure results. | +| `TrackInfo` | Value object pairing a Lavalink `Track` with its requester (`Member`). | +| `SourceFinder` | Identifies the source/type of a query (YouTube, SoundCloud, HTTP, …). | +| `StuckTrackRecoveryHandler` | Retry-then-skip logic for tracks the node reports as stuck. | -`MusicManager` keeps a static `Map` of managers keyed by guild id, so each guild -has its own player, scheduler, and queue. +`MusicServiceImpl` keeps a `Map` of managers keyed by guild id, so each guild +has its own link, scheduler, and queue. ## Startup -`MusicManager.initPlayerManager()` (called from -[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer -`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom -[Udio source](#custom-udio-source), and HTTP). +`MusicServiceImpl` is a priority-40 [`Lifecycle`](architecture.md#startup-lifecycle) component. Its +constructor creates the `LavalinkClient` (the bot user id is derived from the token) and `start()` +registers the configured node. **A node that is unreachable never prevents the bot from booting**: +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) @@ -38,50 +54,111 @@ has its own player, scheduler, and queue. │ ▼ 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) - ├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons) + ├─ ontrackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member) + ├─ onPlaylistLoaded / onSearchResultLoaded ──▶ install a LoadPlaylistTask (choice buttons) ├─ noMatches ──▶ reply "nothing found" └─ 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) -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 - now-playing/queue displays and vote-skip. -- **Playlists / search** — playlist and multi-result loads produce a + now-playing/queue displays and vote-skip. Duplicate rejection compares the encoded track payload. +- **Playlists / search** — playlist and search loads produce a [`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons (`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist 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 [`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured music channel. -## Custom Udio source +## Running the node -`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist, -duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the -page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the -`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's -`Mp3AudioTrack`. +`docker-compose.yml` defines a `lavalink` service (image `ghcr.io/lavalink-devs/lavalink:4.2`, the +minimum version that handles DAVE end-to-end encrypted voice) on the `netneutrality` network, with +`lavalink/application.yml` mounted read-only at `/opt/Lavalink/application.yml`. The bot depends on +it and the node depends on the existing `ejs-api` (yt-cipher) container. + +```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 -- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the player has been idle. -- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the - static map. -- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice +- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the channel is empty or the queue + has run out; the connected channel is read from the bot's gateway voice state. +- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances. +- 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)). ## Related commands diff --git a/docs/reference/udio-lavalink-plugin/README.md b/docs/reference/udio-lavalink-plugin/README.md new file mode 100644 index 0000000..d0577df --- /dev/null +++ b/docs/reference/udio-lavalink-plugin/README.md @@ -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: ":udio-plugin:" +``` + +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. diff --git a/src/main/java/me/bobert/netneutrality/music/source/UdioAudioSourceManager.java b/docs/reference/udio-lavalink-plugin/UdioAudioSourceManager.java similarity index 100% rename from src/main/java/me/bobert/netneutrality/music/source/UdioAudioSourceManager.java rename to docs/reference/udio-lavalink-plugin/UdioAudioSourceManager.java diff --git a/src/test/java/me/bobert/netneutrality/music/source/UdioAudioSourceManagerTest.java b/docs/reference/udio-lavalink-plugin/UdioAudioSourceManagerTest.java similarity index 100% rename from src/test/java/me/bobert/netneutrality/music/source/UdioAudioSourceManagerTest.java rename to docs/reference/udio-lavalink-plugin/UdioAudioSourceManagerTest.java diff --git a/src/main/java/me/bobert/netneutrality/music/source/UdioAudioTrack.java b/docs/reference/udio-lavalink-plugin/UdioAudioTrack.java similarity index 100% rename from src/main/java/me/bobert/netneutrality/music/source/UdioAudioTrack.java rename to docs/reference/udio-lavalink-plugin/UdioAudioTrack.java diff --git a/docs/threading.md b/docs/threading.md index 1b7b1d0..f7532c5 100644 --- a/docs/threading.md +++ b/docs/threading.md @@ -37,7 +37,8 @@ startup by [`TaskManager.init()`](tasks.md#taskmanager)): - `cancelAll()` — cancels every scheduled task; called first in [`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)). > **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc diff --git a/docs/utilities-and-bootstrap.md b/docs/utilities-and-bootstrap.md index 8c61f9f..67170a0 100644 --- a/docs/utilities-and-bootstrap.md +++ b/docs/utilities-and-bootstrap.md @@ -86,7 +86,8 @@ An example configuration file is `bot.properties.example`. Key configuration val | `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). | | `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)). | -| `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 diff --git a/lavalink/application.yml b/lavalink/application.yml new file mode 100644 index 0000000..e1de73f --- /dev/null +++ b/lavalink/application.yml @@ -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 diff --git a/pom.xml b/pom.xml index 1270255..f66355e 100644 --- a/pom.xml +++ b/pom.xml @@ -146,35 +146,11 @@ JDA 6.4.1 + dev.arbjerg - lavaplayer - 2.2.7 - - - dev.lavalink.youtube - v2 - 1.18.2 - - - club.minnced - jdave-api - 0.1.8 - - - club.minnced - jdave-native-linux-x86-64 - 0.1.8 - - - club.minnced - jdave-native-win-x86-64 - 0.1.8 - - - club.minnced - jdave-native-darwin - 0.1.8 + lavalink-client + 3.4.0 diff --git a/src/main/java/me/bobert/netneutrality/App.java b/src/main/java/me/bobert/netneutrality/App.java index 10af23b..6cbd25a 100644 --- a/src/main/java/me/bobert/netneutrality/App.java +++ b/src/main/java/me/bobert/netneutrality/App.java @@ -6,7 +6,7 @@ package me.bobert.netneutrality; * https://discord.gg/qUa6zNFZsd */ -import club.minnced.discord.jdave.interop.JDaveSessionFactory; +import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener; import io.sentry.Sentry; import me.bobert.netneutrality.bootstrap.ApplicationContext; import me.bobert.netneutrality.bootstrap.Lifecycle; @@ -33,7 +33,6 @@ import me.bobert.netneutrality.utils.Settings; import me.bobert.netneutrality.http.WebModule; import net.dv8tion.jda.api.JDA; 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.exceptions.InvalidTokenException; import net.dv8tion.jda.api.requests.GatewayIntent; @@ -47,6 +46,7 @@ public class App { private static App instance; private final Settings settings; private JDA jda; + private MusicService music; private final ApplicationContext context = new ApplicationContext(); static void main() { @@ -71,8 +71,7 @@ public class App { if ("dev".equals(env)) { Configurator.setLevel("net.dv8tion.jda", Level.DEBUG); - Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG); - Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG); + Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG); } instance = new App(settings); @@ -137,7 +136,7 @@ public class App { JpaUtils.bindGuildServiceLookup(guildServices::get); // 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); // JDA gateway; disconnected on shutdown. @@ -200,8 +199,9 @@ public class App { GatewayIntent.GUILD_MESSAGE_REACTIONS, GatewayIntent.GUILD_VOICE_STATES, GatewayIntent.MESSAGE_CONTENT) - .setAudioModuleConfig( - new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())) + // Voice state/server updates are handed to Lavalink instead of JDA's audio stack; + // DAVE encryption is handled by the node. + .setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient())) .setActivity(Activity.watching("me load...")) .build(); jda.awaitReady(); diff --git a/src/main/java/me/bobert/netneutrality/music/AudioPlayerSendHandler.java b/src/main/java/me/bobert/netneutrality/music/AudioPlayerSendHandler.java deleted file mode 100644 index 9307f62..0000000 --- a/src/main/java/me/bobert/netneutrality/music/AudioPlayerSendHandler.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/me/bobert/netneutrality/music/MusicManager.java b/src/main/java/me/bobert/netneutrality/music/MusicManager.java index 6b83f1d..424dcca 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicManager.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicManager.java @@ -6,51 +6,57 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.utils.MusicUtils; -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.arbjerg.lavalink.client.LavalinkClient; +import dev.arbjerg.lavalink.client.Link; +import dev.arbjerg.lavalink.client.player.LavalinkPlayer; +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.context.CommandContext; +import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.db.GSettings; 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.GuildVoiceState; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.function.Consumer; + /** * Per-guild music player and queue owner. * - *

The shared {@link AudioPlayerManager} and the manager cache live on {@link MusicServiceImpl}. + *

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

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 { private static final Logger logger = LogManager.getLogger(MusicManager.class); static final int DEFAULT_VOLUME = 50; private final Guild guild; - private final AudioPlayerManager playerManager; - private final AudioPlayer player; + private final LavalinkClient client; private final TrackScheduler scheduler; private final int baseVolume; private final GuildService guildService; private final TaskManager taskManager; 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.playerManager = playerManager; - this.player = playerManager.createPlayer(); + this.client = client; this.scheduler = new TrackScheduler(this); this.guildService = guildService; this.taskManager = taskManager; this.voteSkipService = new VoteSkipService(); this.baseVolume = parseBaseVolume(guildService); - - guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player)); - player.addListener(scheduler); } public GuildService getGuildService() { @@ -69,50 +75,101 @@ public class MusicManager implements MusicManagerCallback { return scheduler.getQueueManager(); } - public AudioPlayer getPlayer() { - return player; - } - public Guild getGuild() { 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. + * + *

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) { - AudioChannel current = guild.getAudioManager().getConnectedChannel(); + AudioChannel current = getConnectedChannel(); return vChannel == null || current == null || !vChannel.getId().equals(current.getId()); } public synchronized void connect(AudioChannel audioChannel) { - 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 { + if (audioChannel == 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() { - if (guild.getAudioManager().getConnectedChannel() != null) { - logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), guild.getAudioManager().getConnectedChannel().getId()); - player.stopTrack(); - guild.getAudioManager().closeAudioConnection(); + if (getConnectedChannel() != null) { + logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), getConnectedChannel().getId()); + stopTrack(); + guild.getJDA().getDirectAudioController().disconnect(guild); } 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() { - return player.getPlayingTrack(); + public synchronized boolean isConnected() { + 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() { - return player.getPlayingTrack() != null; + return scheduler.getCurrentTrack() != null; } 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) { logger.debug("Guild {}: request to play '{}' by user {}", guild.getId(), query, context.getGuildUser().getId()); - if (MusicUtils.canConnect(context, this)) { - SourceFinder.AudioSourceType type = SourceFinder.getSource(query); - String identifier = SourceFinder.getIdentifier(query); - if (identifier == null) { - context.reply("No source found to play the given url"); - return; - } - - TrackLoadingHandler loadingHandler = new TrackLoadingHandler(context, this, identifier, type, silent); - playerManager.loadItemOrdered(this, identifier, loadingHandler); + if (!MusicUtils.canConnect(context, this)) { + return; } + + 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); + if (identifier == null) { + context.reply("No source found to play the given url"); + return; + } + + Link link = getLink(); + 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() { - player.stopTrack(); + updatePlayer("stop track", builder -> builder.stopTrack()); } public synchronized CommandExecutionResult resume() { @@ -144,12 +221,11 @@ public class MusicManager implements MusicManagerCallback { } public synchronized CommandExecutionResult voteSkip(CommandContext context) { - AudioChannel audioChannel = guild.getAudioManager().getConnectedChannel(); - return voteSkipService.voteSkip(context, getQueueManager(), audioChannel, isCurrentlyPlaying()); + return voteSkipService.voteSkip(context, getQueueManager(), getConnectedChannel(), isCurrentlyPlaying()); } public void setVolume(int volume) { - player.setVolume(volume); + updatePlayer("set volume", builder -> builder.setVolume(volume)); } public int getQueueSize() { @@ -161,6 +237,26 @@ public class MusicManager implements MusicManagerCallback { 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 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) { try { return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME)); diff --git a/src/main/java/me/bobert/netneutrality/music/MusicService.java b/src/main/java/me/bobert/netneutrality/music/MusicService.java index 13cc403..3cdd702 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicService.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicService.java @@ -6,15 +6,16 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ +import dev.arbjerg.lavalink.client.LavalinkClient; import java.util.Collection; import me.bobert.netneutrality.bootstrap.Lifecycle; 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. * - *

As a {@link Lifecycle} component it registers audio sources on {@link #start()} and - * disconnects every player on {@link #stop()}. + *

As a {@link Lifecycle} component it registers the Lavalink node on {@link #start()} and + * destroys every player on {@link #stop()}. */ public interface MusicService extends Lifecycle { @@ -32,4 +33,11 @@ public interface MusicService extends Lifecycle { * @return the managers */ Collection getManagers(); + + /** + * Returns the shared Lavalink client used for voice dispatch interception and player control. + * + * @return the client + */ + LavalinkClient getClient(); } diff --git a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java index ebff57e..b402cec 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java @@ -6,41 +6,66 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager; -import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager; -import dev.lavalink.youtube.YoutubeAudioSourceManager; -import dev.lavalink.youtube.YoutubeSourceOptions; -import me.bobert.netneutrality.db.service.GuildServiceProvider; +import dev.arbjerg.lavalink.client.Helpers; +import dev.arbjerg.lavalink.client.LavalinkClient; +import dev.arbjerg.lavalink.client.LavalinkNode; +import dev.arbjerg.lavalink.client.NodeOptions; +import dev.arbjerg.lavalink.client.event.ReadyEvent; +import dev.arbjerg.lavalink.client.event.TrackEndEvent; +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.music.source.UdioAudioSourceManager; +import me.bobert.netneutrality.core.thread.ExecutorProvider; +import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.utils.SettingsProvider; import net.dv8tion.jda.api.entities.Guild; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import reactor.core.Disposable; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Map; 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. + * + *

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 { 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 GuildServiceProvider guildServices; private final TaskManager taskManager; + private final ExecutorProvider executors; private final Map managers = new ConcurrentHashMap<>(); - private final AudioPlayerManager playerManager = new DefaultAudioPlayerManager(); + private final List 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.guildServices = guildServices; this.taskManager = taskManager; + this.executors = executors; + this.client = new LavalinkClient(Helpers.getUserIdFromToken(settings.get("NETNEUTRALITY_BOT_TOKEN"))); } @Override @@ -50,41 +75,38 @@ public class MusicServiceImpl implements MusicService { @Override public void start() { - logger.debug("Initializing music player sources.."); - - String cipherUrl = settings.get("NETNEUTRALITY_CIPHER_URL"); - 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); + subscribeToEvents(); + registerNode(); + startReconnectWatchdog(); } @Override public void stop() { - for (MusicManager mm : managers.values()) { - mm.getQueueManager().clearQueue(); - mm.getPlayer().destroy(); + if (reconnectWatchdog != null) { + reconnectWatchdog.cancel(false); + 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 public MusicManager get(Guild guild) { - if (managers.containsKey(guild.getId())) { - return managers.get(guild.getId()); - } - MusicManager manager = new MusicManager(guild, playerManager, guildServices.get(guild), taskManager); - managers.put(guild.getId(), manager); - return manager; + return managers.computeIfAbsent( + guild.getId(), + id -> new MusicManager(guild, client, guildServices.get(guild), taskManager) + ); } @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() { - return playerManager; + @Override + 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. + * + *

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 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); + } } } diff --git a/src/main/java/me/bobert/netneutrality/music/QueueManager.java b/src/main/java/me/bobert/netneutrality/music/QueueManager.java index 88610a8..916b176 100644 --- a/src/main/java/me/bobert/netneutrality/music/QueueManager.java +++ b/src/main/java/me/bobert/netneutrality/music/QueueManager.java @@ -5,8 +5,7 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.arbjerg.lavalink.client.player.Track; import me.bobert.netneutrality.core.command.CommandExecutionResult; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; @@ -23,13 +22,11 @@ import java.util.function.Predicate; public class QueueManager { private final Logger logger = LogManager.getLogger(QueueManager.class); private final TrackScheduler scheduler; - private final AudioPlayer player; private final Guild guild; private final Deque queue; public QueueManager(TrackScheduler scheduler) { this.scheduler = scheduler; - this.player = scheduler.getPlayer(); this.guild = scheduler.getGuild(); this.queue = new LinkedBlockingDeque<>(); @@ -110,20 +107,20 @@ public class QueueManager { } } - public List getAllQueuedTracks() { + public List getAllQueuedTracks() { synchronized (queue) { - ArrayList tracks = new ArrayList<>(); + ArrayList tracks = new ArrayList<>(); queue.forEach(trackInfo -> tracks.add(trackInfo.getTrack())); return tracks; } } - public void queue(List tracks, Member member) { + public void queue(List tracks, Member 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); synchronized (queue) { @@ -131,42 +128,67 @@ public class QueueManager { return; } - if (player.getPlayingTrack() == null && !queue.isEmpty()) { + boolean idle = scheduler.getCurrentTrack() == null; + + if (idle && !queue.isEmpty()) { queue.addFirst(info); } else { queue.add(info); } - if (player.getPlayingTrack() == null) { - AudioTrack toPlay = queue.peek() == null ? null : queue.peek().getTrack(); - player.startTrack(toPlay, true); + if (idle) { + TrackInfo head = queue.peek(); + 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() { synchronized (queue) { TrackInfo next = queue.peek(); if (next != null) { - player.startTrack(next.getTrack().makeClone(), false); - return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().title); + startTrack(next.getTrack().makeClone(), false); + return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().getTitle()); } - player.stopTrack(); + stopTrack(); return CommandExecutionResult.info("Queue is empty, leaving voice channel"); } } public void startNextTrack() { synchronized (queue) { - TrackInfo last = queue.poll(); + queue.poll(); TrackInfo next = queue.peek(); if (next != null) { - player.startTrack(next.getTrack().makeClone(), false); + startTrack(next.getTrack().makeClone(), false); } else { - player.stopTrack(); + stopTrack(); } } } diff --git a/src/main/java/me/bobert/netneutrality/music/SourceFinder.java b/src/main/java/me/bobert/netneutrality/music/SourceFinder.java index 9e70b27..13b55c5 100644 --- a/src/main/java/me/bobert/netneutrality/music/SourceFinder.java +++ b/src/main/java/me/bobert/netneutrality/music/SourceFinder.java @@ -68,8 +68,10 @@ public class SourceFinder { String identifier; identifier = switch (getSource(url)) { - case Youtube, YTMusic, Soundcloud, Udio, DirectLinkMp3 -> url; + case Youtube, YTMusic, Soundcloud, DirectLinkMp3 -> 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; }; diff --git a/src/main/java/me/bobert/netneutrality/music/StuckTrackRecoveryHandler.java b/src/main/java/me/bobert/netneutrality/music/StuckTrackRecoveryHandler.java index d95a60e..91d8b1b 100644 --- a/src/main/java/me/bobert/netneutrality/music/StuckTrackRecoveryHandler.java +++ b/src/main/java/me/bobert/netneutrality/music/StuckTrackRecoveryHandler.java @@ -5,8 +5,7 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.arbjerg.lavalink.client.player.Track; import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.context.Context; 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 - * {@link TrackScheduler#onTrackStuck(AudioPlayer, AudioTrack, long)}. + * {@link TrackScheduler#onTrackStuck(Track, long)}. */ class StuckTrackRecoveryHandler { private static final Logger logger = LogManager.getLogger(StuckTrackRecoveryHandler.class); - private final AudioPlayer player; + private final MusicManager manager; private final QueueManager queueManager; - private AudioTrack lastStuckTrack; + private Track lastStuckTrack; - StuckTrackRecoveryHandler(AudioPlayer player, QueueManager queueManager) { - this.player = player; + StuckTrackRecoveryHandler(MusicManager manager, QueueManager queueManager) { + this.manager = manager; this.queueManager = queueManager; } @@ -40,11 +39,11 @@ class StuckTrackRecoveryHandler { /** * 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. */ - void handle(AudioTrack track, Context context) { - if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) { + void handle(Track track, Context context) { + if (lastStuckTrack == null || !identifierOf(lastStuckTrack).equals(identifierOf(track))) { lastStuckTrack = track; attemptRecovery(track, context); } else { @@ -53,28 +52,26 @@ class StuckTrackRecoveryHandler { } } - private void attemptRecovery(AudioTrack track, Context context) { - logger.debug("Trying to load stuck track again: {}", track.getIdentifier()); + private void attemptRecovery(Track track, Context context) { + logger.debug("Trying to load stuck track again: {}", identifierOf(track)); context.async("Current track is stuck, trying to load it again", asyncContext -> { try { TrackInfo info = queueManager.peekHeadOfQueue(); - if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) { - AudioTrack clone = track.makeClone(); - long lastPosition = track.getPosition(); + if (info != null && identifierOf(info.getTrack()).equals(identifierOf(track))) { + long lastPosition = manager.getCurrentPosition(); - player.stopTrack(); + manager.stopTrack(); - if (queueManager.peekHeadOfQueue() != null - && queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) { + TrackInfo head = queueManager.peekHeadOfQueue(); + if (head != null && identifierOf(head.getTrack()).equals(identifierOf(track))) { queueManager.removeHeadOfQueue(); } - queueManager.queue(clone, info.getRequester()); + queueManager.queue(track.makeClone(), info.getRequester()); - AudioTrack playing = player.getPlayingTrack(); - if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) { - playing.setPosition(lastPosition); + if (lastPosition > 0) { + manager.updatePlayer("seek to " + lastPosition, builder -> builder.setPosition(lastPosition)); lastStuckTrack = null; } else { 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(); + } } diff --git a/src/main/java/me/bobert/netneutrality/music/TrackInfo.java b/src/main/java/me/bobert/netneutrality/music/TrackInfo.java index 1b32f51..16ed8bf 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackInfo.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackInfo.java @@ -5,7 +5,7 @@ package me.bobert.netneutrality.music; * 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 org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -16,18 +16,18 @@ import java.util.Objects; import java.util.Set; public class TrackInfo { - private final AudioTrack track; + private final Track track; private final Member member; private final Set skips; 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.member = member; this.skips = new HashSet<>(); } - public AudioTrack getTrack() { + public Track getTrack() { return track; } @@ -54,6 +54,11 @@ public class TrackInfo { 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 public boolean equals(Object o) { if (this == o) { @@ -63,18 +68,18 @@ public class TrackInfo { return false; } TrackInfo trackInfo = (TrackInfo) o; - return track.equals(trackInfo.track); + return track.getEncoded().equals(trackInfo.track.getEncoded()); } @Override public int hashCode() { - return Objects.hash(track); + return Objects.hash(track.getEncoded()); } @Override public String toString() { return "TrackInfo{" + - "track=" + track.getInfo().title + + "track=" + track.getInfo().getTitle() + ", member=" + member.getEffectiveName() + ", skips=" + skips + '}'; diff --git a/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java b/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java index d980848..5c5abc0 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackLoadingHandler.java @@ -6,20 +6,27 @@ package me.bobert.netneutrality.music; */ -import me.bobert.netneutrality.utils.MusicUtils; -import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; -import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; -import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler; +import dev.arbjerg.lavalink.client.player.LoadFailed; +import dev.arbjerg.lavalink.client.player.PlaylistLoaded; +import dev.arbjerg.lavalink.client.player.SearchResult; +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.context.CommandContext; -import me.bobert.netneutrality.tasks.music.LoadPlaylistTask; 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 org.apache.logging.log4j.LogManager; 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 final String identifier; @@ -42,24 +49,42 @@ public class TrackLoadingHandler implements AudioLoadResultHandler { } @Override - public void trackLoaded(AudioTrack audioTrack) { - logger.debug("Guild {}: track loaded: {}", guild.getId(), audioTrack.getIdentifier()); + public void ontrackLoaded(TrackLoaded result) { + Track track = result.getTrack(); + logger.debug("Guild {}: track loaded: {}", guild.getId(), track.getInfo().getIdentifier()); context.deleteMessage(); if (MusicUtils.connect(context, manager)) { - queueManager.queue(audioTrack, context.getGuildUser()); + queueManager.queue(track, context.getGuildUser()); if (!silent) { - context.reply(MessageFunction.buildAddingTrackMessage(audioTrack, context.getGuildUser(), type)); + context.reply(MessageFunction.buildAddingTrackMessage(track, context.getGuildUser(), type)); } } } @Override - public void playlistLoaded(AudioPlaylist playlist) { + public void onPlaylistLoaded(PlaylistLoaded result) { 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 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 @@ -72,11 +97,11 @@ public class TrackLoadingHandler implements AudioLoadResultHandler { } @Override - public void loadFailed(FriendlyException e) { + public void loadFailed(LoadFailed result) { logger.debug("Guild {}: load failed for identifier {}", guild.getId(), identifier); //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); if (!silent) { diff --git a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java index c17090a..450f120 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java @@ -5,11 +5,9 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; -import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.client.player.TrackException; +import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent; import me.bobert.netneutrality.context.MusicChannelContext; import me.bobert.netneutrality.core.LogHandler; 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.Logger; -public class TrackScheduler extends AudioEventAdapter { +/** + * Sink for the track lifecycle events emitted by the Lavalink node. + * + *

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 final QueueManager queueManager; private final MusicManager manager; private final MusicManagerCallback callback; - private final AudioPlayer player; private final Guild guild; private volatile Context context; + private volatile Track currentTrack; private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler; public TrackScheduler(MusicManager manager) { this.manager = manager; this.callback = manager; - this.player = manager.getPlayer(); this.guild = manager.getGuild(); this.queueManager = new QueueManager(this); - this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(player, queueManager); + this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(manager, queueManager); } public QueueManager getQueueManager() { @@ -50,14 +53,26 @@ public class TrackScheduler extends AudioEventAdapter { return callback; } - public AudioPlayer getPlayer() { - return player; - } - public Guild getGuild() { return guild; } + /** + * Returns the track the node is currently playing. + * + *

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() { Context ctx = context; if (ctx != null) { @@ -83,36 +98,30 @@ public class TrackScheduler extends AudioEventAdapter { return stuckTrackRecoveryHandler; } - @Override - public void onPlayerPause(AudioPlayer player) { - // Player was paused - } - - @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()); + public void onTrackStart(Track track) { + logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier()); + currentTrack = track; stuckTrackRecoveryHandler.reset(); } - @Override - public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) { - logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getIdentifier()); + public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) { + logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getInfo().getIdentifier()); - if (endReason == AudioTrackEndReason.LOAD_FAILED) { - logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getIdentifier()); + if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) { + currentTrack = null; + } + + if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) { + logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getInfo().getIdentifier()); } // check if mayStartNext - if (endReason == AudioTrackEndReason.FINISHED || endReason == AudioTrackEndReason.LOAD_FAILED) { + if (endReason == TrackEndEvent.AudioTrackEndReason.FINISHED + || endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) { queueManager.startNextTrack(); } - if (endReason == AudioTrackEndReason.STOPPED) { + if (endReason == TrackEndEvent.AudioTrackEndReason.STOPPED) { // maybe skip removing head to make resuming easier? queueManager.removeHeadOfQueue(); } @@ -125,16 +134,23 @@ public class TrackScheduler extends AudioEventAdapter { // clone of this back to your queue } - @Override - public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyException exception) { - logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getIdentifier()); + public void onTrackException(Track track, TrackException exception) { + logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().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(AudioPlayer player, AudioTrack track, long thresholdMs) { - logger.error("Guild {}: currently playing track is stuck: {}", guild.getId(), track.getIdentifier()); + public void onTrackStuck(Track track, long thresholdMs) { + logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs); 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; + } } diff --git a/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java b/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java index b624fbc..42616ba 100644 --- a/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java +++ b/src/main/java/me/bobert/netneutrality/scheduled/music/MusicPlayerAutoDisconnect.java @@ -50,7 +50,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask { } private void processManager(MusicManager manager) throws InterruptedException { - AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel(); + AudioChannel channel = manager.getConnectedChannel(); if (channel != null) { logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", manager.getGuild().getId()); @@ -68,7 +68,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask { } private boolean conditionsMetToDisconnect(MusicManager manager) { - AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel(); + AudioChannel channel = manager.getConnectedChannel(); if (channel != null) { List members = channel.getMembers().stream().filter(member -> !member.getId().equals(channel.getJDA().getSelfUser().getId())).toList(); diff --git a/src/main/java/me/bobert/netneutrality/shared/util/MessageFunction.java b/src/main/java/me/bobert/netneutrality/shared/util/MessageFunction.java index 03c26af..9f1c8c7 100644 --- a/src/main/java/me/bobert/netneutrality/shared/util/MessageFunction.java +++ b/src/main/java/me/bobert/netneutrality/shared/util/MessageFunction.java @@ -6,9 +6,8 @@ package me.bobert.netneutrality.shared.util; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.protocol.v4.TrackInfo; import io.sentry.Sentry; import me.bobert.netneutrality.core.command.Command; 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.music.MusicManager; import me.bobert.netneutrality.music.SourceFinder; -import me.bobert.netneutrality.music.TrackInfo; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; @@ -40,32 +38,30 @@ public class MessageFunction { private static final Logger logger = LogManager.getLogger(MessageFunction.class); public static MessageEmbed buildCurrentlyPlayingMessage(MusicManager manager) { - AudioTrack current = manager.getCurrentlyPlaying(); + Track current = manager.getCurrentlyPlaying(); + TrackInfo currentInfo = current.getInfo(); EmbedBuilder message = new EmbedBuilder(); - message.setAuthor("Link to track (click me)", String.format("https://youtu.be/%s", current.getIdentifier())); - message.setThumbnail(String.format("https://img.youtube.com/vi/%s/default.jpg", 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", currentInfo.getIdentifier())); //message.setTitle("Currently playing:"); message.setColor(Color.BLUE); - message.addField("Currently playing: ", current.getInfo().title, true); - message.addField("Duration: ", DurationFormatUtils.formatDuration(current.getPosition(), "HH:mm:ss", true) + " / " + DurationFormatUtils.formatDuration(current.getDuration(), "HH:mm:ss", true), true); + message.addField("Currently playing: ", currentInfo.getTitle(), 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()); 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(); - String url = track.getIdentifier(); - String thumbnail = null; + TrackInfo info = track.getInfo(); + String url = info.getIdentifier(); + String thumbnail = info.getArtworkUrl(); if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch || type == SourceFinder.AudioSourceType.YTMusic) { - url = String.format("https://youtu.be/%s", track.getIdentifier()); - thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", track.getIdentifier()); - } - - if (type == SourceFinder.AudioSourceType.Udio) { - thumbnail = track.getInfo().artworkUrl; + url = String.format("https://youtu.be/%s", info.getIdentifier()); + thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", info.getIdentifier()); } if (thumbnail != null) { @@ -73,26 +69,26 @@ public class MessageFunction { } message.setTitle("Adding track to queue.."); message.setColor(Color.BLUE); - message.addField("Track title: ", String.format("[**`%s`**](%s)", track.getInfo().title, url), false); - message.addField("Duration: ", DurationFormatUtils.formatDuration(track.getDuration(), "**HH:mm:ss**", true), false); + message.addField("Track title: ", String.format("[**`%s`**](%s)", info.getTitle(), url), false); + message.addField("Duration: ", DurationFormatUtils.formatDuration(info.getLength(), "**HH:mm:ss**", true), false); message.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl()); message.setTimestamp(OffsetDateTime.now()); return message.build(); } - public static MessageEmbed buildAddingPlaylistMessage(List tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) { + public static MessageEmbed buildAddingPlaylistMessage(List tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) { EmbedBuilder message = new EmbedBuilder(); String url = ""; String thumbnail = null; if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch) { - url = String.format("https://youtu.be/%s", tracks.get(0).getIdentifier()); - thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", 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).getInfo().getIdentifier()); } 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) { @@ -109,13 +105,13 @@ public class MessageFunction { } public static String buildViewQueueMessage(MusicManager manager) { - List tracks = manager.getQueueManager().getAllQueuedTracks(); + List tracks = manager.getQueueManager().getAllQueuedTracks(); if (tracks.isEmpty()) { return "Queue is empty!"; } int limit = 10; - AudioTrack current = manager.getCurrentlyPlaying(); + Track current = manager.getCurrentlyPlaying(); int numberofTracksShown = Math.min(tracks.size(), limit); StringBuilder message = new StringBuilder(); @@ -127,15 +123,15 @@ public class MessageFunction { .append(" items):\n"); int index = 0; - for (AudioTrack track : tracks) { + for (Track track : tracks) { index++; - AudioTrackInfo info = track.getInfo(); + TrackInfo info = track.getInfo(); message.append("\n"); - if (current != null && current.getInfo().title.equals(info.title)) { - message.append("- ").append("**").append(info.title).append("**"); + if (current != null && current.getInfo().getTitle().equals(info.getTitle())) { + message.append("- ").append("**").append(info.getTitle()).append("**"); } else { - message.append("- ").append(info.title); + message.append("- ").append(info.getTitle()); } if (index == limit) { @@ -147,8 +143,8 @@ public class MessageFunction { } public static MessageEmbed buildUserTracksMessage(MusicManager manager, Member member) { - List tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester())); - AudioTrack current = manager.getCurrentlyPlaying(); + List tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester())); + Track current = manager.getCurrentlyPlaying(); int numberofTracksShown = Math.min(tracks.size(), 10); EmbedBuilder message = new EmbedBuilder(); @@ -156,13 +152,13 @@ public class MessageFunction { message.setColor(Color.BLUE); int index = 0; - for (TrackInfo track : tracks) { - AudioTrackInfo info = track.getTrack().getInfo(); + for (me.bobert.netneutrality.music.TrackInfo track : tracks) { + TrackInfo info = track.getTrack().getInfo(); - if (current != null && current.getInfo().title.equals(info.title)) { - message.addField("Track #" + ++index + " (Currently playing)", info.title, false); + if (current != null && current.getInfo().getTitle().equals(info.getTitle())) { + message.addField("Track #" + ++index + " (Currently playing)", info.getTitle(), false); } else { - message.addField("Track #" + ++index, info.title, false); + message.addField("Track #" + ++index, info.getTitle(), false); } if (index == 10) { @@ -174,7 +170,7 @@ public class MessageFunction { return message.build(); } - public static MessageEmbed buildSearchResultsMessage(List tracks, Member member) { + public static MessageEmbed buildSearchResultsMessage(List tracks, Member member) { EmbedBuilder embed = new EmbedBuilder(); embed.setTitle("Search results"); @@ -182,8 +178,8 @@ public class MessageFunction { embed.setColor(Color.MAGENTA); int i = 0; - for (AudioTrack track : tracks) { - embed.addField(++i + ": ", track.getInfo().title, false); + for (Track track : tracks) { + embed.addField(++i + ": ", track.getInfo().getTitle(), false); } embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl()); @@ -192,11 +188,11 @@ public class MessageFunction { 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(); - embed.setTitle(String.format("Loading playlist: %s", playlist.getName())); - embed.setDescription(String.format("You are about to load %d tracks from this playlist, are you sure?", playlist.getTracks().size())); + 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?", trackCount)); embed.setColor(Color.CYAN); embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl()); diff --git a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java index 1a200f2..e09f4cf 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java @@ -5,8 +5,7 @@ package me.bobert.netneutrality.tasks.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.arbjerg.lavalink.client.player.Track; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.tasks.Task; import me.bobert.netneutrality.core.tasks.impl.ButtonTask; @@ -28,13 +27,18 @@ import java.util.List; public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable { private final List childrens = new ArrayList<>(); - private final AudioPlaylist playlist; + private final String playlistName; + private final List playlistTracks; + private final boolean searchResult; private final SourceFinder.AudioSourceType sourceType; private final MusicManager musicManager; - public LoadPlaylistTask(AudioPlaylist playlist, SourceFinder.AudioSourceType sourceType, MusicManager musicManager) { + public LoadPlaylistTask(String playlistName, List playlistTracks, boolean searchResult, + SourceFinder.AudioSourceType sourceType, MusicManager musicManager) { super(musicManager.getTaskManager(), null, null); - this.playlist = playlist; + this.playlistName = playlistName; + this.playlistTracks = playlistTracks; + this.searchResult = searchResult; this.sourceType = sourceType; this.musicManager = musicManager; } @@ -51,7 +55,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta @Override public void replyToInteraction(CommandContext context) { - if (playlist.getTracks().isEmpty()) { + if (playlistTracks.isEmpty()) { context.reply("No tracks found"); return; } @@ -62,12 +66,12 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta .getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE)); // Search results - if (playlist.isSearchResult()) { - List searchResults = playlist.getTracks().stream().limit(searchResultsLimit).toList(); + if (searchResult) { + List searchResults = playlistTracks.stream().limit(searchResultsLimit).toList(); List loadTasks = new ArrayList<>(); 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)); } @@ -79,11 +83,11 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta return; } - List tracks = playlist.getTracks(); + List tracks = playlistTracks; 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; } @@ -91,17 +95,17 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta if (MusicUtils.connect(context, musicManager)) { 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 tracks, String addAllText, String cancelText) { + private ActionRow commonButtons(List tracks, String addAllText, String cancelText) { Task accept = AcceptButton.createSuccessButton(taskManager(), this, addAllText, context -> { Member member = context.getGuildUser(); if (MusicUtils.connect(context, musicManager)) { 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; } diff --git a/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java b/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java index 28ca188..b1fce4a 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/LoadTrackFromPlaylistTask.java @@ -5,7 +5,7 @@ package me.bobert.netneutrality.tasks.music; * 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.SourceFinder; 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; public class LoadTrackFromPlaylistTask extends AcceptButton { - 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 -> { Member member = context.getGuildUser(); @@ -36,6 +36,6 @@ public class LoadTrackFromPlaylistTask extends AcceptButton { @Override public String getName() { - return String.format("load-track-%s", track.getIdentifier()); + return String.format("load-track-%s", track.getInfo().getIdentifier()); } } diff --git a/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java b/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java index ff247b7..24bde7a 100644 --- a/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java +++ b/src/test/java/me/bobert/netneutrality/music/QueueManagerTest.java @@ -5,9 +5,7 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import dev.arbjerg.lavalink.client.player.Track; import me.bobert.netneutrality.core.command.CommandExecutionResult; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; @@ -21,22 +19,20 @@ import static org.mockito.Mockito.*; public class QueueManagerTest { - private AudioPlayer player; + private TrackScheduler scheduler; private MusicManager musicManager; private MusicManagerCallback callback; private QueueManager queueManager; @BeforeEach void setUp() { - player = mock(AudioPlayer.class); Guild guild = mock(Guild.class); when(guild.getId()).thenReturn("guild1"); - TrackScheduler scheduler = mock(TrackScheduler.class); + scheduler = mock(TrackScheduler.class); musicManager = mock(MusicManager.class); callback = mock(MusicManagerCallback.class); - when(scheduler.getPlayer()).thenReturn(player); when(scheduler.getGuild()).thenReturn(guild); when(scheduler.getMusicManager()).thenReturn(musicManager); when(scheduler.getCallback()).thenReturn(callback); @@ -44,11 +40,14 @@ public class QueueManagerTest { queueManager = new QueueManager(scheduler); } - private AudioTrack mockTrack(String id) { - AudioTrack track = mock(AudioTrack.class); - when(track.getIdentifier()).thenReturn(id); - AudioTrackInfo info = new AudioTrackInfo("Title " + id, "Author", 1000L, id, false, "http://example.com/" + id); + static Track mockTrack(String id) { + Track track = mock(Track.class); + dev.arbjerg.lavalink.protocol.v4.TrackInfo info = mock(dev.arbjerg.lavalink.protocol.v4.TrackInfo.class); + when(info.getIdentifier()).thenReturn(id); + when(info.getTitle()).thenReturn("Title " + id); + when(info.getLength()).thenReturn(1000L); when(track.getInfo()).thenReturn(info); + when(track.getEncoded()).thenReturn("encoded-" + id); when(track.makeClone()).thenReturn(track); return track; } @@ -64,23 +63,24 @@ public class QueueManagerTest { @Test public void queueAddsToTailAndStartsPlaybackWhenEmpty() { - AudioTrack track = mockTrack("t1"); + Track track = mockTrack("t1"); Member member = mockMember("u1"); - when(player.getPlayingTrack()).thenReturn(null); + when(scheduler.getCurrentTrack()).thenReturn(null); queueManager.queue(track, member); assertEquals(1, queueManager.getQueueSize()); - verify(player).startTrack(track, true); + verify(scheduler).setCurrentTrack(track); + verify(musicManager).updatePlayer(contains("t1"), any()); } @Test public void queueAddsToTailWhenAlreadyPlaying() { - AudioTrack t1 = mockTrack("t1"); - AudioTrack t2 = mockTrack("t2"); + Track t1 = mockTrack("t1"); + Track t2 = mockTrack("t2"); Member member = mockMember("u1"); - when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1); + when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1); queueManager.queue(t1, member); queueManager.queue(t2, member); @@ -93,31 +93,28 @@ public class QueueManagerTest { @Test public void queueAddsToFrontWhenNothingPlayingAndQueueNotEmpty() { - AudioTrack t1 = mockTrack("t1"); - AudioTrack t2 = mockTrack("t2"); + Track t1 = mockTrack("t1"); + Track t2 = mockTrack("t2"); Member member = mockMember("u1"); - // First queue: nothing playing, queue empty -> add to tail, start track - when(player.getPlayingTrack()).thenReturn(null); - queueManager.queue(t1, member); + // Nothing playing on both calls: the second track jumps to the head so that it is the + // one actually started. + when(scheduler.getCurrentTrack()).thenReturn(null); - // Second queue: nothing playing (startTrack with noInterrupt=true didn't actually start), - // queue not empty -> should addFirst so t2 is at head and gets played - when(player.getPlayingTrack()).thenReturn(null); + queueManager.queue(t1, member); queueManager.queue(t2, member); assertEquals(2, queueManager.getQueueSize()); List q = queueManager.getQueue(); - // t2 should be at the front since nothing was playing and queue was not empty assertSame(t2, q.get(0).getTrack()); assertSame(t1, q.get(1).getTrack()); } @Test public void queueRejectsDuplicate() { - AudioTrack track = mockTrack("t1"); + Track track = mockTrack("t1"); 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); @@ -136,7 +133,7 @@ public class QueueManagerTest { @Test 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("t2"), mockMember("u1")); @@ -158,10 +155,10 @@ public class QueueManagerTest { @Test public void skipSingleTrack() { - AudioTrack t1 = mockTrack("t1"); - AudioTrack t2 = mockTrack("t2"); + Track t1 = mockTrack("t1"); + Track t2 = mockTrack("t2"); 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(t2, member); @@ -174,18 +171,16 @@ public class QueueManagerTest { @Test public void skipMoreThanQueueSizeSkipsAll() { - AudioTrack t1 = mockTrack("t1"); - AudioTrack t2 = mockTrack("t2"); + Track t1 = mockTrack("t1"); + Track t2 = mockTrack("t2"); 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(t2, member); // skip(10) should clamp to queue.size() = 2, removing all - CommandExecutionResult result = queueManager.skip(10); - // After removing (nbTrack-1)=1 via poll, queue has 1 left, then startNextTrack polls that - // Actually with nbTrack clamped to 2: poll 1 track, then startNextTrack polls the last + queueManager.skip(10); assertTrue(queueManager.isEmpty()); } @@ -193,8 +188,8 @@ public class QueueManagerTest { @Test public void shuffleTooSmallQueueReturnsWarn() { - AudioTrack t1 = mockTrack("t1"); - when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1); + Track t1 = mockTrack("t1"); + when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1); queueManager.queue(t1, mockMember("u1")); CommandExecutionResult result = queueManager.shuffle(); @@ -204,10 +199,10 @@ public class QueueManagerTest { @Test public void shufflePreservesCurrentTrackAtHead() { Member member = mockMember("u1"); - AudioTrack playing = mockTrack("t1"); - when(player.getPlayingTrack()).thenReturn(null).thenReturn(playing).thenReturn(playing).thenReturn(playing).thenReturn(playing); + Track playing = mockTrack("t1"); + when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(playing); - queueManager.queue(mockTrack("t1"), member); + queueManager.queue(playing, member); queueManager.queue(mockTrack("t2"), member); queueManager.queue(mockTrack("t3"), member); queueManager.queue(mockTrack("t4"), member); @@ -218,7 +213,7 @@ public class QueueManagerTest { // First track should still be t1 List q = queueManager.getQueue(); assertEquals(4, q.size()); - assertEquals("t1", q.get(0).getTrack().getIdentifier()); + assertEquals("t1", q.get(0).getTrack().getInfo().getIdentifier()); } // --- RemoveFromQueue --- @@ -231,7 +226,7 @@ public class QueueManagerTest { @Test 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")); CommandExecutionResult result = queueManager.removeFromQueue(5); @@ -240,7 +235,7 @@ public class QueueManagerTest { @Test 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")); CommandExecutionResult result = queueManager.removeFromQueue(0); diff --git a/src/test/java/me/bobert/netneutrality/music/TrackInfoTest.java b/src/test/java/me/bobert/netneutrality/music/TrackInfoTest.java index fe0a22c..3f94117 100644 --- a/src/test/java/me/bobert/netneutrality/music/TrackInfoTest.java +++ b/src/test/java/me/bobert/netneutrality/music/TrackInfoTest.java @@ -5,7 +5,7 @@ package me.bobert.netneutrality.music; * 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 org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,13 +18,13 @@ import static org.mockito.Mockito.when; public class TrackInfoTest { - private AudioTrack track; + private Track track; private Member member; private TrackInfo trackInfo; @BeforeEach void setUp() { - track = mock(AudioTrack.class); + track = QueueManagerTest.mockTrack("t1"); member = mock(Member.class); when(member.getId()).thenReturn("user1"); when(member.getEffectiveName()).thenReturn("User1"); @@ -101,7 +101,7 @@ public class TrackInfoTest { @Test public void notEqualWithDifferentTrack() { - AudioTrack otherTrack = mock(AudioTrack.class); + Track otherTrack = QueueManagerTest.mockTrack("t2"); TrackInfo other = new TrackInfo(otherTrack, member); assertNotEquals(trackInfo, other); } diff --git a/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java b/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java index 2ec269f..0d68e7e 100644 --- a/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java +++ b/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java @@ -5,28 +5,27 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent; import net.dv8tion.jda.api.entities.Guild; import org.junit.jupiter.api.BeforeEach; 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.*; public class TrackSchedulerTest { - private AudioPlayer player; + private MusicManager musicManager; private TrackScheduler scheduler; @BeforeEach void setUp() { - player = mock(AudioPlayer.class); Guild guild = mock(Guild.class); when(guild.getId()).thenReturn("guild1"); - MusicManager musicManager = mock(MusicManager.class); - when(musicManager.getPlayer()).thenReturn(player); + musicManager = mock(MusicManager.class); when(musicManager.getGuild()).thenReturn(guild); scheduler = new TrackScheduler(musicManager); @@ -34,45 +33,51 @@ public class TrackSchedulerTest { @Test public void onTrackEndFinishedStartsNextTrack() { - AudioTrack track = mock(AudioTrack.class); - when(track.getIdentifier()).thenReturn("t1"); + Track track = QueueManagerTest.mockTrack("t1"); - // With empty queue, startNextTrack will just stopTrack - scheduler.onTrackEnd(player, track, AudioTrackEndReason.FINISHED); - verify(player).stopTrack(); + // With empty queue, startNextTrack will just stop playback on the node + scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.FINISHED); + verify(musicManager).stopTrack(); } @Test public void onTrackEndLoadFailedStartsNextTrack() { - AudioTrack track = mock(AudioTrack.class); - when(track.getIdentifier()).thenReturn("t1"); + Track track = QueueManagerTest.mockTrack("t1"); - scheduler.onTrackEnd(player, track, AudioTrackEndReason.LOAD_FAILED); - verify(player).stopTrack(); + scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.LOAD_FAILED); + verify(musicManager).stopTrack(); } @Test public void onTrackEndReplacedDoesNotStartNext() { - AudioTrack track = mock(AudioTrack.class); - when(track.getIdentifier()).thenReturn("t1"); + Track track = QueueManagerTest.mockTrack("t1"); - scheduler.onTrackEnd(player, track, AudioTrackEndReason.REPLACED); - verify(player, never()).stopTrack(); - verify(player, never()).startTrack(any(), anyBoolean()); + scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.REPLACED); + verify(musicManager, never()).stopTrack(); + verify(musicManager, never()).updatePlayer(anyString(), any()); } @Test - public void onTrackStartResetsStuckHandler() { - AudioTrack track = mock(AudioTrack.class); - when(track.getIdentifier()).thenReturn("t1"); + public void onTrackStartCachesCurrentTrack() { + Track track = QueueManagerTest.mockTrack("t1"); - // After onTrackStart, the stuck handler should be reset - scheduler.onTrackStart(player, track); - // No exception means reset was called successfully + scheduler.onTrackStart(track); + + 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 public void getDjReturnsNullWhenNoContext() { - assert scheduler.getDj() == null; + assertNull(scheduler.getDj()); } } diff --git a/src/test/java/me/bobert/netneutrality/music/VoteSkipServiceTest.java b/src/test/java/me/bobert/netneutrality/music/VoteSkipServiceTest.java index 87f7627..fefdc63 100644 --- a/src/test/java/me/bobert/netneutrality/music/VoteSkipServiceTest.java +++ b/src/test/java/me/bobert/netneutrality/music/VoteSkipServiceTest.java @@ -5,7 +5,6 @@ package me.bobert.netneutrality.music; * https://discord.gg/qUa6zNFZsd */ -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.context.CommandContext; import net.dv8tion.jda.api.entities.Guild; @@ -39,7 +38,7 @@ public class VoteSkipServiceTest { when(guild.getId()).thenReturn("guild1"); 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); when(requester.getId()).thenReturn("requester"); trackInfo = new TrackInfo(track, requester);