Files
netneutrality/.junie/plans/migrate-music-to-lavalink.md
T
anthorob 4cf7c47645 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.
2026-09-23 15:08:06 -04:00

249 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
sessionId: session-260923-114918-ce7w
---
# Requirements
### Overview & Goals
Replace the in-process **Lavaplayer** audio stack with a remote **Lavalink v4** node driven by the official `dev.arbjerg:lavalink-client`. Audio decoding, source resolution and voice sending move out of the bot process to the Lavalink node, which can then be extended with server-side plugins (YouTube source, LavaSrc, etc.) without touching the bot.
All dependency/plugin examples from the Lavalink docs are Gradle-based and will be **translated to Maven** (`pom.xml` `<repositories>` / `<dependencies>`); the Lavalink node itself is configured via a YAML file mounted into the container, not via any build script.
### Scope
**In Scope**
- Swap `dev.arbjerg:lavaplayer` + `dev.lavalink.youtube:v2` for `dev.arbjerg:lavalink-client` in `pom.xml`.
- Rewrite the `me.bobert.netneutrality.music` package against Lavalink: `MusicServiceImpl`, `MusicManager`, `TrackScheduler`, `QueueManager`, `TrackInfo`, `TrackLoadingHandler`, `StuckTrackRecoveryHandler`; delete `AudioPlayerSendHandler`.
- Route voice connections through Lavalink's `JDAVoiceUpdateListener` / `DirectAudioController` instead of `Guild#getAudioManager()`.
- Add a `lavalink` service plus `lavalink/application.yml` to `docker-compose.yml`, wired to the existing `ejs-api` (yt-cipher) container.
- Update `MessageFunction`, `LoadPlaylistTask`, `LoadTrackFromPlaylistTask` and the music command classes to the new track type.
- Update the affected tests and the docs under `docs/`.
**Out of Scope**
- Porting `music/source/UdioAudioSourceManager` to a Lavalink server plugin — captured as a documented follow-up. Udio playback is temporarily unavailable; the existing code stays in the repo as the porting reference.
- Any change to command UX, queue semantics, DJ rules or vote-skip behaviour.
- Audio filters/equalizer, node load balancing across multiple nodes, and session resuming (Lavalink supports these; not enabled here).
### User Stories
- As a bot operator, I want audio to be served by a separate Lavalink node so that I can add source plugins and restart the bot without interrupting the audio pipeline's configuration.
- As a user, I want `/play`, `/skip`, `/queue`, `/volume`, `/stop`, `/resume`, `/shuffle` and vote-skip to behave exactly as before the migration.
- As a maintainer, I want YouTube cipher/poToken configuration to live in the node's `application.yml` rather than in Java code.
### Functional Requirements
- All 13 commands in `command/music/` keep their current signatures, replies and `CommandExecutionResult` semantics.
- `SourceFinder` keeps classifying URLs and prefixing searches with `ytsearch:`; identifiers are passed to Lavalink's `loadItem` unchanged.
- Queue ordering, duplicate rejection, auto-start on first enqueue, auto-disconnect on empty queue and the two-strike stuck-track recovery are preserved.
- If the Lavalink node is unreachable at startup, the bot still starts; music commands reply with a clear error instead of crashing.
- Udio URLs reply with a "temporarily unsupported" message rather than failing silently.
### Non-Functional Requirements
- The bot no longer ships native audio encoders: the `jdave-api` / `jdave-native-*` dependencies and the `AudioModuleConfig` DAVE wiring in `App.initJDA()` are removed, since DAVE is handled by the node (Lavalink ≥ 4.2.0).
- New settings are read through the existing `SettingsProvider` and documented in `bot.properties.example`.
- Checkstyle (`checkstyle.xml`, bound to the `test` phase) must keep passing.
# Technical Design
### Current Implementation
- `MusicServiceImpl` (priority-40 `Lifecycle`, registered in `App.buildContext()` line 140) owns a `DefaultAudioPlayerManager` and registers YouTube (with `setRemoteCipher(...)`), SoundCloud, Udio and HTTP source managers in `start()`; `stop()` clears queues and calls `player.destroy()`.
- `MusicManager` creates an `AudioPlayer` per guild, sets `AudioPlayerSendHandler` on `guild.getAudioManager()`, opens/closes the voice connection itself, and calls `playerManager.loadItemOrdered(this, identifier, new TrackLoadingHandler(...))`.
- `TrackScheduler extends AudioEventAdapter` and receives `onTrackStart/End/Exception/Stuck` callbacks directly from the player.
- `QueueManager` holds a `Deque<TrackInfo>` and drives playback with `player.startTrack(track.makeClone(), false)` / `player.stopTrack()`.
- `StuckTrackRecoveryHandler` relies on `AudioTrack#makeClone`, `getPosition()` and `setPosition()`.
- `com.sedmelluq.*` types appear in 14 main sources and 5 tests (`QueueManagerTest`, `TrackInfoTest`, `TrackSchedulerTest`, `VoteSkipServiceTest`, `UdioAudioSourceManagerTest`).
- `pom.xml` already declares the `https://maven.lavalink.dev/releases` repository (lines 136–140).
- `docker-compose.yml` runs `db`, `bot` and `ejs_signature_api` (yt-cipher) on the `netneutrality` network; there is no Lavalink node.
### Key Decisions
1. **Official client** — `dev.arbjerg:lavalink-client` (v4-only). `VoiceDispatchInterceptor` is verified present in the bundled JDA 6.4.1 jar, so `JDAVoiceUpdateListener` attaches cleanly.
2. **Central event dispatcher** — `MusicServiceImpl` holds the single subscription to the client's event flux and routes each event to the owning guild's `TrackScheduler`. One `Disposable` to dispose in `stop()`.
3. **Synchronous facade** — `MusicManager` subscribes to the returned `Mono`s internally; command classes keep their current synchronous signatures. Failures are reported via `LogHandler` and the attached `MusicChannelContext`.
4. **Lavalink `Track` everywhere** — `dev.arbjerg.lavalink.protocol.v4.Track` replaces `AudioTrack` in `TrackInfo`, `QueueManager`, `MessageFunction` and the playlist tasks. `Track` is an immutable data class, so `makeClone()` disappears and `setPosition` becomes `player.setPosition(...)` on the Lavalink player.
5. **Node in compose** — a `lavalink` service with a mounted `application.yml`; YouTube cipher/poToken config moves out of `MusicServiceImpl.start()` into that YAML.
6. **Udio deferred** — `music/source/**` is excluded from compilation-relevant wiring and the `Udio` branch of `SourceFinder.getIdentifier` returns `null` with a friendly reply; the classes remain for the future plugin port.
### Proposed Changes
**Maven (`pom.xml`)** — translated from the docs' Gradle snippets:
```xml
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavalink-client</artifactId>
<version>3.4.0</version>
</dependency>
```
resolved from the already-present `arbjergDev-releases` repository. Removed: `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2`, `club.minnced:jdave-*`.
**`MusicServiceImpl`**
- Field `LavalinkClient client = new LavalinkClient(Helpers.getUserIdFromToken(token))`.
- `start()` registers one node (`client.addNode(...)` with URI, password and name from settings) and opens the dispatcher subscription:
`client.on(TrackStartEvent.class)`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`, `WebSocketClosedEvent` → look up `managers.get(event.getGuildId())` → forward to its `TrackScheduler`.
- `stop()` disposes the subscriptions, clears queues, destroys players and closes the client.
- Exposes `LavalinkClient getClient()` in place of `getPlayerManager()`.
**`App`**
- `initJDA()` adds `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` and drops `.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))`.
- Ordering: the `LavalinkClient` must exist before JDA is built, which the existing priorities already guarantee (music = 40, JDA = 60).
- Dev log levels switch from `com.sedmelluq.discord` / `dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
**`MusicManager`**
- Holds `LavalinkClient client` and resolves `Link link = client.getOrCreateLink(guildId)`.
- `connect(AudioChannel)` → `jda.getDirectAudioController().connect(channel)` + `setSelfDeafened`; `disconnect()` → `getDirectAudioController().disconnect(guild)` and `link.destroyPlayer()`.
- `isConnected()` / `isNotConnectedTo(...)` read `guild.getSelfMember().getVoiceState().getChannel()` — **`Guild#getAudioManager()` must not be used** with Lavalink.
- `play(...)` → `link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))`.
- `getCurrentlyPlaying()` returns the cached `Track` from the last `TrackStartEvent` (the REST player state is not synchronously readable).
- `setVolume` / `stopTrack` → `link.getPlayer().flatMap(p -> p.setVolume(v).setEncodedTrack(null))` subscribed internally.
**`TrackScheduler`**
- No longer extends `AudioEventAdapter`; exposes `onTrackStart(Track)`, `onTrackEnd(Track, AudioTrackEndReason)`, `onTrackException`, `onTrackStuck(Track, long)` called by the dispatcher.
- The existing `endReason` branching maps 1:1 onto Lavalink's `AudioTrackEndReason` (`FINISHED`, `LOAD_FAILED`, `STOPPED`, `REPLACED`, `CLEANUP`).
**`QueueManager`**
- `player.getPlayingTrack() == null` becomes a `volatile Track currentTrack` maintained by scheduler events (avoids a blocking REST call inside the `synchronized (queue)` blocks).
- `startTrack(track, noInterrupt)` becomes `link.createOrUpdatePlayer().setTrack(track).setNoReplace(...)`; `makeClone()` calls are dropped since `Track` is immutable.
**`TrackLoadingHandler`** — implements lavalink-client's `AbstractAudioLoadResultHandler`: `ontrackLoaded` / `onPlaylistLoaded` / `onSearchResultLoaded` / `noMatches` / `loadFailed(LoadFailed)`. `onSearchResultLoaded` takes the first result, matching today's `ytsearch:` behaviour.
**`StuckTrackRecoveryHandler`** — re-queues the same immutable `Track` and restores position via `player.setPosition(lastPosition)`; the second consecutive strike still skips.
### Data Models / Contracts
```java
// MusicManager (synchronous facade, unchanged call sites)
public synchronized void play(String query, CommandContext ctx, boolean silent);
public synchronized void stopTrack();
public synchronized void setVolume(int volume);
public synchronized Track getCurrentlyPlaying();
// TrackScheduler (invoked by the dispatcher)
void onTrackStart(Track track);
void onTrackEnd(Track track, AudioTrackEndReason endReason);
void onTrackStuck(Track track, long thresholdMs);
// TrackInfo
public record TrackInfo(Track track, Member requester) { }
```
New settings (read via `SettingsProvider`, documented in `bot.properties.example`):
`NETNEUTRALITY_LAVALINK_URI` (default `ws://lavalink:2333`), `NETNEUTRALITY_LAVALINK_PASSWORD`, `NETNEUTRALITY_LAVALINK_NAME`.
### File Structure
| Path | Change |
|---|---|
| `pom.xml` | swap lavaplayer/youtube-v2/jdave → `lavalink-client` |
| `docker-compose.yml` | add `lavalink` service |
| `lavalink/application.yml` | **new** — node + youtube-source plugin + yt-cipher config |
| `bot.properties.example` | new Lavalink settings |
| `music/MusicServiceImpl.java` | client lifecycle + event dispatcher |
| `music/MusicManager.java` | `Link`/`DirectAudioController` based |
| `music/TrackScheduler.java` | plain event sink |
| `music/QueueManager.java`, `TrackInfo.java`, `TrackLoadingHandler.java`, `StuckTrackRecoveryHandler.java` | `Track` type + REST player calls |
| `music/AudioPlayerSendHandler.java` | **deleted** |
| `music/SourceFinder.java` | Udio → unsupported |
| `App.java` | voice interceptor, drop DAVE wiring, log levels |
| `shared/util/MessageFunction.java`, `tasks/music/LoadPlaylistTask.java`, `tasks/music/LoadTrackFromPlaylistTask.java` | `Track` type |
| `src/test/.../music/*` | updated fixtures |
| `docs/music.md`, `docs/architecture.md`, `docs/README.md` | rewritten sections |
### Architecture Diagram
```mermaid
graph LR
JDA[JDA gateway] -->|voice state| VIL[JDAVoiceUpdateListener]
VIL --> C[LavalinkClient]
C -->|REST and WS| N[Lavalink node]
N --> V[Discord voice]
CMD[music commands] --> MM[MusicManager]
MM -->|Link ops| C
MM --> QM[QueueManager]
C -->|event flux| DISP[MusicServiceImpl dispatcher]
DISP --> TS[TrackScheduler]
TS --> QM
```
### Risks
- **Voice API confusion**: any remaining `guild.getAudioManager()` call silently breaks playback; all call sites (`MusicManager`, `MusicUtils`, `BotState`) must be swept.
- **Blocking on reactor**: subscribing inside `synchronized` blocks in `QueueManager` risks deadlock — hence the cached `currentTrack` instead of a REST read.
- **Node availability**: `MusicServiceImpl.start()` must not throw when the node is down, or `App.startup()` exits with code 1.
- **Feature regressions**: Udio support pauses; direct-MP3 and SoundCloud must be re-verified against the node's enabled sources.
- **DAVE**: removing JDave requires Lavalink ≥ 4.2.0 — the compose image must be pinned accordingly.
# Testing
### Validation Approach
- `./mvnw -q verify` must compile and pass Checkstyle plus the updated unit tests.
- Unit tests keep using mocks: `QueueManagerTest` and `TrackSchedulerTest` mock the Lavalink `Link`/`LavalinkPlayer` and assert on the interactions instead of on `AudioPlayer`.
- A grep sweep confirms no `com.sedmelluq` / `dev.lavalink.youtube` import survives outside `music/source/**`.
- Manual smoke run with `docker compose up` to confirm the bot reaches the node and plays a YouTube URL.
### Key Scenarios
- Enqueue on an empty queue starts playback immediately; enqueue while playing appends.
- `TrackEndEvent(FINISHED)` advances to the next track; an empty queue triggers `onQueueEmpty()` → disconnect.
- `skip(n)`, `shuffle`, `clearQueue`, `removeTracksFromUser` and `removeFromQueue(index)` return the same `CommandExecutionResult` messages as before.
- `ytsearch:` queries resolve through `onSearchResultLoaded` and queue the first hit.
- Volume from `GSettings.MUSIC_VOLUME` is applied on connect, with the 50 fallback on a malformed value.
### Edge Cases
- Lavalink node unreachable at startup → bot boots, music commands reply with an error.
- Node disconnects mid-playback → `WebSocketClosedEvent` is logged and the guild state is cleaned up.
- Stuck track: first event re-queues and seeks, second consecutive event skips.
- Udio URL → explicit "temporarily unsupported" reply.
- Duplicate track enqueue is still rejected by `TrackInfo` equality.
### Test Changes
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest`, `VoteSkipServiceTest` to the Lavalink `Track` type and mocked players.
- Keep `UdioAudioSourceManagerTest` as-is while the source classes remain in the repo for the future plugin port.
# Delivery Steps
### ✓ Step 1: Swap Maven dependencies and stand up a Lavalink node
The project builds against `lavalink-client` and `docker compose up` starts a reachable Lavalink v4 node.
- Replace `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2` and the four `club.minnced:jdave-*` entries in `pom.xml` with `dev.arbjerg:lavalink-client` (Gradle docs snippet translated to Maven, using the existing `arbjergDev-releases` repository).
- Add a `lavalink` service to `docker-compose.yml` on the `netneutrality` network, pinned to an image with DAVE support (≥ 4.2.0), with the bot depending on it.
- Create `lavalink/application.yml` mounted into the node: server port/password, the youtube-source plugin, and the remote cipher pointed at the existing `ejs-api` container plus poToken/visitorData.
- Add `NETNEUTRALITY_LAVALINK_URI`, `_PASSWORD` and `_NAME` to `bot.properties.example`.
### ✓ Step 2: Wire LavalinkClient into the bootstrap and JDA voice handling
The bot connects to the node on startup and routes Discord voice through Lavalink instead of JDA's audio manager.
- Rework `MusicServiceImpl` to own a `LavalinkClient`, registering the configured node in `start()` without throwing when it is unreachable.
- Dispose the client and its subscriptions in `stop()`, keeping the priority-40 `Lifecycle` contract used by `App.buildContext()`.
- Add `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` in `App.initJDA()` and remove the `AudioModuleConfig`/`JDaveSessionFactory` wiring.
- Update the dev-mode log levels in `App.main` from `com.sedmelluq.discord`/`dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
- Replace `getPlayerManager()` with `getClient()` and fix `BotState`'s usage.
### ✓ Step 3: Rebuild MusicManager on Links and the DirectAudioController
Per-guild connect, disconnect and player control go through Lavalink while command signatures stay unchanged.
- Replace the `AudioPlayer` field in `MusicManager` with a `Link` obtained from `client.getOrCreateLink(guildId)`.
- Implement `connect`/`disconnect` via `jda.getDirectAudioController()` and `link.destroyPlayer()`, deleting the `guild.getAudioManager()` calls and the `AudioPlayerSendHandler` class.
- Base `isConnected()` and `isNotConnectedTo(...)` on `getSelfMember().getVoiceState().getChannel()`, and update `MusicUtils.connect`/`checkIfConnected` accordingly.
- Keep the synchronous facade: `stopTrack`, `setVolume` and `play` subscribe to the returned `Mono`s internally and report failures through `LogHandler`.
- Apply the `GSettings.MUSIC_VOLUME` base volume on connect with the existing default-50 fallback.
### ✓ Step 4: Migrate queue, track loading and message rendering to the Lavalink Track type
Tracks are represented by `dev.arbjerg.lavalink.protocol.v4.Track` end to end and loading uses Lavalink's REST resolver.
- Change `TrackInfo` to wrap `Track` + `Member`, preserving equality-based duplicate rejection.
- Update `QueueManager` to drive playback with `createOrUpdatePlayer().setTrack(...)`/`setNoReplace(...)`, dropping `makeClone()` and replacing `player.getPlayingTrack()` checks with a scheduler-maintained `volatile Track currentTrack`.
- Reimplement `TrackLoadingHandler` on `AbstractAudioLoadResultHandler`, mapping `onSearchResultLoaded` to the first hit to preserve the `ytsearch:` behaviour.
- Update `MessageFunction`, `LoadPlaylistTask` and `LoadTrackFromPlaylistTask` to the new track type.
- Make `SourceFinder` return no identifier for Udio URLs and have `MusicManager.play` reply that Udio is temporarily unsupported.
### ✓ Step 5: Route Lavalink events to TrackScheduler and restore stuck-track recovery
Track lifecycle events from the node drive queue advancement, error reporting and stuck recovery exactly as before.
- Add a central dispatcher in `MusicServiceImpl` subscribing once to `TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent` and `WebSocketClosedEvent`, routing by guild id to the cached `MusicManager`.
- Convert `TrackScheduler` from an `AudioEventAdapter` subclass into a plain sink with `onTrackStart`/`onTrackEnd`/`onTrackException`/`onTrackStuck`, keeping the existing `FINISHED`/`LOAD_FAILED`/`STOPPED` branching and `MusicChannelContext` attachment.
- Rework `StuckTrackRecoveryHandler` to re-queue the immutable `Track` and seek with `player.setPosition(...)`, retaining the retry-then-skip rule.
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest` and `VoteSkipServiceTest` to mock `Link`/`LavalinkPlayer`, and verify `./mvnw verify` passes Checkstyle and the suite.
### ✓ Step 6: Update documentation and record the Udio plugin follow-up
The docs describe the Lavalink-based architecture and the deferred Udio work is explicit.
- Rewrite the Lavaplayer sections of `docs/music.md` and `docs/architecture.md` to cover the node, the client, the dispatcher and the new settings.
- Refresh the music mentions in `docs/README.md`, `docs/threading.md` and `docs/utilities-and-bootstrap.md`.
- Document how to run the node locally and how to add server-side plugins via `lavalink/application.yml`.
- Add a follow-up note stating that `music/source/UdioAudioSourceManager` and `UdioAudioTrack` remain only as the reference for a future Lavalink server plugin and that Udio playback is disabled until then.