+{
+ private final K key;
+ private final V value;
+
+ public Pair(K key, V value)
+ {
+ this.key = key;
+ this.value = value;
+ }
+
+ public K getKey()
+ {
+ return key;
+ }
+
+ public V getValue()
+ {
+ return value;
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java b/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java
new file mode 100644
index 0000000..aaab525
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/entities/Prompt.java
@@ -0,0 +1,133 @@
+package com.jagrosh.jmusicbot.entities;
+
+import java.util.Scanner;
+import javax.swing.JOptionPane;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class Prompt
+{
+ private final String title;
+ private final String noguiMessage;
+
+ private boolean nogui;
+ private boolean noprompt;
+ private Scanner scanner;
+
+ public Prompt(String title)
+ {
+ this(title, null);
+ }
+
+ public Prompt(String title, String noguiMessage)
+ {
+ this(title, noguiMessage, "true".equalsIgnoreCase(System.getProperty("nogui")), "true".equalsIgnoreCase(System.getProperty("noprompt")));
+ }
+
+ public Prompt(String title, String noguiMessage, boolean nogui, boolean noprompt)
+ {
+ this.title = title;
+ this.noguiMessage = noguiMessage == null ? "Switching to nogui mode. You can manually start in nogui mode by including the -Dnogui=true flag." : noguiMessage;
+ this.nogui = nogui;
+ this.noprompt = noprompt;
+ }
+
+ public boolean isNoGUI()
+ {
+ return nogui;
+ }
+
+ public void alert(Level level, String context, String message)
+ {
+ if(nogui)
+ {
+ Logger log = LoggerFactory.getLogger(context);
+ switch(level)
+ {
+ case INFO:
+ log.info(message);
+ break;
+ case WARNING:
+ log.warn(message);
+ break;
+ case ERROR:
+ log.error(message);
+ break;
+ default:
+ log.info(message);
+ break;
+ }
+ }
+ else
+ {
+ try
+ {
+ int option = 0;
+ switch(level)
+ {
+ case INFO:
+ option = JOptionPane.INFORMATION_MESSAGE;
+ break;
+ case WARNING:
+ option = JOptionPane.WARNING_MESSAGE;
+ break;
+ case ERROR:
+ option = JOptionPane.ERROR_MESSAGE;
+ break;
+ default:
+ option = JOptionPane.PLAIN_MESSAGE;
+ break;
+ }
+ JOptionPane.showMessageDialog(null, ""+message, title, option);
+ }
+ catch(Exception e)
+ {
+ nogui = true;
+ alert(Level.WARNING, context, noguiMessage);
+ alert(level, context, message);
+ }
+ }
+ }
+
+ public String prompt(String content)
+ {
+ if(noprompt)
+ return null;
+ if(nogui)
+ {
+ if(scanner==null)
+ scanner = new Scanner(System.in);
+ try
+ {
+ System.out.println(content);
+ if(scanner.hasNextLine())
+ return scanner.nextLine();
+ return null;
+ }
+ catch(Exception e)
+ {
+ alert(Level.ERROR, title, "Unable to read input from command line.");
+ e.printStackTrace();
+ return null;
+ }
+ }
+ else
+ {
+ try
+ {
+ return JOptionPane.showInputDialog(null, content, title, JOptionPane.QUESTION_MESSAGE);
+ }
+ catch(Exception e)
+ {
+ nogui = true;
+ alert(Level.WARNING, title, noguiMessage);
+ return prompt(content);
+ }
+ }
+ }
+
+ public static enum Level
+ {
+ INFO, WARNING, ERROR;
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/ConsolePanel.java b/src/main/java/com/jagrosh/jmusicbot/gui/ConsolePanel.java
new file mode 100644
index 0000000..d20ebd1
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/gui/ConsolePanel.java
@@ -0,0 +1,30 @@
+package com.jagrosh.jmusicbot.gui;
+
+import java.awt.Dimension;
+import java.awt.GridLayout;
+import java.io.PrintStream;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextArea;
+
+public class ConsolePanel extends JPanel {
+
+ public ConsolePanel()
+ {
+ super();
+ JTextArea text = new JTextArea();
+ text.setLineWrap(true);
+ text.setWrapStyleWord(true);
+ text.setEditable(false);
+ PrintStream con=new PrintStream(new TextAreaOutputStream(text));
+ System.setOut(con);
+ System.setErr(con);
+
+ JScrollPane pane = new JScrollPane();
+ pane.setViewportView(text);
+
+ super.setLayout(new GridLayout(1,1));
+ super.add(pane);
+ super.setPreferredSize(new Dimension(400,300));
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/GUI.java b/src/main/java/com/jagrosh/jmusicbot/gui/GUI.java
new file mode 100644
index 0000000..cc4be7e
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/gui/GUI.java
@@ -0,0 +1,53 @@
+package com.jagrosh.jmusicbot.gui;
+
+import java.awt.event.WindowEvent;
+import java.awt.event.WindowListener;
+import javax.swing.JFrame;
+import javax.swing.JTabbedPane;
+import javax.swing.WindowConstants;
+import com.jagrosh.jmusicbot.Bot;
+
+public class GUI extends JFrame
+{
+ private final ConsolePanel console;
+ private final Bot bot;
+
+ public GUI(Bot bot)
+ {
+ super();
+ this.bot = bot;
+ console = new ConsolePanel();
+ }
+
+ public void init()
+ {
+ setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
+ setTitle("JMusicBot");
+ JTabbedPane tabs = new JTabbedPane();
+ tabs.add("Console", console);
+ getContentPane().add(tabs);
+ pack();
+ setLocationRelativeTo(null);
+ setVisible(true);
+ addWindowListener(new WindowListener()
+ {
+ @Override public void windowOpened(WindowEvent e) { /* unused */ }
+ @Override public void windowClosing(WindowEvent e)
+ {
+ try
+ {
+ bot.shutdown();
+ }
+ catch(Exception ex)
+ {
+ System.exit(0);
+ }
+ }
+ @Override public void windowClosed(WindowEvent e) { /* unused */ }
+ @Override public void windowIconified(WindowEvent e) { /* unused */ }
+ @Override public void windowDeiconified(WindowEvent e) { /* unused */ }
+ @Override public void windowActivated(WindowEvent e) { /* unused */ }
+ @Override public void windowDeactivated(WindowEvent e) { /* unused */ }
+ });
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/gui/TextAreaOutputStream.java b/src/main/java/com/jagrosh/jmusicbot/gui/TextAreaOutputStream.java
new file mode 100644
index 0000000..46a2b43
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/gui/TextAreaOutputStream.java
@@ -0,0 +1,139 @@
+package com.jagrosh.jmusicbot.gui;
+
+import java.awt.*;
+import java.io.*;
+import java.util.*;
+import java.util.List;
+import javax.swing.*;
+
+public class TextAreaOutputStream extends OutputStream {
+
+// *************************************************************************************************
+// INSTANCE MEMBERS
+// *************************************************************************************************
+
+private byte[] oneByte; // array for write(int val);
+private Appender appender; // most recent action
+
+public TextAreaOutputStream(JTextArea txtara) {
+ this(txtara,1000);
+ }
+
+public TextAreaOutputStream(JTextArea txtara, int maxlin) {
+ if(maxlin<1) { throw new IllegalArgumentException("TextAreaOutputStream maximum lines must be positive (value="+maxlin+")"); }
+ oneByte=new byte[1];
+ appender=new Appender(txtara,maxlin);
+ }
+
+/** Clear the current console text area. */
+public synchronized void clear() {
+ if(appender!=null) { appender.clear(); }
+ }
+
+@Override
+public synchronized void close() {
+ appender=null;
+ }
+
+@Override
+public synchronized void flush() {
+ /* empty */
+ }
+
+@Override
+public synchronized void write(int val) {
+ oneByte[0]=(byte)val;
+ write(oneByte,0,1);
+ }
+
+@Override
+public synchronized void write(byte[] ba) {
+ write(ba,0,ba.length);
+ }
+
+@Override
+public synchronized void write(byte[] ba,int str,int len) {
+ if(appender!=null) { appender.append(bytesToString(ba,str,len)); }
+ }
+
+//@edu.umd.cs.findbugs.annotations.SuppressWarnings("DM_DEFAULT_ENCODING")
+static private String bytesToString(byte[] ba, int str, int len) {
+ try {
+ return new String(ba,str,len,"UTF-8");
+ } catch(UnsupportedEncodingException thr) {
+ return new String(ba,str,len);
+ } // all JVMs are required to support UTF-8
+ }
+
+// *************************************************************************************************
+// STATIC MEMBERS
+// *************************************************************************************************
+
+ static class Appender
+ implements Runnable
+ {
+ static private final String EOL1="\n";
+ static private final String EOL2=System.getProperty("line.separator",EOL1);
+
+ private final JTextArea textArea;
+ private final int maxLines; // maximum lines allowed in text area
+ private final LinkedList lengths; // length of lines within text area
+ private final List values; // values waiting to be appended
+
+ private int curLength; // length of current line
+ private boolean clear;
+ private boolean queue;
+
+ Appender(JTextArea txtara, int maxlin) {
+ textArea =txtara;
+ maxLines =maxlin;
+ lengths =new LinkedList<>();
+ values =new ArrayList<>();
+
+ curLength=0;
+ clear =false;
+ queue =true;
+ }
+
+ private synchronized void append(String val) {
+ values.add(val);
+ if(queue) {
+ queue=false;
+ EventQueue.invokeLater(this);
+ }
+ }
+
+ private synchronized void clear() {
+ clear=true;
+ curLength=0;
+ lengths.clear();
+ values.clear();
+ if(queue) {
+ queue=false;
+ EventQueue.invokeLater(this);
+ }
+ }
+
+ // MUST BE THE ONLY METHOD THAT TOUCHES textArea!
+ @Override
+ public synchronized void run() {
+ if(clear) { textArea.setText(""); }
+ values.stream().map((val) -> {
+ curLength+=val.length();
+ return val;
+ }).map((val) -> {
+ if(val.endsWith(EOL1) || val.endsWith(EOL2)) {
+ if(lengths.size()>=maxLines) { textArea.replaceRange("",0,lengths.removeFirst()); }
+ lengths.addLast(curLength);
+ curLength=0;
+ }
+ return val;
+ }).forEach((val) -> {
+ textArea.append(val);
+ });
+ values.clear();
+ clear =false;
+ queue =true;
+ }
+ }
+} /* END PUBLIC CLASS */
diff --git a/src/main/java/com/jagrosh/jmusicbot/playlist/PlaylistLoader.java b/src/main/java/com/jagrosh/jmusicbot/playlist/PlaylistLoader.java
new file mode 100644
index 0000000..feccb33
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/playlist/PlaylistLoader.java
@@ -0,0 +1,276 @@
+package com.jagrosh.jmusicbot.playlist;
+
+import com.jagrosh.jmusicbot.BotConfig;
+import com.jagrosh.jmusicbot.utils.OtherUtil;
+import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
+import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
+import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
+import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
+import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+public class PlaylistLoader
+{
+ private final BotConfig config;
+
+ public PlaylistLoader(BotConfig config)
+ {
+ this.config = config;
+ }
+
+ public List getPlaylistNames()
+ {
+ if(folderExists())
+ {
+ File folder = new File(OtherUtil.getPath(config.getPlaylistsFolder()).toString());
+ return Arrays.asList(folder.listFiles((pathname) -> pathname.getName().endsWith(".txt")))
+ .stream().map(f -> f.getName().substring(0,f.getName().length()-4)).collect(Collectors.toList());
+ }
+ else
+ {
+ createFolder();
+ return Collections.emptyList();
+ }
+ }
+
+ public void createFolder()
+ {
+ try
+ {
+ Files.createDirectory(OtherUtil.getPath(config.getPlaylistsFolder()));
+ }
+ catch (IOException ignore) {}
+ }
+
+ public boolean folderExists()
+ {
+ return Files.exists(OtherUtil.getPath(config.getPlaylistsFolder()));
+ }
+
+ public void createPlaylist(String name) throws IOException
+ {
+ Files.createFile(OtherUtil.getPath(config.getPlaylistsFolder()+File.separator+name+".txt"));
+ }
+
+ public void deletePlaylist(String name) throws IOException
+ {
+ Files.delete(OtherUtil.getPath(config.getPlaylistsFolder()+File.separator+name+".txt"));
+ }
+
+ public void writePlaylist(String name, String text) throws IOException
+ {
+ Files.write(OtherUtil.getPath(config.getPlaylistsFolder()+File.separator+name+".txt"), text.trim().getBytes());
+ }
+
+ public Playlist getPlaylist(String name)
+ {
+ if(!getPlaylistNames().contains(name))
+ return null;
+ try
+ {
+ if(folderExists())
+ {
+ boolean[] shuffle = {false};
+ List list = new ArrayList<>();
+ Files.readAllLines(OtherUtil.getPath(config.getPlaylistsFolder()+File.separator+name+".txt")).forEach(str ->
+ {
+ String s = str.trim();
+ if(s.isEmpty())
+ return;
+ if(s.startsWith("#") || s.startsWith("//"))
+ {
+ s = s.replaceAll("\\s+", "");
+ if(s.equalsIgnoreCase("#shuffle") || s.equalsIgnoreCase("//shuffle"))
+ shuffle[0]=true;
+ }
+ else
+ list.add(s);
+ });
+ if(shuffle[0])
+ shuffle(list);
+ return new Playlist(name, list, shuffle[0]);
+ }
+ else
+ {
+ createFolder();
+ return null;
+ }
+ }
+ catch(IOException e)
+ {
+ return null;
+ }
+ }
+
+
+ private static void shuffle(List list)
+ {
+ for(int first =0; first items;
+ private final boolean shuffle;
+ private final List tracks = new LinkedList<>();
+ private final List errors = new LinkedList<>();
+ private boolean loaded = false;
+
+ private Playlist(String name, List items, boolean shuffle)
+ {
+ this.name = name;
+ this.items = items;
+ this.shuffle = shuffle;
+ }
+
+ public void loadTracks(AudioPlayerManager manager, Consumer consumer, Runnable callback)
+ {
+ if(loaded)
+ return;
+ loaded = true;
+ for(int i=0; i loaded = new ArrayList<>(ap.getTracks());
+ if(shuffle)
+ for(int first =0; first config.isTooLong(track));
+ loaded.forEach(at -> at.setUserData(0L));
+ tracks.addAll(loaded);
+ loaded.forEach(at -> consumer.accept(at));
+ }
+ done();
+ }
+
+ @Override
+ public void noMatches()
+ {
+ errors.add(new PlaylistLoadError(index, items.get(index), "No matches found."));
+ done();
+ }
+
+ @Override
+ public void loadFailed(FriendlyException fe)
+ {
+ errors.add(new PlaylistLoadError(index, items.get(index), "Failed to load track: "+fe.getLocalizedMessage()));
+ done();
+ }
+ });
+ }
+ }
+
+ public void shuffleTracks()
+ {
+ shuffle(tracks);
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+
+ public List getItems()
+ {
+ return items;
+ }
+
+ public List getTracks()
+ {
+ return tracks;
+ }
+
+ public List getErrors()
+ {
+ return errors;
+ }
+ }
+
+ public class PlaylistLoadError
+ {
+ private final int number;
+ private final String item;
+ private final String reason;
+
+ private PlaylistLoadError(int number, String item, String reason)
+ {
+ this.number = number;
+ this.item = item;
+ this.reason = reason;
+ }
+
+ public int getIndex()
+ {
+ return number;
+ }
+
+ public String getItem()
+ {
+ return item;
+ }
+
+ public String getReason()
+ {
+ return reason;
+ }
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java b/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java
new file mode 100644
index 0000000..46c78fb
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/queue/AbstractQueue.java
@@ -0,0 +1,110 @@
+package com.jagrosh.jmusicbot.queue;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+public abstract class AbstractQueue
+{
+ protected AbstractQueue(AbstractQueue queue)
+ {
+ this.list = queue != null ? queue.getList() : new LinkedList<>();
+ }
+
+ protected final List list;
+
+ public abstract int add(T item);
+
+ public void addAt(int index, T item)
+ {
+ if(index >= list.size())
+ list.add(item);
+ else
+ list.add(index, item);
+ }
+
+ public int size() {
+ return list.size();
+ }
+
+ public T pull() {
+ return list.remove(0);
+ }
+
+ public boolean isEmpty()
+ {
+ return list.isEmpty();
+ }
+
+ public List getList()
+ {
+ return list;
+ }
+
+ public T get(int index) {
+ return list.get(index);
+ }
+
+ public T remove(int index)
+ {
+ return list.remove(index);
+ }
+
+ public int removeAll(long identifier)
+ {
+ int count = 0;
+ for(int i=list.size()-1; i>=0; i--)
+ {
+ if(list.get(i).getIdentifier()==identifier)
+ {
+ list.remove(i);
+ count++;
+ }
+ }
+ return count;
+ }
+
+ public void clear()
+ {
+ list.clear();
+ }
+
+ public int shuffle(long identifier)
+ {
+ List iset = new ArrayList<>();
+ for(int i=0; i 0) {
+ list.subList(0, number).clear();
+ }
+ }
+
+ /**
+ * Move an item to a different position in the list
+ * @param from The position of the item
+ * @param to The new position of the item
+ * @return the moved item
+ */
+ public T moveItem(int from, int to)
+ {
+ T item = list.remove(from);
+ list.add(to, item);
+ return item;
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/FairQueue.java b/src/main/java/com/jagrosh/jmusicbot/queue/FairQueue.java
new file mode 100644
index 0000000..f0e7f83
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/queue/FairQueue.java
@@ -0,0 +1,34 @@
+package com.jagrosh.jmusicbot.queue;
+
+import java.util.HashSet;
+import java.util.Set;
+
+public class FairQueue extends AbstractQueue
+{
+ public FairQueue(AbstractQueue queue)
+ {
+ super(queue);
+ }
+
+ protected final Set set = new HashSet<>();
+
+ @Override
+ public int add(T item)
+ {
+ int lastIndex;
+ for(lastIndex=list.size()-1; lastIndex>-1; lastIndex--)
+ if(list.get(lastIndex).getIdentifier() == item.getIdentifier())
+ break;
+ lastIndex++;
+ set.clear();
+ for(; lastIndex extends AbstractQueue
+{
+ public LinearQueue(AbstractQueue queue)
+ {
+ super(queue);
+ }
+
+ @Override
+ public int add(T item)
+ {
+ list.add(item);
+ return list.size() - 1;
+ }
+
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/QueueSupplier.java b/src/main/java/com/jagrosh/jmusicbot/queue/QueueSupplier.java
new file mode 100644
index 0000000..2ed85fa
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/queue/QueueSupplier.java
@@ -0,0 +1,7 @@
+package com.jagrosh.jmusicbot.queue;
+
+@FunctionalInterface
+public interface QueueSupplier
+{
+ AbstractQueue apply(AbstractQueue queue);
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/queue/Queueable.java b/src/main/java/com/jagrosh/jmusicbot/queue/Queueable.java
new file mode 100644
index 0000000..38130b2
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/queue/Queueable.java
@@ -0,0 +1,6 @@
+package com.jagrosh.jmusicbot.queue;
+
+public interface Queueable {
+
+ public long getIdentifier();
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/QueueType.java b/src/main/java/com/jagrosh/jmusicbot/settings/QueueType.java
new file mode 100644
index 0000000..36bba69
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/settings/QueueType.java
@@ -0,0 +1,50 @@
+package com.jagrosh.jmusicbot.settings;
+
+import com.jagrosh.jmusicbot.queue.AbstractQueue;
+import com.jagrosh.jmusicbot.queue.FairQueue;
+import com.jagrosh.jmusicbot.queue.LinearQueue;
+import com.jagrosh.jmusicbot.queue.Queueable;
+import com.jagrosh.jmusicbot.queue.QueueSupplier;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public enum QueueType
+{
+ LINEAR("\u23E9", "Linear", LinearQueue::new), // ⏩
+ FAIR("\uD83D\uDD22", "Fair", FairQueue::new); // 🔢
+
+ private final String userFriendlyName;
+ private final String emoji;
+ private final QueueSupplier supplier;
+
+ QueueType(final String emoji, final String userFriendlyName, QueueSupplier supplier)
+ {
+ this.userFriendlyName = userFriendlyName;
+ this.emoji = emoji;
+ this.supplier = supplier;
+ }
+
+ public static List getNames()
+ {
+ return Arrays.stream(QueueType.values())
+ .map(type -> type.name().toLowerCase())
+ .collect(Collectors.toList());
+ }
+
+ public AbstractQueue createInstance(AbstractQueue previous)
+ {
+ return supplier.apply(previous);
+ }
+
+ public String getUserFriendlyName()
+ {
+ return userFriendlyName;
+ }
+
+ public String getEmoji()
+ {
+ return emoji;
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/RepeatMode.java b/src/main/java/com/jagrosh/jmusicbot/settings/RepeatMode.java
new file mode 100644
index 0000000..e44b547
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/settings/RepeatMode.java
@@ -0,0 +1,27 @@
+package com.jagrosh.jmusicbot.settings;
+
+public enum RepeatMode
+{
+ OFF(null, "Off"),
+ ALL("\uD83D\uDD01", "All"), // 🔁
+ SINGLE("\uD83D\uDD02", "Single"); // 🔂
+
+ private final String emoji;
+ private final String userFriendlyName;
+
+ private RepeatMode(String emoji, String userFriendlyName)
+ {
+ this.emoji = emoji;
+ this.userFriendlyName = userFriendlyName;
+ }
+
+ public String getEmoji()
+ {
+ return emoji;
+ }
+
+ public String getUserFriendlyName()
+ {
+ return userFriendlyName;
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/Settings.java b/src/main/java/com/jagrosh/jmusicbot/settings/Settings.java
new file mode 100644
index 0000000..a0ab734
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/settings/Settings.java
@@ -0,0 +1,179 @@
+package com.jagrosh.jmusicbot.settings;
+
+import com.jagrosh.jdautilities.command.GuildSettingsProvider;
+import java.util.Collection;
+import java.util.Collections;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.VoiceChannel;
+
+public class Settings implements GuildSettingsProvider
+{
+ private final SettingsManager manager;
+ protected long textId;
+ protected long voiceId;
+ protected long roleId;
+ private int volume;
+ private String defaultPlaylist;
+ private RepeatMode repeatMode;
+ private QueueType queueType;
+ private String prefix;
+ private double skipRatio;
+
+ public Settings(SettingsManager manager, String textId, String voiceId, String roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType)
+ {
+ this.manager = manager;
+ try
+ {
+ this.textId = Long.parseLong(textId);
+ }
+ catch(NumberFormatException e)
+ {
+ this.textId = 0;
+ }
+ try
+ {
+ this.voiceId = Long.parseLong(voiceId);
+ }
+ catch(NumberFormatException e)
+ {
+ this.voiceId = 0;
+ }
+ try
+ {
+ this.roleId = Long.parseLong(roleId);
+ }
+ catch(NumberFormatException e)
+ {
+ this.roleId = 0;
+ }
+ this.volume = volume;
+ this.defaultPlaylist = defaultPlaylist;
+ this.repeatMode = repeatMode;
+ this.prefix = prefix;
+ this.skipRatio = skipRatio;
+ this.queueType = queueType;
+ }
+
+ public Settings(SettingsManager manager, long textId, long voiceId, long roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType)
+ {
+ this.manager = manager;
+ this.textId = textId;
+ this.voiceId = voiceId;
+ this.roleId = roleId;
+ this.volume = volume;
+ this.defaultPlaylist = defaultPlaylist;
+ this.repeatMode = repeatMode;
+ this.prefix = prefix;
+ this.skipRatio = skipRatio;
+ this.queueType = queueType;
+ }
+
+ // Getters
+ public TextChannel getTextChannel(Guild guild)
+ {
+ return guild == null ? null : guild.getTextChannelById(textId);
+ }
+
+ public VoiceChannel getVoiceChannel(Guild guild)
+ {
+ return guild == null ? null : guild.getVoiceChannelById(voiceId);
+ }
+
+ public Role getRole(Guild guild)
+ {
+ return guild == null ? null : guild.getRoleById(roleId);
+ }
+
+ public int getVolume()
+ {
+ return volume;
+ }
+
+ public String getDefaultPlaylist()
+ {
+ return defaultPlaylist;
+ }
+
+ public RepeatMode getRepeatMode()
+ {
+ return repeatMode;
+ }
+
+ public String getPrefix()
+ {
+ return prefix;
+ }
+
+ public double getSkipRatio()
+ {
+ return skipRatio;
+ }
+
+ public QueueType getQueueType()
+ {
+ return queueType;
+ }
+
+ @Override
+ public Collection getPrefixes()
+ {
+ return prefix == null ? Collections.emptySet() : Collections.singleton(prefix);
+ }
+
+ // Setters
+ public void setTextChannel(TextChannel tc)
+ {
+ this.textId = tc == null ? 0 : tc.getIdLong();
+ this.manager.writeSettings();
+ }
+
+ public void setVoiceChannel(VoiceChannel vc)
+ {
+ this.voiceId = vc == null ? 0 : vc.getIdLong();
+ this.manager.writeSettings();
+ }
+
+ public void setDJRole(Role role)
+ {
+ this.roleId = role == null ? 0 : role.getIdLong();
+ this.manager.writeSettings();
+ }
+
+ public void setVolume(int volume)
+ {
+ this.volume = volume;
+ this.manager.writeSettings();
+ }
+
+ public void setDefaultPlaylist(String defaultPlaylist)
+ {
+ this.defaultPlaylist = defaultPlaylist;
+ this.manager.writeSettings();
+ }
+
+ public void setRepeatMode(RepeatMode mode)
+ {
+ this.repeatMode = mode;
+ this.manager.writeSettings();
+ }
+
+ public void setPrefix(String prefix)
+ {
+ this.prefix = prefix;
+ this.manager.writeSettings();
+ }
+
+ public void setSkipRatio(double skipRatio)
+ {
+ this.skipRatio = skipRatio;
+ this.manager.writeSettings();
+ }
+
+ public void setQueueType(QueueType queueType)
+ {
+ this.queueType = queueType;
+ this.manager.writeSettings();
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/settings/SettingsManager.java b/src/main/java/com/jagrosh/jmusicbot/settings/SettingsManager.java
new file mode 100644
index 0000000..650f2af
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/settings/SettingsManager.java
@@ -0,0 +1,116 @@
+package com.jagrosh.jmusicbot.settings;
+
+import com.jagrosh.jdautilities.command.GuildSettingsManager;
+import com.jagrosh.jmusicbot.utils.OtherUtil;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.util.HashMap;
+import net.dv8tion.jda.api.entities.Guild;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SettingsManager implements GuildSettingsManager
+{
+ private final static Logger LOG = LoggerFactory.getLogger("Settings");
+ private final static String SETTINGS_FILE = "serversettings.json";
+ private final HashMap settings;
+
+ public SettingsManager()
+ {
+ this.settings = new HashMap<>();
+
+ try {
+ JSONObject loadedSettings = new JSONObject(new String(Files.readAllBytes(OtherUtil.getPath(SETTINGS_FILE))));
+ loadedSettings.keySet().forEach((id) -> {
+ JSONObject o = loadedSettings.getJSONObject(id);
+
+ // Legacy version support: On versions 0.3.3 and older, the repeat mode was represented as a boolean.
+ if (!o.has("repeat_mode") && o.has("repeat") && o.getBoolean("repeat"))
+ o.put("repeat_mode", RepeatMode.ALL);
+
+
+ settings.put(Long.parseLong(id), new Settings(this,
+ o.has("text_channel_id") ? o.getString("text_channel_id") : null,
+ o.has("voice_channel_id")? o.getString("voice_channel_id") : null,
+ o.has("dj_role_id") ? o.getString("dj_role_id") : null,
+ o.has("volume") ? o.getInt("volume") : 100,
+ o.has("default_playlist")? o.getString("default_playlist") : null,
+ o.has("repeat_mode") ? o.getEnum(RepeatMode.class, "repeat_mode"): RepeatMode.OFF,
+ o.has("prefix") ? o.getString("prefix") : null,
+ o.has("skip_ratio") ? o.getDouble("skip_ratio") : -1,
+ o.has("queue_type") ? o.getEnum(QueueType.class, "queue_type") : QueueType.FAIR));
+ });
+ } catch (NoSuchFileException e) {
+ // create an empty json file
+ try {
+ LOG.info("serversettings.json will be created in " + OtherUtil.getPath("serversettings.json").toAbsolutePath());
+ Files.write(OtherUtil.getPath("serversettings.json"), new JSONObject().toString(4).getBytes());
+ } catch(IOException ex) {
+ LOG.warn("Failed to create new settings file: "+ex);
+ }
+ return;
+ } catch(IOException | JSONException e) {
+ LOG.warn("Failed to load server settings: "+e);
+ }
+
+ LOG.info("serversettings.json loaded from " + OtherUtil.getPath("serversettings.json").toAbsolutePath());
+ }
+
+ /**
+ * Gets non-null settings for a Guild
+ *
+ * @param guild the guild to get settings for
+ * @return the existing settings, or new settings for that guild
+ */
+ @Override
+ public Settings getSettings(Guild guild)
+ {
+ return getSettings(guild.getIdLong());
+ }
+
+ public Settings getSettings(long guildId)
+ {
+ return settings.computeIfAbsent(guildId, id -> createDefaultSettings());
+ }
+
+ private Settings createDefaultSettings()
+ {
+ return new Settings(this, 0, 0, 0, 100, null, RepeatMode.OFF, null, -1, QueueType.FAIR);
+ }
+
+ protected void writeSettings()
+ {
+ JSONObject obj = new JSONObject();
+ settings.keySet().stream().forEach(key -> {
+ JSONObject o = new JSONObject();
+ Settings s = settings.get(key);
+ if(s.textId!=0)
+ o.put("text_channel_id", Long.toString(s.textId));
+ if(s.voiceId!=0)
+ o.put("voice_channel_id", Long.toString(s.voiceId));
+ if(s.roleId!=0)
+ o.put("dj_role_id", Long.toString(s.roleId));
+ if(s.getVolume()!=100)
+ o.put("volume",s.getVolume());
+ if(s.getDefaultPlaylist() != null)
+ o.put("default_playlist", s.getDefaultPlaylist());
+ if(s.getRepeatMode()!=RepeatMode.OFF)
+ o.put("repeat_mode", s.getRepeatMode());
+ if(s.getPrefix() != null)
+ o.put("prefix", s.getPrefix());
+ if(s.getSkipRatio() != -1)
+ o.put("skip_ratio", s.getSkipRatio());
+ if(s.getQueueType() != QueueType.FAIR)
+ o.put("queue_type", s.getQueueType().name());
+ obj.put(Long.toString(key), o);
+ });
+ try {
+ Files.write(OtherUtil.getPath(SETTINGS_FILE), obj.toString(4).getBytes());
+ } catch(IOException ex){
+ LOG.warn("Failed to write to file: "+ex);
+ }
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java
new file mode 100644
index 0000000..8dd53f4
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java
@@ -0,0 +1,93 @@
+package com.jagrosh.jmusicbot.utils;
+
+import com.jagrosh.jmusicbot.audio.RequestMetadata.UserInfo;
+import java.util.List;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.entities.VoiceChannel;
+
+public class FormatUtil {
+
+ public static String formatUsername(String username, String discrim)
+ {
+ if(discrim == null || discrim.equals("0000"))
+ {
+ return username;
+ }
+ else
+ {
+ return username + "#" + discrim;
+ }
+ }
+
+ public static String formatUsername(UserInfo userinfo)
+ {
+ return formatUsername(userinfo.username, userinfo.discrim);
+ }
+
+ public static String formatUsername(User user)
+ {
+ return formatUsername(user.getName(), user.getDiscriminator());
+ }
+
+ public static String progressBar(double percent)
+ {
+ String str = "";
+ for(int i=0; i<12; i++)
+ if(i == (int)(percent*12))
+ str+="\uD83D\uDD18"; // 🔘
+ else
+ str+="▬";
+ return str;
+ }
+
+ public static String volumeIcon(int volume)
+ {
+ if(volume == 0)
+ return "\uD83D\uDD07"; // 🔇
+ if(volume < 30)
+ return "\uD83D\uDD08"; // 🔈
+ if(volume < 70)
+ return "\uD83D\uDD09"; // 🔉
+ return "\uD83D\uDD0A"; // 🔊
+ }
+
+ public static String listOfTChannels(List list, String query)
+ {
+ String out = " Multiple text channels found matching \""+query+"\":";
+ for(int i=0; i<6 && i)";
+ if(list.size()>6)
+ out+="\n**And "+(list.size()-6)+" more...**";
+ return out;
+ }
+
+ public static String listOfVChannels(List list, String query)
+ {
+ String out = " Multiple voice channels found matching \""+query+"\":";
+ for(int i=0; i<6 && i6)
+ out+="\n**And "+(list.size()-6)+" more...**";
+ return out;
+ }
+
+ public static String listOfRoles(List list, String query)
+ {
+ String out = " Multiple roles found matching \""+query+"\":";
+ for(int i=0; i<6 && i6)
+ out+="\n**And "+(list.size()-6)+" more...**";
+ return out;
+ }
+
+ public static String filter(String input)
+ {
+ return input.replace("\u202E","")
+ .replace("@everyone", "@\u0435veryone") // cyrillic letter e
+ .replace("@here", "@h\u0435re") // cyrillic letter e
+ .trim();
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java
new file mode 100644
index 0000000..9847563
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/utils/OtherUtil.java
@@ -0,0 +1,214 @@
+package com.jagrosh.jmusicbot.utils;
+
+import com.jagrosh.jmusicbot.JMusicBot;
+import com.jagrosh.jmusicbot.entities.Prompt;
+import java.io.*;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.OnlineStatus;
+import net.dv8tion.jda.api.entities.Activity;
+import net.dv8tion.jda.api.entities.ApplicationInfo;
+import net.dv8tion.jda.api.entities.User;
+import okhttp3.*;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+
+public class OtherUtil
+{
+ public final static String NEW_VERSION_AVAILABLE = "Update available!\n"
+ + "Current version: %s\n"
+ + "New Version: %s\n\n"
+ + "Please visit https://code.cif.su/anthony/CiF-Music-Bot/releases/latest to get the latest release.";
+ private final static String WINDOWS_INVALID_PATH = "c:\\windows\\system32\\";
+
+ /**
+ * gets a Path from a String
+ * also fixes the windows tendency to try to start in system32
+ * any time the bot tries to access this path, it will instead start in the location of the jar file
+ *
+ * @param path the string path
+ * @return the Path object
+ */
+ public static Path getPath(String path)
+ {
+ Path result = Paths.get(path);
+ // special logic to prevent trying to access system32
+ if(result.toAbsolutePath().toString().toLowerCase().startsWith(WINDOWS_INVALID_PATH))
+ {
+ try
+ {
+ result = Paths.get(new File(JMusicBot.class.getProtectionDomain().getCodeSource().getLocation().toURI()).getParentFile().getPath() + File.separator + path);
+ }
+ catch(URISyntaxException ignored) {}
+ }
+ return result;
+ }
+
+ /**
+ * Loads a resource from the jar as a string
+ *
+ * @param clazz class base object
+ * @param name name of resource
+ * @return string containing the contents of the resource
+ */
+ public static String loadResource(Object clazz, String name)
+ {
+ try(BufferedReader reader = new BufferedReader(new InputStreamReader(clazz.getClass().getResourceAsStream(name))))
+ {
+ StringBuilder sb = new StringBuilder();
+ reader.lines().forEach(line -> sb.append("\r\n").append(line));
+ return sb.toString().trim();
+ }
+ catch(IOException ignored)
+ {
+ return null;
+ }
+ }
+
+ /**
+ * Loads image data from a URL
+ *
+ * @param url url of image
+ * @return inputstream of url
+ */
+ public static InputStream imageFromUrl(String url)
+ {
+ if(url==null)
+ return null;
+ try
+ {
+ URL u = new URL(url);
+ URLConnection urlConnection = u.openConnection();
+ urlConnection.setRequestProperty("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36");
+ return urlConnection.getInputStream();
+ }
+ catch(IOException | IllegalArgumentException ignore) {}
+ return null;
+ }
+
+ /**
+ * Parses an activity from a string
+ *
+ * @param game the game, including the action such as 'playing' or 'watching'
+ * @return the parsed activity
+ */
+ public static Activity parseGame(String game)
+ {
+ if(game==null || game.trim().isEmpty() || game.trim().equalsIgnoreCase("default"))
+ return null;
+ String lower = game.toLowerCase();
+ if(lower.startsWith("playing"))
+ return Activity.playing(makeNonEmpty(game.substring(7).trim()));
+ if(lower.startsWith("listening to"))
+ return Activity.listening(makeNonEmpty(game.substring(12).trim()));
+ if(lower.startsWith("listening"))
+ return Activity.listening(makeNonEmpty(game.substring(9).trim()));
+ if(lower.startsWith("watching"))
+ return Activity.watching(makeNonEmpty(game.substring(8).trim()));
+ if(lower.startsWith("streaming"))
+ {
+ String[] parts = game.substring(9).trim().split("\\s+", 2);
+ if(parts.length == 2)
+ {
+ return Activity.streaming(makeNonEmpty(parts[1]), "https://twitch.tv/"+parts[0]);
+ }
+ }
+ return Activity.playing(game);
+ }
+
+ public static String makeNonEmpty(String str)
+ {
+ return str == null || str.isEmpty() ? "\u200B" : str;
+ }
+
+ public static OnlineStatus parseStatus(String status)
+ {
+ if(status==null || status.trim().isEmpty())
+ return OnlineStatus.ONLINE;
+ OnlineStatus st = OnlineStatus.fromKey(status);
+ return st == null ? OnlineStatus.ONLINE : st;
+ }
+
+ public static void checkJavaVersion(Prompt prompt)
+ {
+ if(!System.getProperty("java.vm.name").contains("64"))
+ prompt.alert(Prompt.Level.WARNING, "Java Version",
+ "It appears that you may not be using a supported Java version. Please use 64-bit java.");
+ }
+
+ public static void checkVersion(Prompt prompt)
+ {
+ // Get current version number
+ String version = getCurrentVersion();
+
+ // Check for new version
+ String latestVersion = getLatestVersion();
+
+ if(latestVersion!=null && !latestVersion.equals(version))
+ {
+ prompt.alert(Prompt.Level.WARNING, "JMusicBot Version", String.format(NEW_VERSION_AVAILABLE, version, latestVersion));
+ }
+ }
+
+ public static String getCurrentVersion()
+ {
+ if(JMusicBot.class.getPackage()!=null && JMusicBot.class.getPackage().getImplementationVersion()!=null)
+ return JMusicBot.class.getPackage().getImplementationVersion();
+ else
+ return "UNKNOWN";
+ }
+
+ public static String getLatestVersion()
+ {
+ try
+ {
+ Response response = new OkHttpClient.Builder().build()
+ .newCall(new Request.Builder().get().url("https://api.github.com/repos/jagrosh/MusicBot/releases/latest").build())
+ .execute();
+ ResponseBody body = response.body();
+ if(body != null)
+ {
+ try(Reader reader = body.charStream())
+ {
+ JSONObject obj = new JSONObject(new JSONTokener(reader));
+ return obj.getString("tag_name");
+ }
+ finally
+ {
+ response.close();
+ }
+ }
+ else
+ return null;
+ }
+ catch(IOException | JSONException | NullPointerException ex)
+ {
+ return null;
+ }
+ }
+
+ /**
+ * Checks if the bot JMusicBot is being run on is supported & returns the reason if it is not.
+ * @return A string with the reason, or null if it is supported.
+ */
+ public static String getUnsupportedBotReason(JDA jda)
+ {
+ if (jda.getSelfUser().getFlags().contains(User.UserFlag.VERIFIED_BOT))
+ return "The bot is verified. Using JMusicBot in a verified bot is not supported.";
+
+ ApplicationInfo info = jda.retrieveApplicationInfo().complete();
+ if (info.isBotPublic())
+ return "\"Public Bot\" is enabled. Using JMusicBot as a public bot is not supported. Please disable it in the "
+ + "Developer Dashboard at https://discord.com/developers/applications/" + jda.getSelfUser().getId() + "/bot ."
+ + "You may also need to disable all Installation Contexts at https://discord.com/developers/applications/"
+ + jda.getSelfUser().getId() + "/installation .";
+
+ return null;
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/TimeUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/TimeUtil.java
new file mode 100644
index 0000000..c3eed53
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/utils/TimeUtil.java
@@ -0,0 +1,123 @@
+package com.jagrosh.jmusicbot.utils;
+
+public class TimeUtil
+{
+
+ public static String formatTime(long duration)
+ {
+ if(duration == Long.MAX_VALUE)
+ return "LIVE";
+ long seconds = Math.round(duration/1000.0);
+ long hours = seconds/(60*60);
+ seconds %= 60*60;
+ long minutes = seconds/60;
+ seconds %= 60;
+ return (hours>0 ? hours+":" : "") + (minutes<10 ? "0"+minutes : minutes) + ":" + (seconds<10 ? "0"+seconds : seconds);
+ }
+
+ /**
+ * Parses a seek time string into milliseconds and determines if it's relative.
+ * Supports "colon time" (HH:MM:SS) or "unit time" (1h20m)
+ * @param args time string
+ * @return SeekTime object, or null if the string could not be parsed
+ */
+ public static SeekTime parseTime(String args)
+ {
+ if (args.length() == 0) return null;
+ String timestamp = args;
+ boolean relative = false; // seek forward or backward
+ boolean isSeekingBackwards = false;
+ char first = timestamp.charAt(0);
+ if (first == '+' || first == '-')
+ {
+ relative = true;
+ isSeekingBackwards = first == '-';
+ timestamp = timestamp.substring(1);
+ }
+
+ long milliseconds = parseColonTime(timestamp);
+ if(milliseconds == -1) milliseconds = parseUnitTime(timestamp);
+ if(milliseconds == -1) return null;
+
+ milliseconds *= isSeekingBackwards ? -1 : 1;
+
+ return new SeekTime(milliseconds, relative);
+ }
+
+ /**
+ * @param timestamp timestamp formatted as: [+ | -] <HH:MM:SS | MM:SS | SS>
+ * @return Time in milliseconds
+ */
+ public static long parseColonTime(String timestamp)
+ {
+ String[] timestampSplitArray = timestamp.split(":+");
+ if(timestampSplitArray.length > 3 )
+ return -1;
+ double[] timeUnitArray = new double[3]; // hours, minutes, seconds
+ for(int index = 0; index < timestampSplitArray.length; index++)
+ {
+ String unit = timestampSplitArray[index];
+ if (unit.startsWith("+") || unit.startsWith("-")) return -1;
+ unit = unit.replace(",", ".");
+ try
+ {
+ timeUnitArray[index + 3 - timestampSplitArray.length] = Double.parseDouble(unit);
+ }
+ catch (NumberFormatException e)
+ {
+ return -1;
+ }
+ }
+ return Math.round(timeUnitArray[0] * 3600000 + timeUnitArray[1] * 60000 + timeUnitArray[2] * 1000);
+ }
+
+ /**
+ *
+ * @param timestr time string formatted as a unit time, e.g. 20m10, 1d5h20m14s or 1h and 20m
+ * @return Time in milliseconds
+ */
+ public static long parseUnitTime(String timestr)
+ {
+ timestr = timestr.replaceAll("(?i)(\\s|,|and)","")
+ .replaceAll("(?is)(-?\\d+|[a-z]+)", "$1 ")
+ .trim();
+ String[] vals = timestr.split("\\s+");
+ int time = 0;
+ try
+ {
+ for(int j=0; j j+1)
+ {
+ if(vals[j+1].toLowerCase().startsWith("m"))
+ num*=60;
+ else if(vals[j+1].toLowerCase().startsWith("h"))
+ num*=60*60;
+ else if(vals[j+1].toLowerCase().startsWith("d"))
+ num*=60*60*24;
+ }
+
+ time+=num*1000;
+ }
+ }
+ catch(Exception ex)
+ {
+ return -1;
+ }
+ return time;
+ }
+
+ public static class SeekTime
+ {
+ public final long milliseconds;
+ public final boolean relative;
+
+ private SeekTime(long milliseconds, boolean relative)
+ {
+ this.milliseconds = milliseconds;
+ this.relative = relative;
+ }
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/YouTubeUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/YouTubeUtil.java
new file mode 100644
index 0000000..d26dbc6
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/utils/YouTubeUtil.java
@@ -0,0 +1,46 @@
+package com.jagrosh.jmusicbot.utils;
+
+import com.sedmelluq.lava.extensions.youtuberotator.planner.*;
+import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.IpBlock;
+import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv4Block;
+import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv6Block;
+
+import java.util.List;
+
+public class YouTubeUtil {
+ public enum RoutingPlanner {
+ NONE,
+ ROTATE_ON_BAN,
+ LOAD_BALANCE,
+ NANO_SWITCH,
+ ROTATING_NANO_SWITCH
+ }
+
+ public static IpBlock parseIpBlock(String cidr) {
+ if (Ipv6Block.isIpv6CidrBlock(cidr))
+ return new Ipv6Block(cidr);
+
+ if (Ipv4Block.isIpv4CidrBlock(cidr))
+ return new Ipv4Block(cidr);
+
+ throw new IllegalArgumentException("Could not parse CIDR " + cidr);
+ }
+
+ public static AbstractRoutePlanner createRouterPlanner(RoutingPlanner routingPlanner, List ipBlocks) {
+
+ switch (routingPlanner) {
+ case NONE:
+ return null;
+ case ROTATE_ON_BAN:
+ return new RotatingIpRoutePlanner(ipBlocks);
+ case LOAD_BALANCE:
+ return new BalancingIpRoutePlanner(ipBlocks);
+ case NANO_SWITCH:
+ return new NanoIpRoutePlanner(ipBlocks, true);
+ case ROTATING_NANO_SWITCH:
+ return new RotatingNanoIpRoutePlanner(ipBlocks);
+ default:
+ throw new IllegalArgumentException("Unknown RoutingPlanner value provided");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
new file mode 100644
index 0000000..8985f56
--- /dev/null
+++ b/src/main/resources/logback.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ %nopex[%d{HH:mm:ss}] [%level] [%logger{0}]: %msg%n%ex
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/natives/linux-aarch32/libconnector.so b/src/main/resources/natives/linux-aarch32/libconnector.so
new file mode 100644
index 0000000..85d41f0
Binary files /dev/null and b/src/main/resources/natives/linux-aarch32/libconnector.so differ
diff --git a/src/main/resources/natives/linux-aarch64/libconnector.so b/src/main/resources/natives/linux-aarch64/libconnector.so
new file mode 100644
index 0000000..abf337a
Binary files /dev/null and b/src/main/resources/natives/linux-aarch64/libconnector.so differ
diff --git a/src/main/resources/natives/linux-arm/libconnector.so b/src/main/resources/natives/linux-arm/libconnector.so
new file mode 100644
index 0000000..0668792
Binary files /dev/null and b/src/main/resources/natives/linux-arm/libconnector.so differ
diff --git a/src/main/resources/natives/linux-armhf/libconnector.so b/src/main/resources/natives/linux-armhf/libconnector.so
new file mode 100644
index 0000000..0668792
Binary files /dev/null and b/src/main/resources/natives/linux-armhf/libconnector.so differ
diff --git a/src/main/resources/natives/linux-x86/libconnector.so b/src/main/resources/natives/linux-x86/libconnector.so
new file mode 100644
index 0000000..41b0012
Binary files /dev/null and b/src/main/resources/natives/linux-x86/libconnector.so differ
diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf
new file mode 100644
index 0000000..e99c2fc
--- /dev/null
+++ b/src/main/resources/reference.conf
@@ -0,0 +1,251 @@
+//-----------------------------------------------------//
+// Most stuff here is optional, but you must set the //
+// owner-id and the bot-token //
+//-----------------------------------------------------//
+
+
+// This sets the token for the bot to log in with
+// This MUST be a bot token (user tokens will not work)
+// If you don't know how to get a bot token, please see the guide here:
+// https://github.com/jagrosh/MusicBot/wiki/Getting-a-Bot-Token
+
+token = BOT_TOKEN_HERE
+
+
+// This sets the owner of the bot
+// This needs to be the owner's ID (a 17-18 digit number)
+// https://github.com/jagrosh/MusicBot/wiki/Finding-Your-User-ID
+
+owner = 0 // OWNER ID
+
+
+// This sets the prefix for the bot
+// The prefix is used to control the commands
+// If you use !!, the play command will be !!play
+// If you do not set this, the prefix will be a mention of the bot (@Botname play)
+
+prefix = "@mention"
+
+
+// If you set this, it modifies the default game of the bot
+// Set this to NONE to have no game
+// Set this to DEFAULT to use the default game
+// You can make the game "Playing X", "Listening to X", or "Watching X"
+// where X is the title. If you don't include an action, it will use the
+// default of "Playing"
+
+game = "DEFAULT"
+
+
+// If you set this, it will modify the default status of bot
+// Valid values: ONLINE IDLE DND INVISIBLE
+
+status = ONLINE
+
+
+// If you set this to true, the bot will list the title of the song it is currently playing in its
+// "Playing" status. Note that this will ONLY work if the bot is playing music on ONE guild;
+// if the bot is playing on multiple guilds, this will not work.
+
+songinstatus=false
+
+
+// If you set this, the bot will also use this prefix in addition to
+// the one provided above
+
+altprefix = "NONE"
+
+
+// If you set these, it will change the various emojis
+
+success = "🎶"
+warning = "💡"
+error = "🚫"
+loading = "⌚"
+searching = "🔎"
+
+
+// If you set this, you change the word used to view the help.
+// For example, if you set the prefix to !! and the help to cmds, you would type
+// !!cmds to see the help text
+
+help = help
+
+
+// If you set this, the "nowplaying" command will show youtube thumbnails
+// Note: If you set this to true, the nowplaying boxes will NOT refresh
+// This is because refreshing the boxes causes the image to be reloaded
+// every time it refreshes.
+
+npimages = false
+
+
+// If you set this, the bot will not leave a voice channel after it finishes a queue.
+// Keep in mind that being connected to a voice channel uses additional bandwith,
+// so this option is not recommended if bandwidth is a concern.
+
+stayinchannel = false
+
+
+// This sets the maximum amount of seconds any track loaded can be. If not set or set
+// to any number less than or equal to zero, there is no maximum time length. This time
+// restriction applies to songs loaded from any source.
+
+maxtime = 0
+
+// This sets the maximum number of pages of songs that can be loaded from a YouTube
+// playlist. Each page can contain up to 100 tracks. Playing a playlist with more
+// pages than the maximum will stop loading after the provided number of pages.
+// For example, if the max was set to 15 and a playlist contained 1850 tracks,
+// only the first 1500 tracks (15 pages) would be loaded. By default, this is
+// set to 10 pages (1000 tracks).
+
+maxytplaylistpages = 10
+
+
+// This sets the ratio of users that must vote to skip the currently playing song.
+// Guild owners can define their own skip ratios, but this will be used if a guild
+// has not defined their own skip ratio.
+
+skipratio = 0.55
+
+
+// This sets the amount of seconds the bot will stay alone on a voice channel until it
+// automatically leaves the voice channel and clears the queue. If not set or set
+// to any number less than or equal to zero, the bot won't leave when alone.
+
+alonetimeuntilstop = 0
+
+
+// This sets an alternative folder to be used as the Playlists folder
+// This can be a relative or absolute path
+
+playlistsfolder = "Playlists"
+
+
+// By default, the bot will DM the owner if the bot is running and a new version of the bot
+// becomes available. Set this to false to disable this feature.
+
+updatealerts=true
+
+
+// Changing this changes the lyrics provider
+// Currently available providers: "A-Z Lyrics", "Genius", "MusicMatch", "LyricsFreak"
+// At the time of writing, I would recommend sticking with A-Z Lyrics or MusicMatch,
+// as Genius tends to have a lot of non-song results and you might get something
+// completely unrelated to what you want.
+// If you are interested in contributing a provider, please see
+// https://github.com/jagrosh/JLyrics
+
+lyrics.default = "A-Z Lyrics"
+
+
+// These settings allow you to configure custom aliases for all commands.
+// Multiple aliases may be given, separated by commas.
+//
+// Example 1: Giving command "play" the alias "p":
+// play = [ p ]
+//
+// Example 2: Giving command "search" the aliases "yts" and "find":
+// search = [ yts, find ]
+
+aliases {
+ // General commands
+ settings = [ status ]
+
+ // Music commands
+ lyrics = []
+ nowplaying = [ np, current ]
+ play = []
+ playlists = [ pls ]
+ queue = [ list ]
+ remove = [ delete ]
+ scsearch = []
+ search = [ ytsearch ]
+ shuffle = []
+ skip = [ voteskip ]
+
+ // Admin commands
+ prefix = [ setprefix ]
+ setdj = []
+ setskip = [ setskippercent, skippercent, setskipratio ]
+ settc = []
+ setvc = []
+
+ // DJ Commands
+ forceremove = [ forcedelete, modremove, moddelete, modelete ]
+ forceskip = [ modskip ]
+ movetrack = [ move ]
+ pause = []
+ playnext = []
+ queuetype = []
+ repeat = []
+ skipto = [ jumpto ]
+ stop = [ leave ]
+ volume = [ vol ]
+}
+
+
+// This sets the logging verbosity.
+// Available levels: off, error, warn, info, debug, trace, all
+//
+// It is recommended to leave this at info. Debug log levels might help with troubleshooting,
+// but can contain sensitive data.
+
+loglevel = info
+
+
+// Transforms are used to modify specific play inputs and convert them to different kinds of inputs
+// These are quite complicated to use, and have limited use-cases, but in theory allow for rough
+// whitelists or blacklists, roundabout loading from some sources, and customization of how things are
+// requested.
+//
+// These are NOT EASY to set up, so if you want to use these, you'll need to look through the code
+// for how they work and what fields are needed. Also, it's possible this feature might get entirely
+// removed in the future if I find a better way to do this.
+
+transforms = {}
+
+
+// This sets the "Proof of Origin Token" and visitor data to use when playing back YouTube videos.
+// It is recommend to obtain a PO token to avoid getting IP banned from YouTube.
+
+ytpotoken = "PO_TOKEN_HERE"
+ytvisitordata = "VISITOR_DATA_HERE"
+
+
+// This configures the routing planner to use for IP rotation on YouTube.
+// When set, you need to also set an IP block below.
+// The following routing strategies are available:
+// NONE = Disable
+// ROTATE_ON_BAN = Switch IP when currently used address gets banned.
+// LOAD_BALANCE = Selects random IP for each track playback.
+// NANO_SWITCH = Selects IPv6 address based on current nanosecond. Recommended for one /64 IPv6 blocks.
+// ROTATING_NANO_SWITCH = Same as above, but uses the next /64 IPv6 block in the list in case of a ban.
+
+ytroutingplanner = NONE
+
+
+// The IPv4 and IPv6 blocks to use when using the above routing planner.
+// This does nothing when ytroutingplanner is set to none.
+// The list accepts a list of IPv4 or IPv6 CIDR blocks (not a mix of them however)
+// IPv4 example: [ "192.0.2.183", "198.51.100.153/29" ]
+// IPv6 example: [ "2a02:1234:5678:9abc::/64" ]
+
+ytipblocks = [ ]
+
+
+
+// If you set this to true, it will enable the eval command for the bot owner. This command
+// allows the bot owner to run arbitrary code from the bot's account.
+//
+// WARNING:
+// This command can be extremely dangerous. If you don't know what you're doing, you could
+// cause horrific problems on your Discord server or on whatever computer this bot is running
+// on. Never run this command unless you are completely positive what you are running.
+//
+// DO NOT ENABLE THIS IF YOU DON'T KNOW WHAT THIS DOES OR HOW TO USE IT
+// IF SOMEONE ASKS YOU TO ENABLE THIS, THERE IS AN 11/10 CHANCE THEY ARE TRYING TO SCAM YOU
+
+eval=false
+evalengine="Nashorn"
diff --git a/src/test/java/com/jagrosh/jmusicbot/FairQueueTest.java b/src/test/java/com/jagrosh/jmusicbot/FairQueueTest.java
new file mode 100644
index 0000000..b3878e1
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/FairQueueTest.java
@@ -0,0 +1,45 @@
+package com.jagrosh.jmusicbot;
+
+import com.jagrosh.jmusicbot.queue.FairQueue;
+import com.jagrosh.jmusicbot.queue.Queueable;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class FairQueueTest
+{
+ @Test
+ public void differentIdentifierSize()
+ {
+ FairQueue queue = new FairQueue<>(null);
+ int size = 100;
+ for(int i=0; i queue = new FairQueue<>(null);
+ int size = 100;
+ for(int i=0; i