# 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`); 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` 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).