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.
This commit is contained in:
1 parent
01bfe22d22
commit
4cf7c47645
34 files changed
+1187
-479
No files matched your search
+115
-38
@@ -1,35 +1,51 @@
|
||||
# Music
|
||||
|
||||
**Package:** `me.bobert.netneutrality.feature.music`
|
||||
**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube
|
||||
source 1.18.2.
|
||||
**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.
|
||||
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 |
|
||||
|---|---|
|
||||
| `MusicManager` | Central per-guild coordinator; owns the `AudioPlayer` and `TrackScheduler`. |
|
||||
| `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` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). |
|
||||
| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. |
|
||||
| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). |
|
||||
| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). |
|
||||
| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. |
|
||||
| `source/UdioAudioSourceManager` | Custom Lavaplayer source manager for udio.com. |
|
||||
| `source/UdioAudioTrack` | Udio track; delegates playback to `Mp3AudioTrack`. |
|
||||
| `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. |
|
||||
|
||||
`MusicManager` keeps a static `Map<String, MusicManager>` of managers keyed by guild id, so each guild
|
||||
has its own player, scheduler, and queue.
|
||||
`MusicServiceImpl` keeps a `Map<String, MusicManager>` of managers keyed by guild id, so each guild
|
||||
has its own link, scheduler, and queue.
|
||||
|
||||
## Startup
|
||||
|
||||
`MusicManager.initPlayerManager()` (called from
|
||||
[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer
|
||||
`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom
|
||||
[Udio source](#custom-udio-source), and HTTP).
|
||||
`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)
|
||||
|
||||
@@ -38,50 +54,111 @@ has its own player, scheduler, and queue.
|
||||
│
|
||||
▼
|
||||
MusicManager.play(query, ctx)
|
||||
│ SourceFinder identifies the source
|
||||
│ SourceFinder identifies the source and builds the identifier
|
||||
▼
|
||||
audioPlayerManager.loadItemOrdered(guild, identifier, new TrackLoadingHandler(...))
|
||||
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
|
||||
│
|
||||
├─ trackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
|
||||
├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons)
|
||||
├─ 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 the player is idle, start immediately; else append to the deque
|
||||
QueueManager: if nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
|
||||
│
|
||||
▼
|
||||
AudioPlayerSendHandler streams PCM/Opus frames to the voice channel
|
||||
Lavalink node decodes and streams the audio to the Discord voice gateway
|
||||
│
|
||||
▼ (track ends)
|
||||
TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
|
||||
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.
|
||||
- **Playlists / search** — playlist and multi-result loads produce a
|
||||
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** — `TrackScheduler.onTrackStuck` attempts recovery (reload/skip).
|
||||
- **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.
|
||||
|
||||
## Custom Udio source
|
||||
## Running the node
|
||||
|
||||
`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist,
|
||||
duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the
|
||||
page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the
|
||||
`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's
|
||||
`Mp3AudioTrack`.
|
||||
`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 player has been idle.
|
||||
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the
|
||||
static map.
|
||||
- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice
|
||||
- `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
|
||||
|
||||
Reference in new issue
Block a user