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

19 KiB
Raw Blame History

sessionId
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 Monos 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:

<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

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

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

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.

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 Monos internally and report failures through LogHandler.
  • Apply the GSettings.MUSIC_VOLUME base volume on connect with the existing default-50 fallback.

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.

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.