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

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`, `AutoplayCommand`, `BitconnectCommand`. See
[commands.md](commands.md#command-catalog).