Files
netneutrality/docs/music.md
anthorob d97c4c6da2 **Add autoplay support for seamless music playback**
Introduced autoplay functionality to keep music playing when the queue ends. Added new `/autoplay` API endpoints, command handling, and service implementations. Enhanced `TrackScheduler`, queue operations, and testing to support autoplay behavior. Integrated YouTube radio mix as the initial provider.
2026-10-31 16:13:04 -04:00

8.7 KiB

Music

Package: me.bobert.netneutrality.music Engine: a remote Lavalink v4 node driven by dev.arbjerg: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. 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
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<TrackInfo>); enqueue/skip/shuffle.
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.

MusicServiceImpl keeps a Map<String, MusicManager> of managers keyed by guild id, so each guild has its own link, scheduler, and queue.

Startup

MusicServiceImpl is a priority-40 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)

/play query
   │
   ▼
MusicManager.play(query, ctx)
   │  SourceFinder identifies the source and builds the identifier
   ▼
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
   │
   ├─ 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 nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
   │
   ▼
Lavalink node decodes and streams the audio to the Discord voice gateway
   │
   ▼ (track ends)
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. Duplicate rejection compares the encoded track payload.
  • Playlists / search — playlist and search loads produce a LoadPlaylistTask with per-result buttons (LoadTrackFromPlaylistTask), tying the music and task subsystems together. Playlist size is bounded by the MUSIC_MAXPLAYLISTSIZE guild setting.
  • 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 so they land in the guild's configured music channel.

Running the node

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.

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:

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, branch feat/sabr-support) — no local build of the fork is required, JitPack publishes a ready Lavalink plugin jar:

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/ — 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 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()).

All music commands extend MusicCommand (guild only + music role/channel enforced): PlayCommand, SkipCommand/VoteSkipCommand, QueueCommand/ViewQueueCommand, ClearQueueCommand, ShuffleCommand, JoinCommand/LeaveCommand, CurrentlyPlayingCommand, ResumeCommand/StopCommand, VolumeCommand, AutoplayCommand, BitconnectCommand. See commands.md.