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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -5,9 +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 com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
||||
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;
|
||||
@@ -21,22 +19,20 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
public class QueueManagerTest {
|
||||
|
||||
private AudioPlayer player;
|
||||
private TrackScheduler scheduler;
|
||||
private MusicManager musicManager;
|
||||
private MusicManagerCallback callback;
|
||||
private QueueManager queueManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
player = mock(AudioPlayer.class);
|
||||
Guild guild = mock(Guild.class);
|
||||
when(guild.getId()).thenReturn("guild1");
|
||||
|
||||
TrackScheduler scheduler = mock(TrackScheduler.class);
|
||||
scheduler = mock(TrackScheduler.class);
|
||||
musicManager = mock(MusicManager.class);
|
||||
callback = mock(MusicManagerCallback.class);
|
||||
|
||||
when(scheduler.getPlayer()).thenReturn(player);
|
||||
when(scheduler.getGuild()).thenReturn(guild);
|
||||
when(scheduler.getMusicManager()).thenReturn(musicManager);
|
||||
when(scheduler.getCallback()).thenReturn(callback);
|
||||
@@ -44,11 +40,14 @@ public class QueueManagerTest {
|
||||
queueManager = new QueueManager(scheduler);
|
||||
}
|
||||
|
||||
private AudioTrack mockTrack(String id) {
|
||||
AudioTrack track = mock(AudioTrack.class);
|
||||
when(track.getIdentifier()).thenReturn(id);
|
||||
AudioTrackInfo info = new AudioTrackInfo("Title " + id, "Author", 1000L, id, false, "http://example.com/" + id);
|
||||
static Track mockTrack(String id) {
|
||||
Track track = mock(Track.class);
|
||||
dev.arbjerg.lavalink.protocol.v4.TrackInfo info = mock(dev.arbjerg.lavalink.protocol.v4.TrackInfo.class);
|
||||
when(info.getIdentifier()).thenReturn(id);
|
||||
when(info.getTitle()).thenReturn("Title " + id);
|
||||
when(info.getLength()).thenReturn(1000L);
|
||||
when(track.getInfo()).thenReturn(info);
|
||||
when(track.getEncoded()).thenReturn("encoded-" + id);
|
||||
when(track.makeClone()).thenReturn(track);
|
||||
return track;
|
||||
}
|
||||
@@ -64,23 +63,24 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void queueAddsToTailAndStartsPlaybackWhenEmpty() {
|
||||
AudioTrack track = mockTrack("t1");
|
||||
Track track = mockTrack("t1");
|
||||
Member member = mockMember("u1");
|
||||
when(player.getPlayingTrack()).thenReturn(null);
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null);
|
||||
|
||||
queueManager.queue(track, member);
|
||||
|
||||
assertEquals(1, queueManager.getQueueSize());
|
||||
verify(player).startTrack(track, true);
|
||||
verify(scheduler).setCurrentTrack(track);
|
||||
verify(musicManager).updatePlayer(contains("t1"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queueAddsToTailWhenAlreadyPlaying() {
|
||||
AudioTrack t1 = mockTrack("t1");
|
||||
AudioTrack t2 = mockTrack("t2");
|
||||
Track t1 = mockTrack("t1");
|
||||
Track t2 = mockTrack("t2");
|
||||
Member member = mockMember("u1");
|
||||
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||
|
||||
queueManager.queue(t1, member);
|
||||
queueManager.queue(t2, member);
|
||||
@@ -93,31 +93,28 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void queueAddsToFrontWhenNothingPlayingAndQueueNotEmpty() {
|
||||
AudioTrack t1 = mockTrack("t1");
|
||||
AudioTrack t2 = mockTrack("t2");
|
||||
Track t1 = mockTrack("t1");
|
||||
Track t2 = mockTrack("t2");
|
||||
Member member = mockMember("u1");
|
||||
|
||||
// First queue: nothing playing, queue empty -> add to tail, start track
|
||||
when(player.getPlayingTrack()).thenReturn(null);
|
||||
queueManager.queue(t1, member);
|
||||
// Nothing playing on both calls: the second track jumps to the head so that it is the
|
||||
// one actually started.
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null);
|
||||
|
||||
// Second queue: nothing playing (startTrack with noInterrupt=true didn't actually start),
|
||||
// queue not empty -> should addFirst so t2 is at head and gets played
|
||||
when(player.getPlayingTrack()).thenReturn(null);
|
||||
queueManager.queue(t1, member);
|
||||
queueManager.queue(t2, member);
|
||||
|
||||
assertEquals(2, queueManager.getQueueSize());
|
||||
List<TrackInfo> q = queueManager.getQueue();
|
||||
// t2 should be at the front since nothing was playing and queue was not empty
|
||||
assertSame(t2, q.get(0).getTrack());
|
||||
assertSame(t1, q.get(1).getTrack());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queueRejectsDuplicate() {
|
||||
AudioTrack track = mockTrack("t1");
|
||||
Track track = mockTrack("t1");
|
||||
Member member = mockMember("u1");
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(track);
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(track);
|
||||
|
||||
queueManager.queue(track, member);
|
||||
queueManager.queue(track, member);
|
||||
@@ -136,7 +133,7 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void clearQueueRemovesAllAndCallsCallback() {
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
queueManager.queue(mockTrack("t2"), mockMember("u1"));
|
||||
|
||||
@@ -158,10 +155,10 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void skipSingleTrack() {
|
||||
AudioTrack t1 = mockTrack("t1");
|
||||
AudioTrack t2 = mockTrack("t2");
|
||||
Track t1 = mockTrack("t1");
|
||||
Track t2 = mockTrack("t2");
|
||||
Member member = mockMember("u1");
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||
|
||||
queueManager.queue(t1, member);
|
||||
queueManager.queue(t2, member);
|
||||
@@ -174,18 +171,16 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void skipMoreThanQueueSizeSkipsAll() {
|
||||
AudioTrack t1 = mockTrack("t1");
|
||||
AudioTrack t2 = mockTrack("t2");
|
||||
Track t1 = mockTrack("t1");
|
||||
Track t2 = mockTrack("t2");
|
||||
Member member = mockMember("u1");
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||
|
||||
queueManager.queue(t1, member);
|
||||
queueManager.queue(t2, member);
|
||||
|
||||
// skip(10) should clamp to queue.size() = 2, removing all
|
||||
CommandExecutionResult result = queueManager.skip(10);
|
||||
// After removing (nbTrack-1)=1 via poll, queue has 1 left, then startNextTrack polls that
|
||||
// Actually with nbTrack clamped to 2: poll 1 track, then startNextTrack polls the last
|
||||
queueManager.skip(10);
|
||||
assertTrue(queueManager.isEmpty());
|
||||
}
|
||||
|
||||
@@ -193,8 +188,8 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void shuffleTooSmallQueueReturnsWarn() {
|
||||
AudioTrack t1 = mockTrack("t1");
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
|
||||
Track t1 = mockTrack("t1");
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
|
||||
queueManager.queue(t1, mockMember("u1"));
|
||||
|
||||
CommandExecutionResult result = queueManager.shuffle();
|
||||
@@ -204,10 +199,10 @@ public class QueueManagerTest {
|
||||
@Test
|
||||
public void shufflePreservesCurrentTrackAtHead() {
|
||||
Member member = mockMember("u1");
|
||||
AudioTrack playing = mockTrack("t1");
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(playing).thenReturn(playing).thenReturn(playing).thenReturn(playing);
|
||||
Track playing = mockTrack("t1");
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(playing);
|
||||
|
||||
queueManager.queue(mockTrack("t1"), member);
|
||||
queueManager.queue(playing, member);
|
||||
queueManager.queue(mockTrack("t2"), member);
|
||||
queueManager.queue(mockTrack("t3"), member);
|
||||
queueManager.queue(mockTrack("t4"), member);
|
||||
@@ -218,7 +213,7 @@ public class QueueManagerTest {
|
||||
// First track should still be t1
|
||||
List<TrackInfo> q = queueManager.getQueue();
|
||||
assertEquals(4, q.size());
|
||||
assertEquals("t1", q.get(0).getTrack().getIdentifier());
|
||||
assertEquals("t1", q.get(0).getTrack().getInfo().getIdentifier());
|
||||
}
|
||||
|
||||
// --- RemoveFromQueue ---
|
||||
@@ -231,7 +226,7 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void removeFromQueueOutOfBoundsReturnsError() {
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
|
||||
CommandExecutionResult result = queueManager.removeFromQueue(5);
|
||||
@@ -240,7 +235,7 @@ public class QueueManagerTest {
|
||||
|
||||
@Test
|
||||
public void removeFromQueueIndexZeroReturnsNone() {
|
||||
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
|
||||
queueManager.queue(mockTrack("t1"), mockMember("u1"));
|
||||
|
||||
CommandExecutionResult result = queueManager.removeFromQueue(0);
|
||||
|
||||
@@ -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.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -18,13 +18,13 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
public class TrackInfoTest {
|
||||
|
||||
private AudioTrack track;
|
||||
private Track track;
|
||||
private Member member;
|
||||
private TrackInfo trackInfo;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
track = mock(AudioTrack.class);
|
||||
track = QueueManagerTest.mockTrack("t1");
|
||||
member = mock(Member.class);
|
||||
when(member.getId()).thenReturn("user1");
|
||||
when(member.getEffectiveName()).thenReturn("User1");
|
||||
@@ -101,7 +101,7 @@ public class TrackInfoTest {
|
||||
|
||||
@Test
|
||||
public void notEqualWithDifferentTrack() {
|
||||
AudioTrack otherTrack = mock(AudioTrack.class);
|
||||
Track otherTrack = QueueManagerTest.mockTrack("t2");
|
||||
TrackInfo other = new TrackInfo(otherTrack, member);
|
||||
assertNotEquals(trackInfo, other);
|
||||
}
|
||||
|
||||
@@ -5,28 +5,27 @@ package me.bobert.netneutrality.music;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
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.protocol.v4.Message.EmittedEvent.TrackEndEvent;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class TrackSchedulerTest {
|
||||
|
||||
private AudioPlayer player;
|
||||
private MusicManager musicManager;
|
||||
private TrackScheduler scheduler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
player = mock(AudioPlayer.class);
|
||||
Guild guild = mock(Guild.class);
|
||||
when(guild.getId()).thenReturn("guild1");
|
||||
|
||||
MusicManager musicManager = mock(MusicManager.class);
|
||||
when(musicManager.getPlayer()).thenReturn(player);
|
||||
musicManager = mock(MusicManager.class);
|
||||
when(musicManager.getGuild()).thenReturn(guild);
|
||||
|
||||
scheduler = new TrackScheduler(musicManager);
|
||||
@@ -34,45 +33,51 @@ public class TrackSchedulerTest {
|
||||
|
||||
@Test
|
||||
public void onTrackEndFinishedStartsNextTrack() {
|
||||
AudioTrack track = mock(AudioTrack.class);
|
||||
when(track.getIdentifier()).thenReturn("t1");
|
||||
Track track = QueueManagerTest.mockTrack("t1");
|
||||
|
||||
// With empty queue, startNextTrack will just stopTrack
|
||||
scheduler.onTrackEnd(player, track, AudioTrackEndReason.FINISHED);
|
||||
verify(player).stopTrack();
|
||||
// With empty queue, startNextTrack will just stop playback on the node
|
||||
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.FINISHED);
|
||||
verify(musicManager).stopTrack();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onTrackEndLoadFailedStartsNextTrack() {
|
||||
AudioTrack track = mock(AudioTrack.class);
|
||||
when(track.getIdentifier()).thenReturn("t1");
|
||||
Track track = QueueManagerTest.mockTrack("t1");
|
||||
|
||||
scheduler.onTrackEnd(player, track, AudioTrackEndReason.LOAD_FAILED);
|
||||
verify(player).stopTrack();
|
||||
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.LOAD_FAILED);
|
||||
verify(musicManager).stopTrack();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onTrackEndReplacedDoesNotStartNext() {
|
||||
AudioTrack track = mock(AudioTrack.class);
|
||||
when(track.getIdentifier()).thenReturn("t1");
|
||||
Track track = QueueManagerTest.mockTrack("t1");
|
||||
|
||||
scheduler.onTrackEnd(player, track, AudioTrackEndReason.REPLACED);
|
||||
verify(player, never()).stopTrack();
|
||||
verify(player, never()).startTrack(any(), anyBoolean());
|
||||
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.REPLACED);
|
||||
verify(musicManager, never()).stopTrack();
|
||||
verify(musicManager, never()).updatePlayer(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onTrackStartResetsStuckHandler() {
|
||||
AudioTrack track = mock(AudioTrack.class);
|
||||
when(track.getIdentifier()).thenReturn("t1");
|
||||
public void onTrackStartCachesCurrentTrack() {
|
||||
Track track = QueueManagerTest.mockTrack("t1");
|
||||
|
||||
// After onTrackStart, the stuck handler should be reset
|
||||
scheduler.onTrackStart(player, track);
|
||||
// No exception means reset was called successfully
|
||||
scheduler.onTrackStart(track);
|
||||
|
||||
assertSame(track, scheduler.getCurrentTrack());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onTrackEndClearsCurrentTrack() {
|
||||
Track track = QueueManagerTest.mockTrack("t1");
|
||||
scheduler.onTrackStart(track);
|
||||
|
||||
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.STOPPED);
|
||||
|
||||
assertNull(scheduler.getCurrentTrack());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getDjReturnsNullWhenNoContext() {
|
||||
assert scheduler.getDj() == null;
|
||||
assertNull(scheduler.getDj());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.music;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
@@ -39,7 +38,7 @@ public class VoteSkipServiceTest {
|
||||
when(guild.getId()).thenReturn("guild1");
|
||||
when(context.getGuild()).thenReturn(guild);
|
||||
|
||||
AudioTrack track = mock(AudioTrack.class);
|
||||
dev.arbjerg.lavalink.client.player.Track track = QueueManagerTest.mockTrack("t1");
|
||||
Member requester = mock(Member.class);
|
||||
when(requester.getId()).thenReturn("requester");
|
||||
trackInfo = new TrackInfo(track, requester);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package me.bobert.netneutrality.music.source;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
public class UdioAudioSourceManagerTest {
|
||||
|
||||
@Test
|
||||
public void loadItem() {
|
||||
AudioReference reference = new AudioReference("https://www.udio.com/songs/eNmZxqpMt9MRrjFUgYDgyi", null);
|
||||
|
||||
UdioAudioSourceManager udioAudioSourceManager = new UdioAudioSourceManager();
|
||||
AudioItem loaded = udioAudioSourceManager.loadItem(null, reference);
|
||||
|
||||
assertNotNull(loaded);
|
||||
|
||||
UdioAudioTrack audioTrack = (UdioAudioTrack) loaded;
|
||||
AudioTrackInfo info = audioTrack.getInfo();
|
||||
|
||||
assertNotNull(info);
|
||||
|
||||
assertNotNull(info.artworkUrl);
|
||||
|
||||
String artworkOptions = "width=3840,quality=75";
|
||||
assertTrue(info.artworkUrl.contains(artworkOptions));
|
||||
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user