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:
anthorob committed 2026-09-23 15:08:06 -04:00
1 parent 01bfe22d22
commit 4cf7c47645
34 files changed
+1187 -479

No files matched your search

@@ -6,7 +6,7 @@ package me.bobert.netneutrality;
* https://discord.gg/qUa6zNFZsd
*/
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
@@ -33,7 +33,6 @@ import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
@@ -47,6 +46,7 @@ public class App {
private static App instance;
private final Settings settings;
private JDA jda;
private MusicService music;
private final ApplicationContext context = new ApplicationContext();
static void main() {
@@ -71,8 +71,7 @@ public class App {
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG);
}
instance = new App(settings);
@@ -137,7 +136,7 @@ public class App {
JpaUtils.bindGuildServiceLookup(guildServices::get);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks));
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown.
@@ -200,8 +199,9 @@ public class App {
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
.setAudioModuleConfig(
new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
// DAVE encryption is handled by the node.
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
@@ -1,45 +0,0 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
import net.dv8tion.jda.api.audio.AudioSendHandler;
import java.nio.ByteBuffer;
/**
* This is a wrapper around AudioPlayer which makes it behave as an AudioSendHandler for JDA. As JDA calls canProvide
* before every call to provide20MsAudio(), we pull the frame in canProvide() and use the frame we already pulled in
* provide20MsAudio().
*/
public class AudioPlayerSendHandler implements AudioSendHandler {
private final AudioPlayer audioPlayer;
private AudioFrame lastFrame;
/**
* @param audioPlayer Audio player to wrap.
*/
public AudioPlayerSendHandler(AudioPlayer audioPlayer) {
this.audioPlayer = audioPlayer;
}
@Override
public boolean canProvide() {
lastFrame = audioPlayer.provide();
return lastFrame != null;
}
@Override
public ByteBuffer provide20MsAudio() {
return ByteBuffer.wrap(lastFrame.getData());
}
@Override
public boolean isOpus() {
return true;
}
}
@@ -6,51 +6,57 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.utils.MusicUtils;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.LavalinkPlayer;
import dev.arbjerg.lavalink.client.player.PlayerUpdateBuilder;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.function.Consumer;
/**
* Per-guild music player and queue owner.
*
* <p>The shared {@link AudioPlayerManager} and the manager cache live on {@link MusicServiceImpl}.
* <p>Playback is delegated to the Lavalink node through the {@link Link} of this guild; the
* shared {@link LavalinkClient} and the manager cache live on {@link MusicServiceImpl}.
* Obtain instances via {@link MusicService#get(Guild)}.
*
* <p>All Lavalink operations return reactive {@code Mono}s. This class subscribes to them
* internally so that the command layer keeps its synchronous call style; failures are reported
* through {@link LogHandler}.
*/
public class MusicManager implements MusicManagerCallback {
private static final Logger logger = LogManager.getLogger(MusicManager.class);
static final int DEFAULT_VOLUME = 50;
private final Guild guild;
private final AudioPlayerManager playerManager;
private final AudioPlayer player;
private final LavalinkClient client;
private final TrackScheduler scheduler;
private final int baseVolume;
private final GuildService guildService;
private final TaskManager taskManager;
private final VoteSkipService voteSkipService;
MusicManager(Guild guild, AudioPlayerManager playerManager, GuildService guildService, TaskManager taskManager) {
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager) {
this.guild = guild;
this.playerManager = playerManager;
this.player = playerManager.createPlayer();
this.client = client;
this.scheduler = new TrackScheduler(this);
this.guildService = guildService;
this.taskManager = taskManager;
this.voteSkipService = new VoteSkipService();
this.baseVolume = parseBaseVolume(guildService);
guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player));
player.addListener(scheduler);
}
public GuildService getGuildService() {
@@ -69,50 +75,101 @@ public class MusicManager implements MusicManagerCallback {
return scheduler.getQueueManager();
}
public AudioPlayer getPlayer() {
return player;
}
public Guild getGuild() {
return guild;
}
/**
* Returns the {@link Link} binding this guild to a Lavalink node, or {@code null} when no
* node is currently available.
*
* @return the link or {@code null}
*/
public Link getLink() {
try {
return client.getOrCreateLink(guild.getIdLong());
} catch (RuntimeException e) {
logger.error("Guild {}: no Lavalink node available", guild.getId(), e);
return null;
}
}
/**
* Returns the voice channel the bot is currently connected to in this guild.
*
* <p>With Lavalink the voice connection is owned by the node, so JDA's audio manager cannot
* be consulted; the gateway voice state of the self member is the source of truth.
*
* @return the connected channel or {@code null}
*/
public AudioChannel getConnectedChannel() {
GuildVoiceState state = guild.getSelfMember().getVoiceState();
return state == null ? null : state.getChannel();
}
public synchronized boolean isNotConnectedTo(AudioChannel vChannel) {
AudioChannel current = guild.getAudioManager().getConnectedChannel();
AudioChannel current = getConnectedChannel();
return vChannel == null || current == null || !vChannel.getId().equals(current.getId());
}
public synchronized void connect(AudioChannel audioChannel) {
if (audioChannel != null) {
logger.debug("Guild {}: connecting to voice channel {}", audioChannel.getGuild().getId(), audioChannel.getId());
guild.getAudioManager().openAudioConnection(audioChannel);
guild.getAudioManager().setSelfDeafened(true);
player.setVolume(baseVolume);
} else {
if (audioChannel == null) {
throw new IllegalArgumentException("audioChannel must not be null");
}
logger.debug("Guild {}: connecting to voice channel {}", audioChannel.getGuild().getId(), audioChannel.getId());
// Only the self-deaf flag is read from the audio manager; the connection itself is opened
// through the direct audio controller so that Lavalink handles the voice websocket.
guild.getAudioManager().setSelfDeafened(true);
guild.getJDA().getDirectAudioController().connect(audioChannel);
setVolume(baseVolume);
}
public synchronized void disconnect() {
if (guild.getAudioManager().getConnectedChannel() != null) {
logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), guild.getAudioManager().getConnectedChannel().getId());
player.stopTrack();
guild.getAudioManager().closeAudioConnection();
if (getConnectedChannel() != null) {
logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), getConnectedChannel().getId());
stopTrack();
guild.getJDA().getDirectAudioController().disconnect(guild);
}
scheduler.detachContext();
}
public synchronized boolean isConnected() {
return guild.getAudioManager().getConnectedChannel() != null;
/**
* Destroys the Lavalink player of this guild, used on shutdown.
*/
synchronized void destroy() {
Link link = getLink();
if (link != null) {
link.destroy().subscribe(
ignored -> { },
error -> logger.error("Guild {}: failed to destroy player", guild.getId(), error)
);
}
}
public synchronized AudioTrack getCurrentlyPlaying() {
return player.getPlayingTrack();
public synchronized boolean isConnected() {
return getConnectedChannel() != null;
}
public synchronized Track getCurrentlyPlaying() {
return scheduler.getCurrentTrack();
}
/**
* Returns the playback position of the currently playing track, in milliseconds.
*
* @return the position, or {@code 0} when nothing is playing
*/
public long getCurrentPosition() {
Link link = getLink();
LavalinkPlayer player = link == null ? null : link.getCachedPlayer();
return player == null ? 0L : player.getPosition();
}
public synchronized boolean isCurrentlyPlaying() {
return player.getPlayingTrack() != null;
return scheduler.getCurrentTrack() != null;
}
public synchronized void play(String query, CommandContext context) {
@@ -122,21 +179,41 @@ public class MusicManager implements MusicManagerCallback {
public synchronized void play(String query, CommandContext context, boolean silent) {
logger.debug("Guild {}: request to play '{}' by user {}", guild.getId(), query, context.getGuildUser().getId());
if (MusicUtils.canConnect(context, this)) {
SourceFinder.AudioSourceType type = SourceFinder.getSource(query);
String identifier = SourceFinder.getIdentifier(query);
if (identifier == null) {
context.reply("No source found to play the given url");
return;
}
TrackLoadingHandler loadingHandler = new TrackLoadingHandler(context, this, identifier, type, silent);
playerManager.loadItemOrdered(this, identifier, loadingHandler);
if (!MusicUtils.canConnect(context, this)) {
return;
}
SourceFinder.AudioSourceType type = SourceFinder.getSource(query);
if (type == SourceFinder.AudioSourceType.Udio) {
context.reply("Udio playback is temporarily unsupported since the migration to Lavalink");
return;
}
String identifier = SourceFinder.getIdentifier(query);
if (identifier == null) {
context.reply("No source found to play the given url");
return;
}
Link link = getLink();
if (link == null) {
context.reply("The audio node is currently unavailable, please try again later");
return;
}
link.loadItem(identifier).subscribe(
new TrackLoadingHandler(context, this, identifier, type, silent),
error -> {
LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error));
if (!silent) {
context.reply("Failed to load the requested track");
}
}
);
}
public synchronized void stopTrack() {
player.stopTrack();
updatePlayer("stop track", builder -> builder.stopTrack());
}
public synchronized CommandExecutionResult resume() {
@@ -144,12 +221,11 @@ public class MusicManager implements MusicManagerCallback {
}
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
AudioChannel audioChannel = guild.getAudioManager().getConnectedChannel();
return voteSkipService.voteSkip(context, getQueueManager(), audioChannel, isCurrentlyPlaying());
return voteSkipService.voteSkip(context, getQueueManager(), getConnectedChannel(), isCurrentlyPlaying());
}
public void setVolume(int volume) {
player.setVolume(volume);
updatePlayer("set volume", builder -> builder.setVolume(volume));
}
public int getQueueSize() {
@@ -161,6 +237,26 @@ public class MusicManager implements MusicManagerCallback {
disconnect();
}
/**
* Applies a player update on the node and subscribes to it, logging any failure.
*
* @param description what the update does, used for logging
* @param mutation the builder mutation to apply
*/
void updatePlayer(String description, Consumer<PlayerUpdateBuilder> mutation) {
Link link = getLink();
if (link == null) {
return;
}
PlayerUpdateBuilder builder = link.createOrUpdatePlayer();
mutation.accept(builder);
builder.subscribe(
ignored -> { },
error -> logger.error("Guild {}: failed to {}", guild.getId(), description, error)
);
}
private static int parseBaseVolume(GuildService guildService) {
try {
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
@@ -6,15 +6,16 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.LavalinkClient;
import java.util.Collection;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import net.dv8tion.jda.api.entities.Guild;
/**
* Owns the shared audio player manager and the per-guild {@link MusicManager} cache.
* Owns the shared {@link LavalinkClient} and the per-guild {@link MusicManager} cache.
*
* <p>As a {@link Lifecycle} component it registers audio sources on {@link #start()} and
* disconnects every player on {@link #stop()}.
* <p>As a {@link Lifecycle} component it registers the Lavalink node on {@link #start()} and
* destroys every player on {@link #stop()}.
*/
public interface MusicService extends Lifecycle {
@@ -32,4 +33,11 @@ public interface MusicService extends Lifecycle {
* @return the managers
*/
Collection<MusicManager> getManagers();
/**
* Returns the shared Lavalink client used for voice dispatch interception and player control.
*
* @return the client
*/
LavalinkClient getClient();
}
@@ -6,41 +6,66 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager;
import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.YoutubeSourceOptions;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import dev.arbjerg.lavalink.client.Helpers;
import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.LavalinkNode;
import dev.arbjerg.lavalink.client.NodeOptions;
import dev.arbjerg.lavalink.client.event.ReadyEvent;
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent;
import dev.arbjerg.lavalink.client.event.TrackStartEvent;
import dev.arbjerg.lavalink.client.event.TrackStuckEvent;
import dev.arbjerg.lavalink.client.event.WebSocketClosedEvent;
import dev.arbjerg.lavalink.internal.LavalinkSocket;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.music.source.UdioAudioSourceManager;
import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import reactor.core.Disposable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* Instance-based {@link MusicService} owning the shared {@link AudioPlayerManager} and the
* Instance-based {@link MusicService} owning the shared {@link LavalinkClient} and the
* per-guild {@link MusicManager} cache.
*
* <p>Audio decoding and voice sending happen on the remote Lavalink node; this class only
* registers the node and dispatches the node's event flux to the owning guild's
* {@link TrackScheduler}.
*/
public class MusicServiceImpl implements MusicService {
private static final Logger logger = LogManager.getLogger(MusicServiceImpl.class);
private static final String DEFAULT_URI = "ws://lavalink:2333";
private static final String DEFAULT_NAME = "main";
private static final long RECONNECT_WATCHDOG_SECONDS = 5;
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final TaskManager taskManager;
private final ExecutorProvider executors;
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
private final AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
private final List<Disposable> subscriptions = new ArrayList<>();
private final LavalinkClient client;
private ScheduledFuture<?> reconnectWatchdog;
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager) {
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
ExecutorProvider executors) {
this.settings = settings;
this.guildServices = guildServices;
this.taskManager = taskManager;
this.executors = executors;
this.client = new LavalinkClient(Helpers.getUserIdFromToken(settings.get("NETNEUTRALITY_BOT_TOKEN")));
}
@Override
@@ -50,41 +75,38 @@ public class MusicServiceImpl implements MusicService {
@Override
public void start() {
logger.debug("Initializing music player sources..");
String cipherUrl = settings.get("NETNEUTRALITY_CIPHER_URL");
String cipherPassword = settings.get("NETNEUTRALITY_CIPHER_PASSWORD");
YoutubeSourceOptions options = new YoutubeSourceOptions()
.setAllowSearch(true)
.setAllowDirectVideoIds(true)
.setAllowDirectPlaylistIds(true)
.setRemoteCipher(cipherUrl, cipherPassword, "");
playerManager.registerSourceManager(new YoutubeAudioSourceManager(options, YoutubeAudioSourceManager.DEFAULT_CLIENTS));
playerManager.registerSourceManager(SoundCloudAudioSourceManager.createDefault());
playerManager.registerSourceManager(new UdioAudioSourceManager());
playerManager.registerSourceManager(new HttpAudioSourceManager());
String po = settings.get("NETNEUTRALITY_PO_TOKEN");
String visitor = settings.get("NETNEUTRALITY_VISITOR_DATA");
dev.lavalink.youtube.clients.Web.setPoTokenAndVisitorData(po, visitor);
subscribeToEvents();
registerNode();
startReconnectWatchdog();
}
@Override
public void stop() {
for (MusicManager mm : managers.values()) {
mm.getQueueManager().clearQueue();
mm.getPlayer().destroy();
if (reconnectWatchdog != null) {
reconnectWatchdog.cancel(false);
reconnectWatchdog = null;
}
for (Disposable subscription : subscriptions) {
subscription.dispose();
}
subscriptions.clear();
for (MusicManager manager : managers.values()) {
manager.getQueueManager().clearQueue();
manager.destroy();
}
managers.clear();
client.close();
}
@Override
public MusicManager get(Guild guild) {
if (managers.containsKey(guild.getId())) {
return managers.get(guild.getId());
}
MusicManager manager = new MusicManager(guild, playerManager, guildServices.get(guild), taskManager);
managers.put(guild.getId(), manager);
return manager;
return managers.computeIfAbsent(
guild.getId(),
id -> new MusicManager(guild, client, guildServices.get(guild), taskManager)
);
}
@Override
@@ -93,11 +115,106 @@ public class MusicServiceImpl implements MusicService {
}
/**
* Returns the shared Lavaplayer {@link AudioPlayerManager}.
* Returns the shared Lavalink client.
*
* @return the player manager
* @return the client
*/
public AudioPlayerManager getPlayerManager() {
return playerManager;
@Override
public LavalinkClient getClient() {
return client;
}
/**
* Registers the configured node. A node that is unreachable at startup is retried by the
* client itself, so this never prevents the bot from booting.
*/
private void registerNode() {
String uri = settings.get("NETNEUTRALITY_LAVALINK_URI", DEFAULT_URI);
String password = settings.get("NETNEUTRALITY_LAVALINK_PASSWORD");
String name = settings.get("NETNEUTRALITY_LAVALINK_NAME", DEFAULT_NAME);
if (password == null) {
logger.error("No Lavalink password configured (NETNEUTRALITY_LAVALINK_PASSWORD), music will be unavailable");
return;
}
try {
client.addNode(new NodeOptions.Builder()
.setName(name)
.setServerUri(uri)
.setPassword(password)
.build());
logger.info("Registered Lavalink node '{}' at {}", name, uri);
} catch (RuntimeException e) {
logger.error("Failed to register Lavalink node '{}' at {}, music will be unavailable", name, uri, e);
}
}
/**
* Re-arms the client's own reconnect loop for nodes whose socket was closed.
*
* <p>The Lavalink client disables reconnection permanently when the node closes the socket
* with a normal close code (1000/1001), which is exactly what a graceful node restart does.
* Without this watchdog the bot would stay disconnected until it is restarted itself.
*/
private void startReconnectWatchdog() {
reconnectWatchdog = executors.getScheduledExecutor().scheduleWithFixedDelay(
this::rearmReconnects,
RECONNECT_WATCHDOG_SECONDS,
RECONNECT_WATCHDOG_SECONDS,
TimeUnit.SECONDS
);
}
private void rearmReconnects() {
try {
for (LavalinkNode node : client.getNodes()) {
LavalinkSocket socket = node.getWs();
if (!socket.getOpen() && !socket.getMayReconnect()) {
logger.warn("Lavalink node '{}' closed the connection normally, re-arming reconnects", node.getName());
socket.setMayReconnect(true);
}
}
} catch (RuntimeException e) {
logger.error("Failed to re-arm Lavalink reconnects", e);
}
}
/**
* Opens the single subscription to the client event flux and routes every guild-scoped event
* to the owning guild's {@link TrackScheduler}.
*/
private void subscribeToEvents() {
subscriptions.add(client.on(ReadyEvent.class)
.subscribe(event -> logger.info("Lavalink node '{}' is ready (session {})", event.getNode().getName(), event.getSessionId())));
subscriptions.add(client.on(TrackStartEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStart(event.getTrack()))));
subscriptions.add(client.on(TrackEndEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackEnd(event.getTrack(), event.getEndReason()))));
subscriptions.add(client.on(TrackExceptionEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackException(event.getTrack(), event.getException()))));
subscriptions.add(client.on(TrackStuckEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs()))));
subscriptions.add(client.on(WebSocketClosedEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote()))));
}
private void dispatch(long guildId, Consumer<TrackScheduler> action) {
MusicManager manager = managers.get(String.valueOf(guildId));
if (manager == null) {
logger.debug("Received a Lavalink event for unknown guild {}", guildId);
return;
}
try {
action.accept(manager.getScheduler());
} catch (RuntimeException e) {
logger.error("Guild {}: failed to handle Lavalink event", guildId, e);
}
}
}
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@@ -23,13 +22,11 @@ import java.util.function.Predicate;
public class QueueManager {
private final Logger logger = LogManager.getLogger(QueueManager.class);
private final TrackScheduler scheduler;
private final AudioPlayer player;
private final Guild guild;
private final Deque<TrackInfo> queue;
public QueueManager(TrackScheduler scheduler) {
this.scheduler = scheduler;
this.player = scheduler.getPlayer();
this.guild = scheduler.getGuild();
this.queue = new LinkedBlockingDeque<>();
@@ -110,20 +107,20 @@ public class QueueManager {
}
}
public List<AudioTrack> getAllQueuedTracks() {
public List<Track> getAllQueuedTracks() {
synchronized (queue) {
ArrayList<AudioTrack> tracks = new ArrayList<>();
ArrayList<Track> tracks = new ArrayList<>();
queue.forEach(trackInfo -> tracks.add(trackInfo.getTrack()));
return tracks;
}
}
public void queue(List<AudioTrack> tracks, Member member) {
public void queue(List<Track> tracks, Member member) {
tracks.forEach(track -> queue(track, member));
}
public void queue(AudioTrack track, Member member) {
public void queue(Track track, Member member) {
TrackInfo info = new TrackInfo(track, member);
synchronized (queue) {
@@ -131,42 +128,67 @@ public class QueueManager {
return;
}
if (player.getPlayingTrack() == null && !queue.isEmpty()) {
boolean idle = scheduler.getCurrentTrack() == null;
if (idle && !queue.isEmpty()) {
queue.addFirst(info);
} else {
queue.add(info);
}
if (player.getPlayingTrack() == null) {
AudioTrack toPlay = queue.peek() == null ? null : queue.peek().getTrack();
player.startTrack(toPlay, true);
if (idle) {
TrackInfo head = queue.peek();
if (head != null) {
startTrack(head.getTrack(), true);
}
}
}
}
/**
* Sends a play request to the node. The cached current track is updated eagerly so that a
* burst of enqueues does not start several tracks at once while waiting for the node's
* {@code TrackStartEvent}.
*
* @param track the track to play
* @param noReplace whether an already playing track must not be interrupted
*/
private void startTrack(Track track, boolean noReplace) {
scheduler.setCurrentTrack(track);
scheduler.getMusicManager().updatePlayer(
"start track " + track.getInfo().getIdentifier(),
builder -> builder.setTrack(track).setNoReplace(noReplace)
);
}
private void stopTrack() {
scheduler.setCurrentTrack(null);
scheduler.getMusicManager().stopTrack();
}
public CommandExecutionResult resume() {
synchronized (queue) {
TrackInfo next = queue.peek();
if (next != null) {
player.startTrack(next.getTrack().makeClone(), false);
return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().title);
startTrack(next.getTrack().makeClone(), false);
return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().getTitle());
}
player.stopTrack();
stopTrack();
return CommandExecutionResult.info("Queue is empty, leaving voice channel");
}
}
public void startNextTrack() {
synchronized (queue) {
TrackInfo last = queue.poll();
queue.poll();
TrackInfo next = queue.peek();
if (next != null) {
player.startTrack(next.getTrack().makeClone(), false);
startTrack(next.getTrack().makeClone(), false);
} else {
player.stopTrack();
stopTrack();
}
}
}
@@ -68,8 +68,10 @@ public class SourceFinder {
String identifier;
identifier = switch (getSource(url)) {
case Youtube, YTMusic, Soundcloud, Udio, DirectLinkMp3 -> url;
case Youtube, YTMusic, Soundcloud, DirectLinkMp3 -> url;
case YTSearch -> "ytsearch:" + url;
// Udio is not resolvable by the Lavalink node until the source is ported to a
// server-side plugin, see docs/reference/udio-lavalink-plugin.
default -> null;
};
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context;
import org.apache.logging.log4j.LogManager;
@@ -14,19 +13,19 @@ import org.apache.logging.log4j.Logger;
/**
* Encapsulates the retry-or-skip logic for stuck tracks, extracted from
* {@link TrackScheduler#onTrackStuck(AudioPlayer, AudioTrack, long)}.
* {@link TrackScheduler#onTrackStuck(Track, long)}.
*/
class StuckTrackRecoveryHandler {
private static final Logger logger = LogManager.getLogger(StuckTrackRecoveryHandler.class);
private final AudioPlayer player;
private final MusicManager manager;
private final QueueManager queueManager;
private AudioTrack lastStuckTrack;
private Track lastStuckTrack;
StuckTrackRecoveryHandler(AudioPlayer player, QueueManager queueManager) {
this.player = player;
StuckTrackRecoveryHandler(MusicManager manager, QueueManager queueManager) {
this.manager = manager;
this.queueManager = queueManager;
}
@@ -40,11 +39,11 @@ class StuckTrackRecoveryHandler {
/**
* Handles a stuck-track event. On the first occurrence for a given track
* identifier the handler attempts to clone and re-queue the track; on the
* identifier the handler attempts to re-queue the track; on the
* second consecutive occurrence it skips to the next track.
*/
void handle(AudioTrack track, Context context) {
if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) {
void handle(Track track, Context context) {
if (lastStuckTrack == null || !identifierOf(lastStuckTrack).equals(identifierOf(track))) {
lastStuckTrack = track;
attemptRecovery(track, context);
} else {
@@ -53,28 +52,26 @@ class StuckTrackRecoveryHandler {
}
}
private void attemptRecovery(AudioTrack track, Context context) {
logger.debug("Trying to load stuck track again: {}", track.getIdentifier());
private void attemptRecovery(Track track, Context context) {
logger.debug("Trying to load stuck track again: {}", identifierOf(track));
context.async("Current track is stuck, trying to load it again", asyncContext -> {
try {
TrackInfo info = queueManager.peekHeadOfQueue();
if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) {
AudioTrack clone = track.makeClone();
long lastPosition = track.getPosition();
if (info != null && identifierOf(info.getTrack()).equals(identifierOf(track))) {
long lastPosition = manager.getCurrentPosition();
player.stopTrack();
manager.stopTrack();
if (queueManager.peekHeadOfQueue() != null
&& queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) {
TrackInfo head = queueManager.peekHeadOfQueue();
if (head != null && identifierOf(head.getTrack()).equals(identifierOf(track))) {
queueManager.removeHeadOfQueue();
}
queueManager.queue(clone, info.getRequester());
queueManager.queue(track.makeClone(), info.getRequester());
AudioTrack playing = player.getPlayingTrack();
if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) {
playing.setPosition(lastPosition);
if (lastPosition > 0) {
manager.updatePlayer("seek to " + lastPosition, builder -> builder.setPosition(lastPosition));
lastStuckTrack = null;
} else {
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
@@ -90,4 +87,8 @@ class StuckTrackRecoveryHandler {
}
});
}
private static String identifierOf(Track track) {
return track.getInfo().getIdentifier();
}
}
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -16,18 +16,18 @@ import java.util.Objects;
import java.util.Set;
public class TrackInfo {
private final AudioTrack track;
private final Track track;
private final Member member;
private final Set<String> skips;
private static final Logger logger = LogManager.getLogger(TrackInfo.class);
public TrackInfo(AudioTrack track, Member member) {
public TrackInfo(Track track, Member member) {
this.track = track;
this.member = member;
this.skips = new HashSet<>();
}
public AudioTrack getTrack() {
public Track getTrack() {
return track;
}
@@ -54,6 +54,11 @@ public class TrackInfo {
return member;
}
/**
* Two entries are equal when they wrap the same encoded track, which is what makes the queue
* reject duplicates. The Lavalink {@link Track} type has no value equality, so the encoded
* payload is compared instead.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -63,18 +68,18 @@ public class TrackInfo {
return false;
}
TrackInfo trackInfo = (TrackInfo) o;
return track.equals(trackInfo.track);
return track.getEncoded().equals(trackInfo.track.getEncoded());
}
@Override
public int hashCode() {
return Objects.hash(track);
return Objects.hash(track.getEncoded());
}
@Override
public String toString() {
return "TrackInfo{" +
"track=" + track.getInfo().title +
"track=" + track.getInfo().getTitle() +
", member=" + member.getEffectiveName() +
", skips=" + skips +
'}';
@@ -6,20 +6,27 @@ package me.bobert.netneutrality.music;
*/
import me.bobert.netneutrality.utils.MusicUtils;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
import dev.arbjerg.lavalink.client.player.LoadFailed;
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
import dev.arbjerg.lavalink.client.player.SearchResult;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.tasks.music.LoadPlaylistTask;
import me.bobert.netneutrality.shared.util.MessageFunction;
import me.bobert.netneutrality.tasks.music.LoadPlaylistTask;
import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class TrackLoadingHandler implements AudioLoadResultHandler {
import java.util.List;
/**
* Translates a Lavalink load result into queue operations and user feedback.
*/
public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
private static final Logger logger = LogManager.getLogger(TrackLoadingHandler.class);
private final String identifier;
@@ -42,24 +49,42 @@ public class TrackLoadingHandler implements AudioLoadResultHandler {
}
@Override
public void trackLoaded(AudioTrack audioTrack) {
logger.debug("Guild {}: track loaded: {}", guild.getId(), audioTrack.getIdentifier());
public void ontrackLoaded(TrackLoaded result) {
Track track = result.getTrack();
logger.debug("Guild {}: track loaded: {}", guild.getId(), track.getInfo().getIdentifier());
context.deleteMessage();
if (MusicUtils.connect(context, manager)) {
queueManager.queue(audioTrack, context.getGuildUser());
queueManager.queue(track, context.getGuildUser());
if (!silent) {
context.reply(MessageFunction.buildAddingTrackMessage(audioTrack, context.getGuildUser(), type));
context.reply(MessageFunction.buildAddingTrackMessage(track, context.getGuildUser(), type));
}
}
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
public void onPlaylistLoaded(PlaylistLoaded result) {
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
new LoadPlaylistTask(playlist, type, manager).insert(context);
new LoadPlaylistTask(result.getInfo().getName(), result.getTracks(), false, type, manager).insert(context);
}
/**
* Search results keep the pre-migration behaviour: the user is offered the first few hits
* through the same playlist task that lavaplayer's search playlists used to produce.
*/
@Override
public void onSearchResultLoaded(SearchResult result) {
List<Track> tracks = result.getTracks();
logger.debug("Guild {}: search returned {} results for {}", guild.getId(), tracks.size(), identifier);
if (tracks.isEmpty()) {
noMatches();
return;
}
new LoadPlaylistTask("Search results", tracks, true, type, manager).insert(context);
}
@Override
@@ -72,11 +97,11 @@ public class TrackLoadingHandler implements AudioLoadResultHandler {
}
@Override
public void loadFailed(FriendlyException e) {
public void loadFailed(LoadFailed result) {
logger.debug("Guild {}: load failed for identifier {}", guild.getId(), identifier);
//TODO: better error messages
RuntimeException exception = new RuntimeException("Failed to load track: " + e.getMessage(), e);
RuntimeException exception = new RuntimeException("Failed to load track: " + result.getException().getMessage());
LogHandler.log(exception);
if (!silent) {
@@ -5,11 +5,9 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackException;
import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
import me.bobert.netneutrality.context.MusicChannelContext;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context;
@@ -19,23 +17,28 @@ import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class TrackScheduler extends AudioEventAdapter {
/**
* Sink for the track lifecycle events emitted by the Lavalink node.
*
* <p>Events are delivered by the central dispatcher in {@link MusicServiceImpl}, which routes
* them to the scheduler of the guild they belong to.
*/
public class TrackScheduler {
private static final Logger logger = LogManager.getLogger(TrackScheduler.class);
private final QueueManager queueManager;
private final MusicManager manager;
private final MusicManagerCallback callback;
private final AudioPlayer player;
private final Guild guild;
private volatile Context context;
private volatile Track currentTrack;
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
public TrackScheduler(MusicManager manager) {
this.manager = manager;
this.callback = manager;
this.player = manager.getPlayer();
this.guild = manager.getGuild();
this.queueManager = new QueueManager(this);
this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(player, queueManager);
this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(manager, queueManager);
}
public QueueManager getQueueManager() {
@@ -50,14 +53,26 @@ public class TrackScheduler extends AudioEventAdapter {
return callback;
}
public AudioPlayer getPlayer() {
return player;
}
public Guild getGuild() {
return guild;
}
/**
* Returns the track the node is currently playing.
*
* <p>The Lavalink player state is only readable through a REST call, so the last known track
* is cached here and kept up to date by the node's events.
*
* @return the current track or {@code null}
*/
public Track getCurrentTrack() {
return currentTrack;
}
void setCurrentTrack(Track track) {
this.currentTrack = track;
}
public Member getDj() {
Context ctx = context;
if (ctx != null) {
@@ -83,36 +98,30 @@ public class TrackScheduler extends AudioEventAdapter {
return stuckTrackRecoveryHandler;
}
@Override
public void onPlayerPause(AudioPlayer player) {
// Player was paused
}
@Override
public void onPlayerResume(AudioPlayer player) {
// Player was resumed
}
@Override
public void onTrackStart(AudioPlayer player, AudioTrack track) {
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getIdentifier());
public void onTrackStart(Track track) {
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier());
currentTrack = track;
stuckTrackRecoveryHandler.reset();
}
@Override
public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) {
logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getIdentifier());
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getInfo().getIdentifier());
if (endReason == AudioTrackEndReason.LOAD_FAILED) {
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getIdentifier());
if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) {
currentTrack = null;
}
if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getInfo().getIdentifier());
}
// check if mayStartNext
if (endReason == AudioTrackEndReason.FINISHED || endReason == AudioTrackEndReason.LOAD_FAILED) {
if (endReason == TrackEndEvent.AudioTrackEndReason.FINISHED
|| endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
queueManager.startNextTrack();
}
if (endReason == AudioTrackEndReason.STOPPED) {
if (endReason == TrackEndEvent.AudioTrackEndReason.STOPPED) {
// maybe skip removing head to make resuming easier?
queueManager.removeHeadOfQueue();
}
@@ -125,16 +134,23 @@ public class TrackScheduler extends AudioEventAdapter {
// clone of this back to your queue
}
@Override
public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getIdentifier());
public void onTrackException(Track track, TrackException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier());
LogHandler.log(new ContextException(String.format("Error while playing track: %s", track.getInfo().title), getContext(), exception));
LogHandler.log(new ContextException(
String.format("Error while playing track: %s", track.getInfo().getTitle()),
getContext(),
new RuntimeException(exception.getMessage())
));
}
@Override
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {}", guild.getId(), track.getIdentifier());
public void onTrackStuck(Track track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs);
stuckTrackRecoveryHandler.handle(track, context);
}
public void onWebSocketClosed(int code, String reason, boolean byRemote) {
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
currentTrack = null;
}
}
@@ -1,237 +0,0 @@
package me.bobert.netneutrality.music.source;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager;
import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager;
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
public class UdioAudioSourceManager implements AudioSourceManager {
private static final Logger log = LogManager.getLogger(UdioAudioSourceManager.class);
private static final String TRACK_URL_REGEX = "^https://www.udio.com/songs/[0-9A-Za-z]+$";
private static final Pattern trackUrlPattern = Pattern.compile(TRACK_URL_REGEX);
private static final Pattern NEXT_PUSH_PATTERN = Pattern.compile(
"self\\.__next_f\\.push\\(\\[(\\d+),\"(.*?)\"]\\)",
Pattern.DOTALL
);
private final HttpInterfaceManager httpInterfaceManager;
public UdioAudioSourceManager() {
httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager();
}
@Override
public String getSourceName() {
return "udio";
}
@Override
public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference) {
if (!trackUrlPattern.matcher(reference.identifier).matches()) {
return null;
}
try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) {
return loadFromTrackPage(httpInterface, reference.identifier);
} catch (IOException e) {
throw new FriendlyException("Loading Udio track information failed.", SUSPICIOUS, e);
}
}
@Override
public boolean isTrackEncodable(AudioTrack track) {
return true;
}
@Override
public void encodeTrack(AudioTrack track, DataOutput output) {
}
@Override
public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) {
return new UdioAudioTrack(trackInfo, this);
}
@Override
public void shutdown() {
ExceptionTools.closeWithWarnings(httpInterfaceManager);
}
public HttpInterface getHttpInterface() {
return httpInterfaceManager.getInterface();
}
private AudioItem loadFromTrackPage(HttpInterface httpInterface, String trackUrl) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackUrl))) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == HttpStatus.SC_NOT_FOUND) {
return AudioReference.NO_TRACK;
} else if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
new IllegalStateException("Response code is " + statusCode));
}
return loadTrackFromPageContent(trackUrl, IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8));
}
}
private AudioTrack loadTrackFromPageContent(String trackUrl, String content) throws IOException {
JsonBrowser trackInfo = loadTrackInfoFromPageContent(content);
if (trackInfo == null) {
log.debug("Udio HTML: {}", content);
throw new FriendlyException("Udio: Failed to extract track information from web page.", SUSPICIOUS, null);
}
String title = trackInfo.get("title").text();
String artist = trackInfo.get("artist").text();
long duration = (long) (trackInfo.get("duration").as(Double.class) * 1000.0);
String playbackUrl = trackInfo.get("song_path").text();
String artworkUrl = getArtworkUrlFromImagePath(trackInfo);
AudioTrackInfo audioTrackInfo = new AudioTrackInfo(title, artist, duration, trackUrl, false, playbackUrl, artworkUrl, null);
return new UdioAudioTrack(audioTrackInfo, this);
}
protected JsonBrowser loadTrackInfoFromPageContent(String content) throws IOException {
Matcher matcher = NEXT_PUSH_PATTERN.matcher(content);
while (matcher.find()) {
String payload = matcher.group(2);
JsonBrowser trackInfo = tryExtractTrackFromNextChunk(payload);
if (trackInfo != null) {
return trackInfo;
}
}
return null;
}
private JsonBrowser tryExtractTrackFromNextChunk(String payload) {
try {
int trackKeyIndex = payload.indexOf("\\\"track\\\":{");
if (trackKeyIndex < 0) {
return null;
}
int objectStart = payload.indexOf('{', trackKeyIndex);
if (objectStart < 0) {
return null;
}
String escapedTrackJson = extractJsonObjectFromString(payload, objectStart);
if (escapedTrackJson == null) {
return null;
}
String trackJson = unescapeJsonStringFragment(escapedTrackJson);
JsonBrowser track = JsonBrowser.parse(trackJson);
if (isValidTrack(track)) {
return track;
}
} catch (Exception e) {
log.debug("Skipping unparsable track payload fragment", e);
}
return null;
}
/**
* Extracts a JSON object from a given string starting at the specified index. This method parses the string
* to locate and retrieve the JSON object, ensuring proper handling of nested structures and escaped characters.
*
* @param text The input string containing the JSON object to be extracted.
* @param startIndex The index at which to start searching for the JSON object within the input string.
* @return The extracted JSON object as a string, or {@code null} if no valid JSON object is found.
*/
private String extractJsonObjectFromString(String text, int startIndex) {
boolean escaped = false;
boolean inString = false;
int depth = 0;
for (int i = startIndex; i < text.length(); i++) {
char c = text.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
if (c == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
if (depth == 0) {
return text.substring(startIndex, i + 1);
}
}
}
return null;
}
private String unescapeJsonStringFragment(String value) {
return value.replace("\\\"", "\"").replace("\\\\", "\\");
}
private boolean isValidTrack(JsonBrowser track) {
return track != null
&& !track.isNull()
&& !track.get("title").isNull()
&& !track.get("artist").isNull()
&& !track.get("duration").isNull()
&& !track.get("song_path").isNull()
&& !track.get("image_path").isNull();
}
private String getArtworkUrlFromImagePath(JsonBrowser trackInfo) {
String imagePath = trackInfo.get("image_path").text();
String artworkOptions = "width=3840,quality=75";
return imagePath.replace("public", artworkOptions);
}
}
@@ -1,80 +0,0 @@
package me.bobert.netneutrality.music.source;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.container.mp3.Mp3AudioTrack;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack;
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
public class UdioAudioTrack extends DelegatedAudioTrack {
private static final Logger log = LogManager.getLogger(UdioAudioTrack.class);
private final UdioAudioSourceManager sourceManager;
/**
* @param trackInfo Track info
* @param sourceManager Source manager which was used to find this track
*/
public UdioAudioTrack(AudioTrackInfo trackInfo, UdioAudioSourceManager sourceManager) {
super(trackInfo);
this.sourceManager = sourceManager;
}
@Override
public void process(LocalAudioTrackExecutor localExecutor) throws Exception {
try (HttpInterface httpInterface = sourceManager.getHttpInterface()) {
String playbackUrl = loadPlaybackUrl(httpInterface);
log.debug("Starting Udio track from URL: {}", playbackUrl);
try (PersistentHttpStream stream = new PersistentHttpStream(httpInterface, new URI(playbackUrl), null)) {
processDelegate(new Mp3AudioTrack(trackInfo, stream), localExecutor);
}
}
}
private String loadPlaybackUrl(HttpInterface httpInterface) throws IOException {
JsonBrowser trackInfo = loadTrackInfo(httpInterface);
if (trackInfo == null) {
throw new FriendlyException("Udio: Failed to extract track information from web page.", SUSPICIOUS, null);
}
return trackInfo.get("song_path").text();
}
private JsonBrowser loadTrackInfo(HttpInterface httpInterface) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackInfo.identifier))) {
int statusCode = response.getStatusLine().getStatusCode();
if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
new IllegalStateException("Response code for player config is " + statusCode));
}
return sourceManager.loadTrackInfoFromPageContent(IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8));
}
}
}
@@ -50,7 +50,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
}
private void processManager(MusicManager manager) throws InterruptedException {
AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel();
AudioChannel channel = manager.getConnectedChannel();
if (channel != null) {
logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", manager.getGuild().getId());
@@ -68,7 +68,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
}
private boolean conditionsMetToDisconnect(MusicManager manager) {
AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel();
AudioChannel channel = manager.getConnectedChannel();
if (channel != null) {
List<Member> members = channel.getMembers().stream().filter(member -> !member.getId().equals(channel.getJDA().getSelfUser().getId())).toList();
@@ -6,9 +6,8 @@ package me.bobert.netneutrality.shared.util;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.protocol.v4.TrackInfo;
import io.sentry.Sentry;
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.command.CommandCategory;
@@ -20,7 +19,6 @@ import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.music.TrackInfo;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
@@ -40,32 +38,30 @@ public class MessageFunction {
private static final Logger logger = LogManager.getLogger(MessageFunction.class);
public static MessageEmbed buildCurrentlyPlayingMessage(MusicManager manager) {
AudioTrack current = manager.getCurrentlyPlaying();
Track current = manager.getCurrentlyPlaying();
TrackInfo currentInfo = current.getInfo();
EmbedBuilder message = new EmbedBuilder();
message.setAuthor("Link to track (click me)", String.format("https://youtu.be/%s", current.getIdentifier()));
message.setThumbnail(String.format("https://img.youtube.com/vi/%s/default.jpg", current.getIdentifier()));
message.setAuthor("Link to track (click me)", String.format("https://youtu.be/%s", currentInfo.getIdentifier()));
message.setThumbnail(String.format("https://img.youtube.com/vi/%s/default.jpg", currentInfo.getIdentifier()));
//message.setTitle("Currently playing:");
message.setColor(Color.BLUE);
message.addField("Currently playing: ", current.getInfo().title, true);
message.addField("Duration: ", DurationFormatUtils.formatDuration(current.getPosition(), "HH:mm:ss", true) + " / " + DurationFormatUtils.formatDuration(current.getDuration(), "HH:mm:ss", true), true);
message.addField("Currently playing: ", currentInfo.getTitle(), true);
message.addField("Duration: ", DurationFormatUtils.formatDuration(manager.getCurrentPosition(), "HH:mm:ss", true) + " / " + DurationFormatUtils.formatDuration(currentInfo.getLength(), "HH:mm:ss", true), true);
message.setTimestamp(OffsetDateTime.now());
return message.build();
}
public static MessageEmbed buildAddingTrackMessage(AudioTrack track, Member member, SourceFinder.AudioSourceType type) {
public static MessageEmbed buildAddingTrackMessage(Track track, Member member, SourceFinder.AudioSourceType type) {
EmbedBuilder message = new EmbedBuilder();
String url = track.getIdentifier();
String thumbnail = null;
TrackInfo info = track.getInfo();
String url = info.getIdentifier();
String thumbnail = info.getArtworkUrl();
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch || type == SourceFinder.AudioSourceType.YTMusic) {
url = String.format("https://youtu.be/%s", track.getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", track.getIdentifier());
}
if (type == SourceFinder.AudioSourceType.Udio) {
thumbnail = track.getInfo().artworkUrl;
url = String.format("https://youtu.be/%s", info.getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", info.getIdentifier());
}
if (thumbnail != null) {
@@ -73,26 +69,26 @@ public class MessageFunction {
}
message.setTitle("Adding track to queue..");
message.setColor(Color.BLUE);
message.addField("Track title: ", String.format("[**`%s`**](%s)", track.getInfo().title, url), false);
message.addField("Duration: ", DurationFormatUtils.formatDuration(track.getDuration(), "**HH:mm:ss**", true), false);
message.addField("Track title: ", String.format("[**`%s`**](%s)", info.getTitle(), url), false);
message.addField("Duration: ", DurationFormatUtils.formatDuration(info.getLength(), "**HH:mm:ss**", true), false);
message.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
message.setTimestamp(OffsetDateTime.now());
return message.build();
}
public static MessageEmbed buildAddingPlaylistMessage(List<AudioTrack> tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) {
public static MessageEmbed buildAddingPlaylistMessage(List<Track> tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) {
EmbedBuilder message = new EmbedBuilder();
String url = "";
String thumbnail = null;
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch) {
url = String.format("https://youtu.be/%s", tracks.get(0).getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getIdentifier());
url = String.format("https://youtu.be/%s", tracks.get(0).getInfo().getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier());
}
if (type == SourceFinder.AudioSourceType.YTMusic) {
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier());
}
if (thumbnail != null) {
@@ -109,13 +105,13 @@ public class MessageFunction {
}
public static String buildViewQueueMessage(MusicManager manager) {
List<AudioTrack> tracks = manager.getQueueManager().getAllQueuedTracks();
List<Track> tracks = manager.getQueueManager().getAllQueuedTracks();
if (tracks.isEmpty()) {
return "Queue is empty!";
}
int limit = 10;
AudioTrack current = manager.getCurrentlyPlaying();
Track current = manager.getCurrentlyPlaying();
int numberofTracksShown = Math.min(tracks.size(), limit);
StringBuilder message = new StringBuilder();
@@ -127,15 +123,15 @@ public class MessageFunction {
.append(" items):\n");
int index = 0;
for (AudioTrack track : tracks) {
for (Track track : tracks) {
index++;
AudioTrackInfo info = track.getInfo();
TrackInfo info = track.getInfo();
message.append("\n");
if (current != null && current.getInfo().title.equals(info.title)) {
message.append("- ").append("**").append(info.title).append("**");
if (current != null && current.getInfo().getTitle().equals(info.getTitle())) {
message.append("- ").append("**").append(info.getTitle()).append("**");
} else {
message.append("- ").append(info.title);
message.append("- ").append(info.getTitle());
}
if (index == limit) {
@@ -147,8 +143,8 @@ public class MessageFunction {
}
public static MessageEmbed buildUserTracksMessage(MusicManager manager, Member member) {
List<TrackInfo> tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester()));
AudioTrack current = manager.getCurrentlyPlaying();
List<me.bobert.netneutrality.music.TrackInfo> tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester()));
Track current = manager.getCurrentlyPlaying();
int numberofTracksShown = Math.min(tracks.size(), 10);
EmbedBuilder message = new EmbedBuilder();
@@ -156,13 +152,13 @@ public class MessageFunction {
message.setColor(Color.BLUE);
int index = 0;
for (TrackInfo track : tracks) {
AudioTrackInfo info = track.getTrack().getInfo();
for (me.bobert.netneutrality.music.TrackInfo track : tracks) {
TrackInfo info = track.getTrack().getInfo();
if (current != null && current.getInfo().title.equals(info.title)) {
message.addField("Track #" + ++index + " (Currently playing)", info.title, false);
if (current != null && current.getInfo().getTitle().equals(info.getTitle())) {
message.addField("Track #" + ++index + " (Currently playing)", info.getTitle(), false);
} else {
message.addField("Track #" + ++index, info.title, false);
message.addField("Track #" + ++index, info.getTitle(), false);
}
if (index == 10) {
@@ -174,7 +170,7 @@ public class MessageFunction {
return message.build();
}
public static MessageEmbed buildSearchResultsMessage(List<AudioTrack> tracks, Member member) {
public static MessageEmbed buildSearchResultsMessage(List<Track> tracks, Member member) {
EmbedBuilder embed = new EmbedBuilder();
embed.setTitle("Search results");
@@ -182,8 +178,8 @@ public class MessageFunction {
embed.setColor(Color.MAGENTA);
int i = 0;
for (AudioTrack track : tracks) {
embed.addField(++i + ": ", track.getInfo().title, false);
for (Track track : tracks) {
embed.addField(++i + ": ", track.getInfo().getTitle(), false);
}
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
@@ -192,11 +188,11 @@ public class MessageFunction {
return embed.build();
}
public static MessageEmbed buildPlaylistLoadMessage(AudioPlaylist playlist, Member member) {
public static MessageEmbed buildPlaylistLoadMessage(String playlistName, int trackCount, Member member) {
EmbedBuilder embed = new EmbedBuilder();
embed.setTitle(String.format("Loading playlist: %s", playlist.getName()));
embed.setDescription(String.format("You are about to load %d tracks from this playlist, are you sure?", playlist.getTracks().size()));
embed.setTitle(String.format("Loading playlist: %s", playlistName));
embed.setDescription(String.format("You are about to load %d tracks from this playlist, are you sure?", trackCount));
embed.setColor(Color.CYAN);
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.tasks.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
@@ -28,13 +27,18 @@ import java.util.List;
public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable {
private final List<Task> childrens = new ArrayList<>();
private final AudioPlaylist playlist;
private final String playlistName;
private final List<Track> playlistTracks;
private final boolean searchResult;
private final SourceFinder.AudioSourceType sourceType;
private final MusicManager musicManager;
public LoadPlaylistTask(AudioPlaylist playlist, SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
public LoadPlaylistTask(String playlistName, List<Track> playlistTracks, boolean searchResult,
SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
super(musicManager.getTaskManager(), null, null);
this.playlist = playlist;
this.playlistName = playlistName;
this.playlistTracks = playlistTracks;
this.searchResult = searchResult;
this.sourceType = sourceType;
this.musicManager = musicManager;
}
@@ -51,7 +55,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
@Override
public void replyToInteraction(CommandContext context) {
if (playlist.getTracks().isEmpty()) {
if (playlistTracks.isEmpty()) {
context.reply("No tracks found");
return;
}
@@ -62,12 +66,12 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
.getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE));
// Search results
if (playlist.isSearchResult()) {
List<AudioTrack> searchResults = playlist.getTracks().stream().limit(searchResultsLimit).toList();
if (searchResult) {
List<Track> searchResults = playlistTracks.stream().limit(searchResultsLimit).toList();
List<Task> loadTasks = new ArrayList<>();
for (int i = 0; i < searchResults.size(); i++) {
AudioTrack track = searchResults.get(i);
Track track = searchResults.get(i);
loadTasks.add(new LoadTrackFromPlaylistTask(this, String.valueOf(i + 1), track, musicManager));
}
@@ -79,11 +83,11 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
return;
}
List<AudioTrack> tracks = playlist.getTracks();
List<Track> tracks = playlistTracks;
if (tracks.size() > maxPlaylistSizeBeforeAsking) {
ActionRow firstRow = commonButtons(playlist.getTracks(), "I'm ready", "Please don't");
ActionRow firstRow = commonButtons(tracks, "I'm ready", "Please don't");
context.edit(MessageFunction.buildPlaylistLoadMessage(playlist, context.getGuildUser()), List.of(firstRow));
context.edit(MessageFunction.buildPlaylistLoadMessage(playlistName, tracks.size(), context.getGuildUser()), List.of(firstRow));
return;
}
@@ -91,17 +95,17 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
if (MusicUtils.connect(context, musicManager)) {
musicManager.getQueueManager().queue(tracks, context.getGuildUser());
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), context.getGuildUser(), sourceType));
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, context.getGuildUser(), sourceType));
}
}
private ActionRow commonButtons(List<AudioTrack> tracks, String addAllText, String cancelText) {
private ActionRow commonButtons(List<Track> tracks, String addAllText, String cancelText) {
Task accept = AcceptButton.createSuccessButton(taskManager(), this, addAllText, context -> {
Member member = context.getGuildUser();
if (MusicUtils.connect(context, musicManager)) {
musicManager.getQueueManager().queue(tracks, member);
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), member, SourceFinder.AudioSourceType.Youtube));
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, member, SourceFinder.AudioSourceType.Youtube));
return Lifecycle.REMOVE_SELF_AND_CHILDREN;
}
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.tasks.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.core.tasks.generic.AcceptButton;
@@ -15,9 +15,9 @@ import net.dv8tion.jda.api.components.buttons.ButtonStyle;
import net.dv8tion.jda.api.entities.Member;
public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
private final AudioTrack track;
private final Track track;
public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, AudioTrack track, MusicManager musicManager) {
public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, Track track, MusicManager musicManager) {
super(musicManager.getTaskManager(), parentTask, ButtonStyle.PRIMARY, buttonLabel, context -> {
Member member = context.getGuildUser();
@@ -36,6 +36,6 @@ public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
@Override
public String getName() {
return String.format("load-track-%s", track.getIdentifier());
return String.format("load-track-%s", track.getInfo().getIdentifier());
}
}