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.
171 lines
8.7 KiB
Markdown
171 lines
8.7 KiB
Markdown
# Music
|
|
|
|
**Package:** `me.bobert.netneutrality.music`
|
|
**Engine:** a remote [Lavalink](https://lavalink.dev) v4 node driven by
|
|
[`dev.arbjerg:lavalink-client`](https://github.com/lavalink-devs/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`](architecture.md#startup-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`](tasks.md#concrete-task-catalog) with per-result buttons
|
|
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
|
|
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) 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`](contexts.md#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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
```yaml
|
|
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`](https://jitpack.io/#ashton045/youtube-source), branch
|
|
`feat/sabr-support`) — no local build of the fork is required, JitPack publishes a ready Lavalink
|
|
plugin jar:
|
|
|
|
```yaml
|
|
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/`](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()`](architecture.md#shutdown-lifecycle)).
|
|
|
|
## Related commands
|
|
|
|
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
|
|
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
|
|
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
|
|
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
|
|
[commands.md](commands.md#command-catalog).
|