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.
19 KiB
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:v2fordev.arbjerg:lavalink-clientinpom.xml. - Rewrite the
me.bobert.netneutrality.musicpackage against Lavalink:MusicServiceImpl,MusicManager,TrackScheduler,QueueManager,TrackInfo,TrackLoadingHandler,StuckTrackRecoveryHandler; deleteAudioPlayerSendHandler. - Route voice connections through Lavalink's
JDAVoiceUpdateListener/DirectAudioControllerinstead ofGuild#getAudioManager(). - Add a
lavalinkservice pluslavalink/application.ymltodocker-compose.yml, wired to the existingejs-api(yt-cipher) container. - Update
MessageFunction,LoadPlaylistTask,LoadTrackFromPlaylistTaskand the music command classes to the new track type. - Update the affected tests and the docs under
docs/.
Out of Scope
- Porting
music/source/UdioAudioSourceManagerto 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,/shuffleand 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.ymlrather than in Java code.
Functional Requirements
- All 13 commands in
command/music/keep their current signatures, replies andCommandExecutionResultsemantics. SourceFinderkeeps classifying URLs and prefixing searches withytsearch:; identifiers are passed to Lavalink'sloadItemunchanged.- 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 theAudioModuleConfigDAVE wiring inApp.initJDA()are removed, since DAVE is handled by the node (Lavalink ≥ 4.2.0). - New settings are read through the existing
SettingsProviderand documented inbot.properties.example. - Checkstyle (
checkstyle.xml, bound to thetestphase) must keep passing.
Technical Design
Current Implementation
MusicServiceImpl(priority-40Lifecycle, registered inApp.buildContext()line 140) owns aDefaultAudioPlayerManagerand registers YouTube (withsetRemoteCipher(...)), SoundCloud, Udio and HTTP source managers instart();stop()clears queues and callsplayer.destroy().MusicManagercreates anAudioPlayerper guild, setsAudioPlayerSendHandleronguild.getAudioManager(), opens/closes the voice connection itself, and callsplayerManager.loadItemOrdered(this, identifier, new TrackLoadingHandler(...)).TrackScheduler extends AudioEventAdapterand receivesonTrackStart/End/Exception/Stuckcallbacks directly from the player.QueueManagerholds aDeque<TrackInfo>and drives playback withplayer.startTrack(track.makeClone(), false)/player.stopTrack().StuckTrackRecoveryHandlerrelies onAudioTrack#makeClone,getPosition()andsetPosition().com.sedmelluq.*types appear in 14 main sources and 5 tests (QueueManagerTest,TrackInfoTest,TrackSchedulerTest,VoteSkipServiceTest,UdioAudioSourceManagerTest).pom.xmlalready declares thehttps://maven.lavalink.dev/releasesrepository (lines 136–140).docker-compose.ymlrunsdb,botandejs_signature_api(yt-cipher) on thenetneutralitynetwork; there is no Lavalink node.
Key Decisions
- Official client —
dev.arbjerg:lavalink-client(v4-only).VoiceDispatchInterceptoris verified present in the bundled JDA 6.4.1 jar, soJDAVoiceUpdateListenerattaches cleanly. - Central event dispatcher —
MusicServiceImplholds the single subscription to the client's event flux and routes each event to the owning guild'sTrackScheduler. OneDisposableto dispose instop(). - Synchronous facade —
MusicManagersubscribes to the returnedMonos internally; command classes keep their current synchronous signatures. Failures are reported viaLogHandlerand the attachedMusicChannelContext. - Lavalink
Trackeverywhere —dev.arbjerg.lavalink.protocol.v4.TrackreplacesAudioTrackinTrackInfo,QueueManager,MessageFunctionand the playlist tasks.Trackis an immutable data class, somakeClone()disappears andsetPositionbecomesplayer.setPosition(...)on the Lavalink player. - Node in compose — a
lavalinkservice with a mountedapplication.yml; YouTube cipher/poToken config moves out ofMusicServiceImpl.start()into that YAML. - Udio deferred —
music/source/**is excluded from compilation-relevant wiring and theUdiobranch ofSourceFinder.getIdentifierreturnsnullwith 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 upmanagers.get(event.getGuildId())→ forward to itsTrackScheduler.stop()disposes the subscriptions, clears queues, destroys players and closes the client.- Exposes
LavalinkClient getClient()in place ofgetPlayerManager().
App
initJDA()adds.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))and drops.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())).- Ordering: the
LavalinkClientmust 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.youtubetodev.arbjerg.lavalink.
MusicManager
- Holds
LavalinkClient clientand resolvesLink link = client.getOrCreateLink(guildId). connect(AudioChannel)→jda.getDirectAudioController().connect(channel)+setSelfDeafened;disconnect()→getDirectAudioController().disconnect(guild)andlink.destroyPlayer().isConnected()/isNotConnectedTo(...)readguild.getSelfMember().getVoiceState().getChannel()—Guild#getAudioManager()must not be used with Lavalink.play(...)→link.loadItem(identifier).subscribe(new TrackLoadingHandler(...)).getCurrentlyPlaying()returns the cachedTrackfrom the lastTrackStartEvent(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; exposesonTrackStart(Track),onTrackEnd(Track, AudioTrackEndReason),onTrackException,onTrackStuck(Track, long)called by the dispatcher. - The existing
endReasonbranching maps 1:1 onto Lavalink'sAudioTrackEndReason(FINISHED,LOAD_FAILED,STOPPED,REPLACED,CLEANUP).
QueueManager
player.getPlayingTrack() == nullbecomes avolatile Track currentTrackmaintained by scheduler events (avoids a blocking REST call inside thesynchronized (queue)blocks).startTrack(track, noInterrupt)becomeslink.createOrUpdatePlayer().setTrack(track).setNoReplace(...);makeClone()calls are dropped sinceTrackis 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
synchronizedblocks inQueueManagerrisks deadlock — hence the cachedcurrentTrackinstead of a REST read. - Node availability:
MusicServiceImpl.start()must not throw when the node is down, orApp.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 verifymust compile and pass Checkstyle plus the updated unit tests.- Unit tests keep using mocks:
QueueManagerTestandTrackSchedulerTestmock the LavalinkLink/LavalinkPlayerand assert on the interactions instead of onAudioPlayer. - A grep sweep confirms no
com.sedmelluq/dev.lavalink.youtubeimport survives outsidemusic/source/**. - Manual smoke run with
docker compose upto 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 triggersonQueueEmpty()→ disconnect.skip(n),shuffle,clearQueue,removeTracksFromUserandremoveFromQueue(index)return the sameCommandExecutionResultmessages as before.ytsearch:queries resolve throughonSearchResultLoadedand queue the first hit.- Volume from
GSettings.MUSIC_VOLUMEis 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 →
WebSocketClosedEventis 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
TrackInfoequality.
Test Changes
- Update
QueueManagerTest,TrackSchedulerTest,TrackInfoTest,VoteSkipServiceTestto the LavalinkTracktype and mocked players. - Keep
UdioAudioSourceManagerTestas-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:v2and the fourclub.minnced:jdave-*entries inpom.xmlwithdev.arbjerg:lavalink-client(Gradle docs snippet translated to Maven, using the existingarbjergDev-releasesrepository). - Add a
lavalinkservice todocker-compose.ymlon thenetneutralitynetwork, pinned to an image with DAVE support (≥ 4.2.0), with the bot depending on it. - Create
lavalink/application.ymlmounted into the node: server port/password, the youtube-source plugin, and the remote cipher pointed at the existingejs-apicontainer plus poToken/visitorData. - Add
NETNEUTRALITY_LAVALINK_URI,_PASSWORDand_NAMEtobot.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
MusicServiceImplto own aLavalinkClient, registering the configured node instart()without throwing when it is unreachable. - Dispose the client and its subscriptions in
stop(), keeping the priority-40Lifecyclecontract used byApp.buildContext(). - Add
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))inApp.initJDA()and remove theAudioModuleConfig/JDaveSessionFactorywiring. - Update the dev-mode log levels in
App.mainfromcom.sedmelluq.discord/dev.lavalink.youtubetodev.arbjerg.lavalink. - Replace
getPlayerManager()withgetClient()and fixBotState'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
AudioPlayerfield inMusicManagerwith aLinkobtained fromclient.getOrCreateLink(guildId). - Implement
connect/disconnectviajda.getDirectAudioController()andlink.destroyPlayer(), deleting theguild.getAudioManager()calls and theAudioPlayerSendHandlerclass. - Base
isConnected()andisNotConnectedTo(...)ongetSelfMember().getVoiceState().getChannel(), and updateMusicUtils.connect/checkIfConnectedaccordingly. - Keep the synchronous facade:
stopTrack,setVolumeandplaysubscribe to the returnedMonos internally and report failures throughLogHandler. - Apply the
GSettings.MUSIC_VOLUMEbase 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
TrackInfoto wrapTrack+Member, preserving equality-based duplicate rejection. - Update
QueueManagerto drive playback withcreateOrUpdatePlayer().setTrack(...)/setNoReplace(...), droppingmakeClone()and replacingplayer.getPlayingTrack()checks with a scheduler-maintainedvolatile Track currentTrack. - Reimplement
TrackLoadingHandleronAbstractAudioLoadResultHandler, mappingonSearchResultLoadedto the first hit to preserve theytsearch:behaviour. - Update
MessageFunction,LoadPlaylistTaskandLoadTrackFromPlaylistTaskto the new track type. - Make
SourceFinderreturn no identifier for Udio URLs and haveMusicManager.playreply 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
MusicServiceImplsubscribing once toTrackStartEvent,TrackEndEvent,TrackExceptionEvent,TrackStuckEventandWebSocketClosedEvent, routing by guild id to the cachedMusicManager. - Convert
TrackSchedulerfrom anAudioEventAdaptersubclass into a plain sink withonTrackStart/onTrackEnd/onTrackException/onTrackStuck, keeping the existingFINISHED/LOAD_FAILED/STOPPEDbranching andMusicChannelContextattachment. - Rework
StuckTrackRecoveryHandlerto re-queue the immutableTrackand seek withplayer.setPosition(...), retaining the retry-then-skip rule. - Update
QueueManagerTest,TrackSchedulerTest,TrackInfoTestandVoteSkipServiceTestto mockLink/LavalinkPlayer, and verify./mvnw verifypasses 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.mdanddocs/architecture.mdto cover the node, the client, the dispatcher and the new settings. - Refresh the music mentions in
docs/README.md,docs/threading.mdanddocs/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/UdioAudioSourceManagerandUdioAudioTrackremain only as the reference for a future Lavalink server plugin and that Udio playback is disabled until then.