--- 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.