Compare commits
55 Commits
@ -1,2 +1,11 @@ |
|||||||
# Chords |
# Chords - Yet Another Discord Music Bot |
||||||
|
|
||||||
|
![The Chords logo - a white guitar with the word "Chords" in bubble font, on a blue background.](chords.png) |
||||||
|
|
||||||
|
Basically everyone has written one at this point, but our little community needed one, so I made one! |
||||||
|
|
||||||
|
Here's some stuff about it: |
||||||
|
|
||||||
|
- Can play from any site that youtube-dl supports ([that's over 1800!](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)) |
||||||
|
- Built-in multi-site searching via my own [MusicSearcher](https://fluff.nekojimi.moe/gitea/Nekojimi/MusicSearcher) project |
||||||
|
- More cool stuff coming soon! |
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
@ -0,0 +1,3 @@ |
|||||||
|
ytdl-cmd: /usr/bin/yt-dlp |
||||||
|
discord-token: ODkwNjU5MjI2NDE2OTg0MDY0.YUzBCw.jHZWpIZSYeaYA7Sc7h93W_jV-rk |
||||||
|
local-addr: 192.168.1.84 |
@ -0,0 +1,556 @@ |
|||||||
|
/* |
||||||
|
* To change this license header, choose License Headers in Project Properties. |
||||||
|
* To change this template file, choose Tools | Templates |
||||||
|
* and open the template in the editor. |
||||||
|
*/ |
||||||
|
package moe.nekojimi.chords; |
||||||
|
|
||||||
|
import com.amihaiemil.eoyaml.Yaml; |
||||||
|
import com.amihaiemil.eoyaml.YamlMapping; |
||||||
|
import com.beust.jcommander.JCommander; |
||||||
|
import com.neovisionaries.ws.client.WebSocketFactory; |
||||||
|
import java.io.File; |
||||||
|
import java.io.IOException; |
||||||
|
import java.net.*; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.time.LocalDateTime; |
||||||
|
import java.time.format.DateTimeFormatter; |
||||||
|
import java.util.*; |
||||||
|
import java.util.function.BiConsumer; |
||||||
|
import java.util.function.Consumer; |
||||||
|
import java.util.logging.Level; |
||||||
|
import java.util.logging.Logger; |
||||||
|
import javax.security.auth.login.LoginException; |
||||||
|
import moe.nekojimi.chords.commands.*; |
||||||
|
import moe.nekojimi.musicsearcher.providers.MetaSearcher; |
||||||
|
import moe.nekojimi.musicsearcher.providers.Searcher; |
||||||
|
import net.dv8tion.jda.api.JDA; |
||||||
|
import net.dv8tion.jda.api.JDABuilder; |
||||||
|
import net.dv8tion.jda.api.entities.*; |
||||||
|
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; |
||||||
|
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; |
||||||
|
import net.dv8tion.jda.api.events.message.MessageEmbedEvent; |
||||||
|
import net.dv8tion.jda.api.events.message.MessageReceivedEvent; |
||||||
|
import net.dv8tion.jda.api.hooks.ListenerAdapter; |
||||||
|
import net.dv8tion.jda.api.managers.AudioManager; |
||||||
|
import net.dv8tion.jda.api.requests.GatewayIntent; |
||||||
|
import net.dv8tion.jda.api.utils.Compression; |
||||||
|
import net.dv8tion.jda.api.utils.cache.CacheFlag; |
||||||
|
import net.dv8tion.jda.internal.utils.IOUtil; |
||||||
|
import okhttp3.OkHttpClient; |
||||||
|
|
||||||
|
/** |
||||||
|
* The Chords music Discord bot. |
||||||
|
* |
||||||
|
* @author Nekojimi |
||||||
|
*/ |
||||||
|
public final class Chords extends ListenerAdapter |
||||||
|
{ |
||||||
|
|
||||||
|
private static final CommandOptions options = new CommandOptions(); |
||||||
|
private static Settings settings; |
||||||
|
|
||||||
|
private final File dataDirectory; |
||||||
|
private final File playlistsDirectory; |
||||||
|
|
||||||
|
private MusicHandler musicHandler; |
||||||
|
private final Downloader downloader; |
||||||
|
private final Searcher searcher; |
||||||
|
private final QueueManager queueManager; |
||||||
|
// private Recommender recommender;
|
||||||
|
private JDA jda; |
||||||
|
|
||||||
|
private final Map<String, Command> commands = new HashMap<>(); |
||||||
|
private final Command helpCommand; |
||||||
|
private PlayCommand playCommand; |
||||||
|
|
||||||
|
private AudioChannel currentVoiceChannel = null; |
||||||
|
|
||||||
|
private final Map<String, Playlist> playlists = new HashMap<>(); |
||||||
|
|
||||||
|
/** |
||||||
|
* @param args the command line arguments |
||||||
|
*/ |
||||||
|
public static void main(String[] args) throws LoginException, IOException |
||||||
|
{ |
||||||
|
|
||||||
|
JCommander commander = JCommander.newBuilder() |
||||||
|
.args(args) |
||||||
|
.acceptUnknownOptions(true) |
||||||
|
.addObject(options) |
||||||
|
.build(); |
||||||
|
|
||||||
|
// We only need 2 gateway intents enabled for this example:
|
||||||
|
EnumSet<GatewayIntent> intents = EnumSet.of( |
||||||
|
// We need messages in guilds to accept commands from users
|
||||||
|
GatewayIntent.GUILD_MESSAGES, |
||||||
|
// We need voice states to connect to the voice channel
|
||||||
|
GatewayIntent.GUILD_VOICE_STATES, |
||||||
|
GatewayIntent.GUILD_MEMBERS, |
||||||
|
GatewayIntent.MESSAGE_CONTENT |
||||||
|
); |
||||||
|
|
||||||
|
settings = new Settings(new File(options.getSettingsPath())); |
||||||
|
|
||||||
|
JDABuilder builder = JDABuilder.createDefault(settings.getDiscordToken(), intents); |
||||||
|
|
||||||
|
builder.enableIntents(GatewayIntent.MESSAGE_CONTENT); |
||||||
|
if (settings.getLocalAddr() != null) |
||||||
|
{ |
||||||
|
System.out.println("Using local address: " + settings.getLocalAddr()); |
||||||
|
// make local binding socket factory
|
||||||
|
final LocalBindSocketFactory localBindSocketFactory = new LocalBindSocketFactory(); |
||||||
|
localBindSocketFactory.setLocalAddress(InetAddress.getByName(settings.getLocalAddr())); |
||||||
|
|
||||||
|
// install local socket factory for HTTP
|
||||||
|
OkHttpClient.Builder httpBuilder = IOUtil.newHttpClientBuilder(); |
||||||
|
httpBuilder.socketFactory(localBindSocketFactory); |
||||||
|
builder.setHttpClientBuilder(httpBuilder); |
||||||
|
|
||||||
|
// install local socket factory for websockets
|
||||||
|
final WebSocketFactory webSocketFactory = new WebSocketFactory(); |
||||||
|
webSocketFactory.setSocketFactory(localBindSocketFactory); |
||||||
|
builder.setWebsocketFactory(webSocketFactory); |
||||||
|
} |
||||||
|
|
||||||
|
// Disable parts of the cache
|
||||||
|
builder.disableCache(CacheFlag.MEMBER_OVERRIDES); |
||||||
|
// Enable the bulk delete event
|
||||||
|
builder.setBulkDeleteSplittingEnabled(false); |
||||||
|
// Disable compression (not recommended)
|
||||||
|
builder.setCompression(Compression.NONE); |
||||||
|
// Set activity (like "playing Something")
|
||||||
|
builder.setActivity(Activity.playing("music!")); |
||||||
|
|
||||||
|
final Chords listener = new Chords(); |
||||||
|
|
||||||
|
builder.addEventListeners(listener); |
||||||
|
builder.setAutoReconnect(true); |
||||||
|
|
||||||
|
JDA jda = builder.build(); |
||||||
|
listener.setJda(jda); |
||||||
|
} |
||||||
|
private final Consumer<Track> nowPlayingConsumer = new NowPlayingConsumer(); |
||||||
|
|
||||||
|
private final BiConsumer<TrackRequest, Exception> downloaderMessageHandler = new DownloaderMessageHandler(); |
||||||
|
|
||||||
|
public Chords() throws IOException |
||||||
|
{ |
||||||
|
log("INFO", "Starting up..."); |
||||||
|
|
||||||
|
// init dirs
|
||||||
|
dataDirectory = new File(System.getProperty("user.dir")); |
||||||
|
playlistsDirectory = initDirectory(dataDirectory, "playlists"); |
||||||
|
|
||||||
|
// init downloader
|
||||||
|
downloader = new Downloader(); |
||||||
|
downloader.setMessageHandler(downloaderMessageHandler); |
||||||
|
|
||||||
|
// init searcher
|
||||||
|
searcher = MetaSearcher.loadYAML(new File("searchproviders.yml")); |
||||||
|
|
||||||
|
// init queue manager
|
||||||
|
queueManager = new QueueManager(); |
||||||
|
queueManager.addSource(downloader); |
||||||
|
|
||||||
|
// init commands
|
||||||
|
addCommand(new JoinCommand(this)); |
||||||
|
addCommand(new LeaveCommand(this)); |
||||||
|
playCommand = new PlayCommand(this); |
||||||
|
addCommand(playCommand); |
||||||
|
addCommand(new QueueCommand(this)); |
||||||
|
addCommand(new RemoveCommand(this)); |
||||||
|
addCommand(new RestartCommand(this)); |
||||||
|
addCommand(new SkipCommand(this)); |
||||||
|
helpCommand = new HelpCommand(this); |
||||||
|
addCommand(helpCommand); |
||||||
|
|
||||||
|
// load playlists
|
||||||
|
loadPlaylists(); |
||||||
|
|
||||||
|
log("INFO", "Started OK!"); |
||||||
|
} |
||||||
|
|
||||||
|
private void addCommand(Command command) |
||||||
|
{ |
||||||
|
commands.put(command.getKeyword(), command); |
||||||
|
} |
||||||
|
|
||||||
|
public void setJda(JDA jda) |
||||||
|
{ |
||||||
|
this.jda = jda; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) |
||||||
|
{ |
||||||
|
super.onGuildVoiceUpdate(event); |
||||||
|
if (this.currentVoiceChannel == null) |
||||||
|
return; |
||||||
|
|
||||||
|
final AudioChannel channelLeft = event.getChannelLeft(); |
||||||
|
if (channelLeft != null && channelLeft == currentVoiceChannel) |
||||||
|
{ |
||||||
|
if (channelLeft.getMembers().size() <= 1) // just us now
|
||||||
|
disconnect(); |
||||||
|
} |
||||||
|
} |
||||||
|
@Override |
||||||
|
public void onMessageReceived(MessageReceivedEvent event) |
||||||
|
{ |
||||||
|
super.onMessageReceived(event); //To change body of generated methods, choose Tools | Templates.
|
||||||
|
Message message = event.getMessage(); |
||||||
|
User author = message.getAuthor(); |
||||||
|
String content = message.getContentRaw(); |
||||||
|
Guild guild = event.getGuild(); |
||||||
|
|
||||||
|
// Ignore message if it's not in a music channel
|
||||||
|
if (!event.getChannel().getName().contains("music")) |
||||||
|
return; |
||||||
|
|
||||||
|
// Ignore message if bot
|
||||||
|
if (author.isBot()) |
||||||
|
return; |
||||||
|
|
||||||
|
log("MESG", "G:" + guild.getName() + " A:" + author.getName() + " C:" + content); |
||||||
|
|
||||||
|
Invocation invocation = null; |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
URL parseURL = new URL(content.trim()); |
||||||
|
invocation = new Invocation(event, List.of(parseURL.toExternalForm())); |
||||||
|
playCommand.call(invocation); |
||||||
|
return; |
||||||
|
} catch (MalformedURLException ex) |
||||||
|
{ |
||||||
|
// not a URL, then
|
||||||
|
} |
||||||
|
|
||||||
|
String[] split = content.split("\\s+"); |
||||||
|
List<String> args = new ArrayList<>(); |
||||||
|
Collections.addAll(args, split); |
||||||
|
args.remove(0); |
||||||
|
|
||||||
|
invocation = new Invocation(event, args); |
||||||
|
invocation.setRequestMessage(message); |
||||||
|
|
||||||
|
String cmd = split[0].toLowerCase(); |
||||||
|
|
||||||
|
if (cmd.startsWith("!")) |
||||||
|
{ |
||||||
|
cmd = cmd.substring(1); // strip prefix char
|
||||||
|
|
||||||
|
if (commands.containsKey(cmd)) |
||||||
|
{ |
||||||
|
Command command = commands.get(cmd); |
||||||
|
command.call(invocation); |
||||||
|
} else |
||||||
|
{ |
||||||
|
helpCommand.call(invocation); |
||||||
|
} |
||||||
|
} else if (!event.getMessage().getAttachments().isEmpty()) |
||||||
|
{ |
||||||
|
for (Message.Attachment attach : event.getMessage().getAttachments()) |
||||||
|
{ |
||||||
|
if (attach.getContentType().startsWith("audio") || attach.getContentType().startsWith("video")) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
invocation = new Invocation(event, List.of(new URL(attach.getUrl()).toExternalForm())); |
||||||
|
invocation.setRequestMessage(message); |
||||||
|
playCommand.call(invocation); |
||||||
|
} catch (MalformedURLException ex) |
||||||
|
{ |
||||||
|
Logger.getLogger(Chords.class.getName()).log(Level.WARNING, null, ex); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (Exception ex) |
||||||
|
{ |
||||||
|
if (invocation != null) |
||||||
|
invocation.respond("Error: " + ex.getMessage()); |
||||||
|
else |
||||||
|
event.getChannel().sendMessage("Error: " + ex.getMessage()).queue(); |
||||||
|
log("UERR", "Command error:" + ex.getMessage()); |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: this will handle uploading files, maybe
|
||||||
|
} |
||||||
|
|
||||||
|
public void queueDownload(TrackRequest request) |
||||||
|
{ |
||||||
|
request.getInvocation().respond("Request pending..."); |
||||||
|
downloader.accept(request); |
||||||
|
} |
||||||
|
|
||||||
|
public void setStatus(Track nowPlaying) |
||||||
|
{ |
||||||
|
jda.getPresence().setActivity(Activity.listening(nowPlaying.toString())); |
||||||
|
} |
||||||
|
|
||||||
|
public void clearStatus() |
||||||
|
{ |
||||||
|
jda.getPresence().setActivity(null); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Connect to requested channel and start echo handler |
||||||
|
* |
||||||
|
* @param channel |
||||||
|
* The channel to connect to |
||||||
|
*/ |
||||||
|
public void connectTo(AudioChannel channel) |
||||||
|
{ |
||||||
|
Guild guild = channel.getGuild(); |
||||||
|
// Get an audio manager for this guild, this will be created upon first use for each guild
|
||||||
|
AudioManager audioManager = guild.getAudioManager(); |
||||||
|
musicHandler = new MusicHandler(); |
||||||
|
queueManager.setHandler(musicHandler); |
||||||
|
// Create our Send/Receive handler for the audio connection
|
||||||
|
// EchoHandler handler = new EchoHandler();
|
||||||
|
|
||||||
|
// The order of the following instructions does not matter!
|
||||||
|
// Set the sending handler to our echo system
|
||||||
|
audioManager.setSendingHandler(musicHandler); |
||||||
|
// Set the receiving handler to the same echo system, otherwise we can't echo anything
|
||||||
|
// audioManager.setReceivingHandler(handler);
|
||||||
|
// Connect to the voice channel
|
||||||
|
audioManager.openAudioConnection(channel); |
||||||
|
currentVoiceChannel = channel; |
||||||
|
|
||||||
|
musicHandler.setNowPlayingConsumer(nowPlayingConsumer); |
||||||
|
} |
||||||
|
|
||||||
|
public void disconnect() |
||||||
|
{ |
||||||
|
if (currentVoiceChannel != null) |
||||||
|
{ |
||||||
|
Guild guild = currentVoiceChannel.getGuild(); |
||||||
|
AudioManager audioManager = guild.getAudioManager(); |
||||||
|
audioManager.setSendingHandler(null); |
||||||
|
audioManager.closeAudioConnection(); |
||||||
|
musicHandler = null; |
||||||
|
currentVoiceChannel = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void log(String type, String message) |
||||||
|
{ |
||||||
|
System.out.println(type + " " + LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) + "\t" + message); |
||||||
|
} |
||||||
|
|
||||||
|
public File initDirectory(File parent, String name) throws IOException |
||||||
|
{ |
||||||
|
File ret = new File(parent, name); |
||||||
|
if (!ret.exists()) |
||||||
|
Files.createDirectories(ret.toPath()); |
||||||
|
if (!ret.canRead()) |
||||||
|
throw new RuntimeException("Cannot read directory " + ret.getAbsolutePath() + "!"); |
||||||
|
if (!ret.canWrite()) |
||||||
|
throw new RuntimeException("Cannot write to directory " + ret.getAbsolutePath() + "!"); |
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
private void loadPlaylists() |
||||||
|
{ |
||||||
|
File[] files = playlistsDirectory.listFiles((File file, String name) -> name.endsWith(".yaml")); |
||||||
|
for (File file : files) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
YamlMapping map = Yaml.createYamlInput(file).readYamlMapping(); |
||||||
|
Playlist playlist = Playlist.fromYaml(map); |
||||||
|
playlists.put(playlist.getName(), playlist); |
||||||
|
} catch (IOException ex) |
||||||
|
{ |
||||||
|
Logger.getLogger(Chords.class.getName()).log(Level.SEVERE, null, ex); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void setRecommenderEnabled(boolean enabled) |
||||||
|
{ |
||||||
|
// if (recommender == null && enabled)
|
||||||
|
// {
|
||||||
|
//// recommender = new Recommender();
|
||||||
|
// downloader.addSource(recommender);
|
||||||
|
// } else if (recommender != null && !enabled)
|
||||||
|
// {
|
||||||
|
//// downloader.removeSource(recommender);
|
||||||
|
// recommender = null;
|
||||||
|
// }
|
||||||
|
} |
||||||
|
|
||||||
|
public MusicHandler getMusicHandler() |
||||||
|
{ |
||||||
|
return musicHandler; |
||||||
|
} |
||||||
|
|
||||||
|
public Downloader getDownloader() |
||||||
|
{ |
||||||
|
return downloader; |
||||||
|
} |
||||||
|
|
||||||
|
public Searcher getSearcher() |
||||||
|
{ |
||||||
|
return searcher; |
||||||
|
} |
||||||
|
|
||||||
|
public JDA getJda() |
||||||
|
{ |
||||||
|
return jda; |
||||||
|
} |
||||||
|
|
||||||
|
public AudioChannel getCurrentVoiceChannel() |
||||||
|
{ |
||||||
|
return currentVoiceChannel; |
||||||
|
} |
||||||
|
|
||||||
|
public QueueManager getQueueManager() |
||||||
|
{ |
||||||
|
return queueManager; |
||||||
|
} |
||||||
|
|
||||||
|
public static Settings getSettings() |
||||||
|
{ |
||||||
|
return settings; |
||||||
|
} |
||||||
|
|
||||||
|
public static CommandOptions getOptions() |
||||||
|
{ |
||||||
|
return options; |
||||||
|
} |
||||||
|
|
||||||
|
public File getDataDirectory() |
||||||
|
{ |
||||||
|
return dataDirectory; |
||||||
|
} |
||||||
|
|
||||||
|
public File getPlaylistsDirectory() |
||||||
|
{ |
||||||
|
return playlistsDirectory; |
||||||
|
} |
||||||
|
|
||||||
|
public Map<String, Command> getCommands() |
||||||
|
{ |
||||||
|
return commands; |
||||||
|
} |
||||||
|
|
||||||
|
public Map<String, Playlist> getPlaylists() |
||||||
|
{ |
||||||
|
return playlists; |
||||||
|
} |
||||||
|
|
||||||
|
// public Recommender getRecommender()
|
||||||
|
// {
|
||||||
|
// return recommender;
|
||||||
|
// }
|
||||||
|
|
||||||
|
private class DownloaderMessageHandler implements BiConsumer<TrackRequest, Exception> |
||||||
|
{ |
||||||
|
|
||||||
|
private static final String PROGRESS_SYMBOLS = " ▏▎▍▌▋▊▉█"; |
||||||
|
|
||||||
|
private double lastProgressUpdate = 0.0; |
||||||
|
private boolean initialUpdateSent = false; |
||||||
|
|
||||||
|
/* |
||||||
|
⠀⡀⣀⣠⣤⣦⣶⣾⣿ |
||||||
|
🌑🌘🌗🌖🌕 |
||||||
|
🌩🌨🌧🌦🌥🌤🌞 |
||||||
|
○◔◑◕● |
||||||
|
□🞎🞏🞐🞑🞒🞓■ |
||||||
|
□▤▥▦▧▨▩■ |
||||||
|
▁▂▃▄▅▆▇█ |
||||||
|
*/ |
||||||
|
// private static final String PROGRESS_SYMBOLS = " ⠀⡀⣀⣠⣤⣦⣶⣾⣿";
|
||||||
|
|
||||||
|
public DownloaderMessageHandler() |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void accept(TrackRequest request, Exception ex) |
||||||
|
{ |
||||||
|
boolean shouldUpdate = false; |
||||||
|
String response = ""; |
||||||
|
if (request.getTracks().size() > 1) |
||||||
|
response += "Downloading " + request.getTracks().size() + " tracks:\n"; |
||||||
|
for (Track track : request.getTracks()) |
||||||
|
{ |
||||||
|
// TextChannel channel = jda.getTextChannelById(track.getRequestedIn());
|
||||||
|
// String bracketNo = "[" + track.getNumber() + "] ";
|
||||||
|
if (ex == null) |
||||||
|
if (track.getLocation() != null && track.getProgress() >= 100) |
||||||
|
{ |
||||||
|
response += ("Finished downloading " + track + "!"); |
||||||
|
log("DOWN", "Downloaded " + track); |
||||||
|
shouldUpdate = true; |
||||||
|
} else |
||||||
|
{ |
||||||
|
Format format = track.getBestFormat(); |
||||||
|
String formatDetails = ""; |
||||||
|
if (format != null) |
||||||
|
{ |
||||||
|
final int bitrate = format.getSampleRate() / 1000; |
||||||
|
final long size = format.getSize(); |
||||||
|
String sizeFmt = (size <= 0 ? "?.??" : String.format("%.2f", size / (1024.0 * 1024.0))) + "MiB"; |
||||||
|
String bitFmt = (bitrate <= 0 ? "??" : bitrate) + "k"; |
||||||
|
formatDetails = " (" + bitFmt + ", " + sizeFmt + ")"; |
||||||
|
} |
||||||
|
|
||||||
|
String progressDetails = ""; |
||||||
|
if (track.getProgress() >= 0) |
||||||
|
{ |
||||||
|
if (track.getProgress() >= lastProgressUpdate + 5.0) |
||||||
|
{ |
||||||
|
shouldUpdate = true; |
||||||
|
lastProgressUpdate = track.getProgress(); |
||||||
|
} |
||||||
|
progressDetails = " [" + String.format("%.1f", track.getProgress()) + "%]"; |
||||||
|
} |
||||||
|
|
||||||
|
response += ("Now downloading " + track + formatDetails + progressDetails + " ..."); |
||||||
|
log("DOWN", "Downloading " + track + "..."); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
response += ("Failed to download " + track + "! Reason: " + ex.getMessage()); |
||||||
|
log("DOWN", "Failed to download " + track + "! Reason: " + ex.getMessage()); |
||||||
|
shouldUpdate = true; |
||||||
|
} |
||||||
|
response += "\n"; |
||||||
|
} |
||||||
|
if (!response.isEmpty() && (shouldUpdate || !initialUpdateSent)) |
||||||
|
{ |
||||||
|
request.getInvocation().respond(response); |
||||||
|
initialUpdateSent = true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private class NowPlayingConsumer implements Consumer<Track> |
||||||
|
{ |
||||||
|
|
||||||
|
public NowPlayingConsumer() |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void accept(Track track) |
||||||
|
{ |
||||||
|
if (track != null) |
||||||
|
jda.getPresence().setActivity(Activity.of(Activity.ActivityType.LISTENING, track.toString())); |
||||||
|
else |
||||||
|
jda.getPresence().setActivity(null); |
||||||
|
|
||||||
|
// if (recommender != null)
|
||||||
|
// recommender.addSeed(track);
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
/* |
||||||
|
* Copyright (C) 2022 jimj316 |
||||||
|
* |
||||||
|
* This program is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU General Public License as published by |
||||||
|
* the Free Software Foundation, either version 3 of the License, or |
||||||
|
* (at your option) any later version. |
||||||
|
* |
||||||
|
* This program is distributed in the hope that it will be useful, |
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||||
|
* GNU General Public License for more details. |
||||||
|
* |
||||||
|
* You should have received a copy of the GNU General Public License |
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/ |
||||||
|
package moe.nekojimi.chords; |
||||||
|
|
||||||
|
import com.beust.jcommander.Parameter; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @author jimj316 |
||||||
|
*/ |
||||||
|
public class CommandOptions |
||||||
|
{ |
||||||
|
|
||||||
|
@Parameter(names = "-settings", description = "A path to the settings.yml file.") |
||||||
|
private String settingsPath = "settings.yml"; |
||||||
|
|
||||||
|
// @Parameter(names = "-token", description = "The API token for Discord.", required = true)
|
||||||
|
// private String token;
|
||||||
|
//
|
||||||
|
// @Parameter(names = "-local-addr", description = "The local address to bind to.")
|
||||||
|
// private String localAddress;
|
||||||
|
|
||||||
|
// public String getToken()
|
||||||
|
// {
|
||||||
|
// return token;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public String getLocalAddress()
|
||||||
|
// {
|
||||||
|
// return localAddress;
|
||||||
|
// }
|
||||||
|
|
||||||
|
public String getSettingsPath() |
||||||
|
{ |
||||||
|
return settingsPath; |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,112 @@ |
|||||||
|
/* |
||||||
|
* Copyright (C) 2022 jimj316 |
||||||
|
* |
||||||
|
* This program is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU General Public License as published by |
||||||
|
* the Free Software Foundation, either version 3 of the License, or |
||||||
|
* (at your option) any later version. |
||||||
|
* |
||||||
|
* This program is distributed in the hope that it will be useful, |
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||||
|
* GNU General Public License for more details. |
||||||
|
* |
||||||
|
* You should have received a copy of the GNU General Public License |
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/ |
||||||
|
package moe.nekojimi.chords; |
||||||
|
|
||||||
|
import java.io.IOException; |
||||||
|
import java.net.*; |
||||||
|
import javax.net.SocketFactory; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @author jimj316 |
||||||
|
*/ |
||||||
|
public class LocalBindSocketFactory extends SocketFactory |
||||||
|
{ |
||||||
|
|
||||||
|
private InetAddress localAddress; |
||||||
|
|
||||||
|
public InetAddress getLocalAddress() |
||||||
|
{ |
||||||
|
return localAddress; |
||||||
|
} |
||||||
|
|
||||||
|
public void setLocalAddress(InetAddress localAddress) |
||||||
|
{ |
||||||
|
this.localAddress = localAddress; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Socket createSocket() throws IOException |
||||||
|
{ |
||||||
|
return new LocalBoundSocket(); |
||||||
|
// throw new RuntimeException("Trying to create an empty socket!");
|
||||||
|
} |
||||||
|
|
||||||
|
private InetSocketAddress findFreeSocketAddress() |
||||||
|
{ |
||||||
|
return new InetSocketAddress(localAddress, findFreePort(localAddress)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Socket createSocket(String remoteAddr, int remotePort) throws IOException, UnknownHostException |
||||||
|
{ |
||||||
|
return createSocket(remoteAddr, remotePort, localAddress, findFreePort(localAddress)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Socket createSocket(String remoteAddr, int remotePort, InetAddress x, int localPort) throws IOException, UnknownHostException |
||||||
|
{ |
||||||
|
return createSocket(InetAddress.getByName(remoteAddr), remotePort, localAddress, localPort); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Socket createSocket(InetAddress remoteAddr, int remotePort) throws IOException |
||||||
|
{ |
||||||
|
return createSocket(remoteAddr, remotePort, localAddress, findFreePort(localAddress)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Socket createSocket(InetAddress remoteAddr, int remotePort, InetAddress x, int localPort) throws IOException |
||||||
|
{ |
||||||
|
System.out.println("Requested socket; " + localAddress + ":" + localPort + " -> " + remoteAddr + ":" + remotePort); |
||||||
|
Socket socket = new Socket(remoteAddr, remotePort, localAddress, localPort); |
||||||
|
System.out.println("Returned socket; " + socket.getLocalSocketAddress() + ":" + socket.getLocalPort() + " -> " + socket.getRemoteSocketAddress() + ":" + socket.getPort()); |
||||||
|
return socket; |
||||||
|
} |
||||||
|
|
||||||
|
private static int findFreePort(InetAddress localAddr) |
||||||
|
{ |
||||||
|
int port = 0; |
||||||
|
// For ServerSocket port number 0 means that the port number is automatically allocated.
|
||||||
|
try (ServerSocket socket = new ServerSocket(0, 0, localAddr)) |
||||||
|
{ |
||||||
|
// Disable timeout and reuse address after closing the socket.
|
||||||
|
socket.setReuseAddress(true); |
||||||
|
port = socket.getLocalPort(); |
||||||
|
} catch (IOException ignored) |
||||||
|
{ |
||||||
|
} |
||||||
|
if (port > 0) |
||||||
|
{ |
||||||
|
return port; |
||||||
|
} |
||||||
|
throw new RuntimeException("Could not find a free port"); |
||||||
|
} |
||||||
|
|
||||||
|
private class LocalBoundSocket extends Socket |
||||||
|
{ |
||||||
|
|
||||||
|
@Override |
||||||
|
public void bind(SocketAddress bindpoint) throws IOException |
||||||
|
{ |
||||||
|
InetSocketAddress localAddress = findFreeSocketAddress(); |
||||||
|
System.err.println("LocalBoundSocket NOT binding to " + bindpoint + ", using " + localAddress + " instead!"); |
||||||
|
super.bind(localAddress); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -1,417 +0,0 @@ |
|||||||
/* |
|
||||||
* To change this license header, choose License Headers in Project Properties. |
|
||||||
* To change this template file, choose Tools | Templates |
|
||||||
* and open the template in the editor. |
|
||||||
*/ |
|
||||||
package moe.nekojimi.chords; |
|
||||||
|
|
||||||
import com.amihaiemil.eoyaml.Yaml; |
|
||||||
import com.amihaiemil.eoyaml.YamlMapping; |
|
||||||
import java.io.File; |
|
||||||
import java.io.FilenameFilter; |
|
||||||
import java.io.IOException; |
|
||||||
import java.net.MalformedURLException; |
|
||||||
import java.net.URL; |
|
||||||
import java.nio.file.Files; |
|
||||||
import java.time.LocalDateTime; |
|
||||||
import java.time.format.DateTimeFormatter; |
|
||||||
import java.util.*; |
|
||||||
import java.util.function.BiConsumer; |
|
||||||
import java.util.function.Consumer; |
|
||||||
import java.util.logging.Level; |
|
||||||
import java.util.logging.Logger; |
|
||||||
import javax.security.auth.login.LoginException; |
|
||||||
import moe.nekojimi.chords.commands.*; |
|
||||||
import moe.nekojimi.musicsearcher.providers.MetaSearcher; |
|
||||||
import moe.nekojimi.musicsearcher.providers.Searcher; |
|
||||||
import net.dv8tion.jda.api.JDA; |
|
||||||
import net.dv8tion.jda.api.JDABuilder; |
|
||||||
import net.dv8tion.jda.api.entities.*; |
|
||||||
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceLeaveEvent; |
|
||||||
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; |
|
||||||
import net.dv8tion.jda.api.hooks.ListenerAdapter; |
|
||||||
import net.dv8tion.jda.api.managers.AudioManager; |
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent; |
|
||||||
import net.dv8tion.jda.api.utils.Compression; |
|
||||||
import net.dv8tion.jda.api.utils.cache.CacheFlag; |
|
||||||
|
|
||||||
/** |
|
||||||
* |
|
||||||
* @author jimj316 |
|
||||||
*/ |
|
||||||
public final class Main extends ListenerAdapter |
|
||||||
{ |
|
||||||
|
|
||||||
private final File dataDirectory; |
|
||||||
private final File playlistsDirectory; |
|
||||||
|
|
||||||
private MusicHandler musicHandler; |
|
||||||
private final Downloader downloader; |
|
||||||
private final Searcher searcher; |
|
||||||
private final QueueManager queueManager; |
|
||||||
private JDA jda; |
|
||||||
|
|
||||||
private final Map<String, Command> commands = new HashMap<>(); |
|
||||||
private final Command helpCommand; |
|
||||||
private PlayCommand playCommand; |
|
||||||
|
|
||||||
private VoiceChannel currentVoiceChannel = null; |
|
||||||
|
|
||||||
private int trackNumber = 1; |
|
||||||
|
|
||||||
private final Map<String, Playlist> playlists = new HashMap<>(); |
|
||||||
|
|
||||||
/** |
|
||||||
* @param args the command line arguments |
|
||||||
*/ |
|
||||||
public static void main(String[] args) throws LoginException, IOException |
|
||||||
{ |
|
||||||
// We only need 2 gateway intents enabled for this example:
|
|
||||||
EnumSet<GatewayIntent> intents = EnumSet.of( |
|
||||||
// We need messages in guilds to accept commands from users
|
|
||||||
GatewayIntent.GUILD_MESSAGES, |
|
||||||
// We need voice states to connect to the voice channel
|
|
||||||
GatewayIntent.GUILD_VOICE_STATES, |
|
||||||
GatewayIntent.GUILD_MEMBERS |
|
||||||
); |
|
||||||
|
|
||||||
JDABuilder builder = JDABuilder.createDefault(args[0], intents); |
|
||||||
|
|
||||||
// Disable parts of the cache
|
|
||||||
builder.disableCache(CacheFlag.MEMBER_OVERRIDES); |
|
||||||
// Enable the bulk delete event
|
|
||||||
builder.setBulkDeleteSplittingEnabled(false); |
|
||||||
// Disable compression (not recommended)
|
|
||||||
builder.setCompression(Compression.NONE); |
|
||||||
// Set activity (like "playing Something")
|
|
||||||
builder.setActivity(Activity.playing("music!")); |
|
||||||
|
|
||||||
final Main listener = new Main(); |
|
||||||
|
|
||||||
builder.addEventListeners(listener); |
|
||||||
builder.setAutoReconnect(true); |
|
||||||
|
|
||||||
JDA jda = builder.build(); |
|
||||||
listener.setJda(jda); |
|
||||||
} |
|
||||||
private final Consumer<Song> nowPlayingConsumer = (Song song) -> |
|
||||||
{ |
|
||||||
if (song != null) |
|
||||||
jda.getPresence().setActivity(Activity.of(Activity.ActivityType.LISTENING, song.toString())); |
|
||||||
else |
|
||||||
jda.getPresence().setActivity(null); |
|
||||||
}; |
|
||||||
|
|
||||||
private final BiConsumer<SongRequest, Exception> downloaderMessageHandler = (SongRequest request, Exception ex) -> |
|
||||||
{ |
|
||||||
Song song = request.getSong(); |
|
||||||
// TextChannel channel = jda.getTextChannelById(song.getRequestedIn());
|
|
||||||
// String bracketNo = "[" + song.getNumber() + "] ";
|
|
||||||
if (ex == null) |
|
||||||
if (song.getLocation() != null) |
|
||||||
{ |
|
||||||
request.respond("Finished downloading " + song + ", added to queue!"); |
|
||||||
log("DOWN", "Downloaded " + song); |
|
||||||
} else |
|
||||||
{ |
|
||||||
Format format = song.getBestFormat(); |
|
||||||
String formatDetails = ""; |
|
||||||
if (format != null) |
|
||||||
{ |
|
||||||
final int bitrate = format.getBitrate() / 1000; |
|
||||||
final long size = format.getSize(); |
|
||||||
String sizeFmt = (size <= 0 ? "?.??" : String.format("%.2f", size / (1024.0 * 1024.0))) + "MiB"; |
|
||||||
String bitFmt = (bitrate <= 0 ? "??" : bitrate) + "k"; |
|
||||||
formatDetails = " (" + bitFmt + ", " + sizeFmt + ")"; |
|
||||||
} |
|
||||||
request.respond("Now downloading " + song + formatDetails + " ..."); |
|
||||||
log("DOWN", "Downloading " + song + "..."); |
|
||||||
} |
|
||||||
else |
|
||||||
{ |
|
||||||
request.respond("Failed to download " + song + "! Reason: " + ex.getMessage()); |
|
||||||
log("DOWN", "Failed to download " + song + "! Reason: " + ex.getMessage()); |
|
||||||
} |
|
||||||
|
|
||||||
}; |
|
||||||
|
|
||||||
public Main() throws IOException |
|
||||||
{ |
|
||||||
log("INFO", "Starting up..."); |
|
||||||
|
|
||||||
// init dirs
|
|
||||||
dataDirectory = new File(System.getProperty("user.dir")); |
|
||||||
playlistsDirectory = initDirectory(dataDirectory, "playlists"); |
|
||||||
|
|
||||||
// init downloader
|
|
||||||
downloader = new Downloader(); |
|
||||||
downloader.setMessageHandler(downloaderMessageHandler); |
|
||||||
|
|
||||||
// init searcher
|
|
||||||
searcher = MetaSearcher.loadYAML(new File("searchproviders.yml")); |
|
||||||
|
|
||||||
// init queue manager
|
|
||||||
queueManager = new QueueManager(); |
|
||||||
|
|
||||||
// init commands
|
|
||||||
addCommand(new JoinCommand(this)); |
|
||||||
addCommand(new LeaveCommand(this)); |
|
||||||
playCommand = new PlayCommand(this); |
|
||||||
addCommand(playCommand); |
|
||||||
addCommand(new QueueCommand(this)); |
|
||||||
addCommand(new RemoveCommand(this)); |
|
||||||
addCommand(new RestartCommand(this)); |
|
||||||
addCommand(new SkipCommand(this)); |
|
||||||
helpCommand = new HelpCommand(this); |
|
||||||
addCommand(helpCommand); |
|
||||||
|
|
||||||
// load playlists
|
|
||||||
loadPlaylists(); |
|
||||||
|
|
||||||
log("INFO", "Started OK!"); |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
private void addCommand(Command command) |
|
||||||
{ |
|
||||||
commands.put(command.getKeyword(), command); |
|
||||||
} |
|
||||||
|
|
||||||
public void setJda(JDA jda) |
|
||||||
{ |
|
||||||
this.jda = jda; |
|
||||||
} |
|
||||||
|
|
||||||
@Override |
|
||||||
public void onGuildVoiceLeave(GuildVoiceLeaveEvent event) |
|
||||||
{ |
|
||||||
if (this.currentVoiceChannel == null) |
|
||||||
return; |
|
||||||
|
|
||||||
final VoiceChannel channelLeft = event.getChannelLeft(); |
|
||||||
|
|
||||||
if (channelLeft.getMembers().isEmpty()) |
|
||||||
if (channelLeft == currentVoiceChannel) |
|
||||||
disconnect(); |
|
||||||
} |
|
||||||
|
|
||||||
@Override |
|
||||||
public void onGuildMessageReceived(GuildMessageReceivedEvent event) |
|
||||||
{ |
|
||||||
Message message = event.getMessage(); |
|
||||||
User author = message.getAuthor(); |
|
||||||
String content = message.getContentRaw(); |
|
||||||
Guild guild = event.getGuild(); |
|
||||||
|
|
||||||
// Ignore message if it's not in a music channel
|
|
||||||
if (!event.getChannel().getName().contains("music")) |
|
||||||
return; |
|
||||||
|
|
||||||
// Ignore message if bot
|
|
||||||
if (author.isBot()) |
|
||||||
return; |
|
||||||
|
|
||||||
log("MESG", "G:" + guild.getName() + " A:" + author.getName() + " C:" + content); |
|
||||||
|
|
||||||
try |
|
||||||
{ |
|
||||||
URL parseURL = new URL(content.trim()); |
|
||||||
playCommand.call(event, List.of(parseURL.toExternalForm())); |
|
||||||
} catch (MalformedURLException ex) |
|
||||||
{ |
|
||||||
// not a URL, then
|
|
||||||
} |
|
||||||
|
|
||||||
try |
|
||||||
{ |
|
||||||
String[] split = content.split("\\s+", 2); |
|
||||||
String cmd = split[0].toLowerCase(); |
|
||||||
|
|
||||||
if (!cmd.startsWith("!")) |
|
||||||
return; // doesn't start with prefix char
|
|
||||||
|
|
||||||
cmd = cmd.substring(1); // strip prefix char
|
|
||||||
|
|
||||||
String arg = ""; |
|
||||||
if (split.length > 1) |
|
||||||
arg = split[1]; |
|
||||||
|
|
||||||
if (commands.containsKey(cmd)) |
|
||||||
{ |
|
||||||
Command command = commands.get(cmd); |
|
||||||
command.call(event, List.of(arg)); |
|
||||||
} else |
|
||||||
{ |
|
||||||
helpCommand.call(event, List.of(arg)); |
|
||||||
} |
|
||||||
} catch (Exception ex) |
|
||||||
{ |
|
||||||
event.getChannel().sendMessage("Error in command! " + ex.getMessage()).queue(); |
|
||||||
log("UERR", "Command error:" + ex.getMessage()); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public Song queueDownload(SongRequest request) |
|
||||||
{ |
|
||||||
Song song; |
|
||||||
if (request.getUrl() != null) |
|
||||||
{ |
|
||||||
song = new Song(request.getUrl()); |
|
||||||
} else |
|
||||||
{ |
|
||||||
// interpret search result
|
|
||||||
throw new UnsupportedOperationException("Not supported yet."); |
|
||||||
} |
|
||||||
if (request.getRequestMessage() != null) |
|
||||||
{ |
|
||||||
song.setRequestedBy(request.getRequestMessage().getAuthor().getName()); |
|
||||||
song.setRequestedIn(request.getRequestMessage().getChannel().getId()); |
|
||||||
} |
|
||||||
song.setNumber(trackNumber); |
|
||||||
trackNumber++; |
|
||||||
request.setSong(song); |
|
||||||
downloader.accept(new Downloader.DownloadTask(request, queueManager)); |
|
||||||
request.respond("Request pending..."); |
|
||||||
return song; |
|
||||||
} |
|
||||||
|
|
||||||
// public Song queueDownload(final URL url, GuildMessageReceivedEvent event)
|
|
||||||
// {
|
|
||||||
// Song song = new Song(url);
|
|
||||||
// song.setRequestedBy(event.getAuthor().getName());
|
|
||||||
// song.setRequestedIn(event.getChannel().getId());
|
|
||||||
// song.setNumber(trackNumber);
|
|
||||||
// trackNumber++;
|
|
||||||
// downloader.accept(new Downloader.DownloadTask(song, musicHandler));
|
|
||||||
// return song;
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// public Song queueDownload(Result res, GuildMessageReceivedEvent event)
|
|
||||||
// {
|
|
||||||
// Song song = queueDownload(res.getLink(), event);
|
|
||||||
// song.setArtist(res.getArtist());
|
|
||||||
// song.setTitle(res.getTitle());
|
|
||||||
// song.setNumber(trackNumber);
|
|
||||||
// return song;
|
|
||||||
// }
|
|
||||||
public void setStatus(Song nowPlaying) |
|
||||||
{ |
|
||||||
jda.getPresence().setActivity(Activity.listening(nowPlaying.toString())); |
|
||||||
} |
|
||||||
|
|
||||||
public void clearStatus() |
|
||||||
{ |
|
||||||
jda.getPresence().setActivity(null); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Connect to requested channel and start echo handler |
|
||||||
* |
|
||||||
* @param channel |
|
||||||
* The channel to connect to |
|
||||||
*/ |
|
||||||
public void connectTo(VoiceChannel channel) |
|
||||||
{ |
|
||||||
Guild guild = channel.getGuild(); |
|
||||||
// Get an audio manager for this guild, this will be created upon first use for each guild
|
|
||||||
AudioManager audioManager = guild.getAudioManager(); |
|
||||||
musicHandler = new MusicHandler(); |
|
||||||
queueManager.setHandler(musicHandler); |
|
||||||
// Create our Send/Receive handler for the audio connection
|
|
||||||
// EchoHandler handler = new EchoHandler();
|
|
||||||
|
|
||||||
// The order of the following instructions does not matter!
|
|
||||||
// Set the sending handler to our echo system
|
|
||||||
audioManager.setSendingHandler(musicHandler); |
|
||||||
// Set the receiving handler to the same echo system, otherwise we can't echo anything
|
|
||||||
// audioManager.setReceivingHandler(handler);
|
|
||||||
// Connect to the voice channel
|
|
||||||
audioManager.openAudioConnection(channel); |
|
||||||
currentVoiceChannel = channel; |
|
||||||
|
|
||||||
musicHandler.setNowPlayingConsumer(nowPlayingConsumer); |
|
||||||
} |
|
||||||
|
|
||||||
public void disconnect() |
|
||||||
{ |
|
||||||
if (currentVoiceChannel != null) |
|
||||||
{ |
|
||||||
Guild guild = currentVoiceChannel.getGuild(); |
|
||||||
AudioManager audioManager = guild.getAudioManager(); |
|
||||||
audioManager.setSendingHandler(null); |
|
||||||
audioManager.closeAudioConnection(); |
|
||||||
musicHandler = null; |
|
||||||
currentVoiceChannel = null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public void log(String type, String message) |
|
||||||
{ |
|
||||||
System.out.println(type + " " + LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) + "\t" + message); |
|
||||||
} |
|
||||||
|
|
||||||
public File initDirectory(File parent, String name) throws IOException |
|
||||||
{ |
|
||||||
File ret = new File(parent, name); |
|
||||||
if (!ret.exists()) |
|
||||||
Files.createDirectories(ret.toPath()); |
|
||||||
if (!ret.canRead()) |
|
||||||
throw new RuntimeException("Cannot read directory " + ret.getAbsolutePath() + "!"); |
|
||||||
if (!ret.canWrite()) |
|
||||||
throw new RuntimeException("Cannot write to directory " + ret.getAbsolutePath() + "!"); |
|
||||||
return ret; |
|
||||||
} |
|
||||||
|
|
||||||
private void loadPlaylists() |
|
||||||
{ |
|
||||||
File[] files = playlistsDirectory.listFiles((File file, String name) -> name.endsWith(".yaml")); |
|
||||||
for (File file : files) |
|
||||||
{ |
|
||||||
try |
|
||||||
{ |
|
||||||
YamlMapping map = Yaml.createYamlInput(file).readYamlMapping(); |
|
||||||
Playlist playlist = Playlist.fromYaml(map); |
|
||||||
playlists.put(playlist.getName(), playlist); |
|
||||||
} catch (IOException ex) |
|
||||||
{ |
|
||||||
Logger.getLogger(Main.class.getName()).log(Level.SEVERE, null, ex); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
public MusicHandler getMusicHandler() |
|
||||||
{ |
|
||||||
return musicHandler; |
|
||||||
} |
|
||||||
|
|
||||||
public Downloader getDownloader() |
|
||||||
{ |
|
||||||
return downloader; |
|
||||||
} |
|
||||||
|
|
||||||
public Searcher getSearcher() |
|
||||||
{ |
|
||||||
return searcher; |
|
||||||
} |
|
||||||
|
|
||||||
public JDA getJda() |
|
||||||
{ |
|
||||||
return jda; |
|
||||||
} |
|
||||||
|
|
||||||
public VoiceChannel getCurrentVoiceChannel() |
|
||||||
{ |
|
||||||
return currentVoiceChannel; |
|
||||||
} |
|
||||||
public QueueManager getQueueManager() |
|
||||||
{ |
|
||||||
return queueManager; |
|
||||||
} |
|
||||||
|
|
||||||
public int getTrackNumber() |
|
||||||
{ |
|
||||||
return trackNumber; |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
} |
|
@ -0,0 +1,211 @@ |
|||||||
|
/* |
||||||
|
* Copyright (C) 2023 jimj316 |
||||||
|
* |
||||||
|
* This program is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU General Public License as published by |
||||||
|
* the Free Software Foundation, either version 3 of the License, or |
||||||
|
* (at your option) any later version. |
||||||
|
* |
||||||
|
* This program is distributed in the hope that it will be useful, |
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||||
|
* GNU General Public License for more details. |
||||||
|
* |
||||||
|
* You should have received a copy of the GNU General Public License |
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/ |
||||||
|
package moe.nekojimi.chords; |
||||||
|
|
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.LinkedList; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Queue; |
||||||
|
import java.util.function.Consumer; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @author jimj316 |
||||||
|
*/ |
||||||
|
public abstract class QueueThing<I, O> implements Consumer<I> |
||||||
|
{ |
||||||
|
// TODO: better name
|
||||||
|
protected final Queue<I> inputQueue; |
||||||
|
protected final List<QueueThing<?, I>> sources = new ArrayList<>(); |
||||||
|
protected final List<QueueThing<O, ?>> sinks = new ArrayList<>(); |
||||||
|
protected final Queue<Promise<I, O>> pendingPromises = new LinkedList<>(); |
||||||
|
|
||||||
|
protected int queueTargetSize = 0; |
||||||
|
|
||||||
|
protected QueueThing(Queue<I> inputQueue) |
||||||
|
{ |
||||||
|
this.inputQueue = inputQueue; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void accept(I t) |
||||||
|
{ |
||||||
|
// FIXME: this causes queue-jumping
|
||||||
|
// if we've got pending promises, fullfill them, otherwise just put it in the queue and notify the sinks
|
||||||
|
if (pendingPromises.isEmpty()) |
||||||
|
{ |
||||||
|
inputQueue.add(t); |
||||||
|
notifyNewInput(); |
||||||
|
} else |
||||||
|
{ |
||||||
|
Promise<I, O> promise = pendingPromises.poll(); |
||||||
|
System.out.println(this.getClass().getSimpleName() + " now has " + pendingPromises + " promises."); |
||||||
|
promise.setInput(t); |
||||||
|
handlePromise(promise); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Called to notify downstream modules that there's input upstream - |
||||||
|
* consider requesting it to pull it through |
||||||
|
*/ |
||||||
|
protected void notifyNewInput() |
||||||
|
{ |
||||||
|
if (inputQueue.size() < queueTargetSize) |
||||||
|
demandInput(queueTargetSize - inputQueue.size()); |
||||||
|
|
||||||
|
for (QueueThing<O, ?> sink : sinks) |
||||||
|
{ |
||||||
|
sink.notifyNewInput(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public List<Promise<I, O>> request(int count, Consumer<O> destination) |
||||||
|
{ |
||||||
|
List<Promise<I, O>> ret = new ArrayList<>(); |
||||||
|
int demandsForClient = 0; |
||||||
|
for (int i = 0; i < count; i++) |
||||||
|
{ |
||||||
|
I input = null; |
||||||
|
if (!inputQueue.isEmpty()) |
||||||
|
{ |
||||||
|
input = inputQueue.poll(); |
||||||
|
} |
||||||
|
|
||||||
|
Promise<I, O> promise = new Promise<>(input, destination); |
||||||
|
|
||||||
|
boolean ok = handlePromise(promise); |
||||||
|
|
||||||
|
if (ok) |
||||||
|
{ |
||||||
|
// we got a promise of output so we can tell the client
|
||||||
|
ret.add(promise); |
||||||
|
} else |
||||||
|
{ |
||||||
|
// we need to get more input from sources
|
||||||
|
demandsForClient++; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
int demandsForMe = queueTargetSize - inputQueue.size(); |
||||||
|
int demandsTotal = demandsForMe + demandsForClient; |
||||||
|
|
||||||
|
if (demandsTotal > 0) |
||||||
|
{ |
||||||
|
// try to get more input from sources
|
||||||
|
int sourcePromises = demandInput(demandsForClient); |
||||||
|
// each promise of input we get represents a promise of output we can give
|
||||||
|
for (int i = 0; i < sourcePromises && i < demandsForClient; i++) |
||||||
|
{ |
||||||
|
Promise<I, O> myPromise = new Promise<>(destination); |
||||||
|
pendingPromises.add(myPromise); |
||||||
|
System.out.println(this.getClass().getSimpleName() + " has made " + pendingPromises + " promises."); |
||||||
|
ret.add(myPromise); |
||||||
|
} |
||||||
|
} |
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
private boolean handlePromise(Promise<I, O> promise) |
||||||
|
{ |
||||||
|
boolean ok; |
||||||
|
ok = completePromise(promise); |
||||||
|
return ok; |
||||||
|
} |
||||||
|
|
||||||
|
protected abstract boolean completePromise(Promise<I, O> request); |
||||||
|
|
||||||
|
/** |
||||||
|
* Requests from sources a certain about of input, to be provided later. |
||||||
|
* |
||||||
|
* @param count the number of input items to request. |
||||||
|
* @return a number Promises of input items. May be more or less than count. |
||||||
|
*/ |
||||||
|
protected int demandInput(int count) |
||||||
|
{ |
||||||
|
int ret = 0; |
||||||
|
for (QueueThing<?, I> source : sources) |
||||||
|
{ |
||||||
|
List<Promise<?, I>> promises = (List<Promise<?, I>>) source.request(count - ret, this); |
||||||
|
ret += promises.size(); |
||||||
|
if (ret >= count) |
||||||
|
break; |
||||||
|
} |
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
public void addSource(QueueThing<?, I> source) |
||||||
|
{ |
||||||
|
sources.add(source); |
||||||
|
source.addSink(this); |
||||||
|
} |
||||||
|
|
||||||
|
public void removeSource(QueueThing<?, I> source) |
||||||
|
{ |
||||||
|
sources.remove(source); |
||||||
|
source.removeSink(this); |
||||||
|
} |
||||||
|
|
||||||
|
private void addSink(QueueThing<O, ?> sink) |
||||||
|
{ |
||||||
|
sinks.add(sink); |
||||||
|
} |
||||||
|
|
||||||
|
private void removeSink(QueueThing<O, ?> sink) |
||||||
|
{ |
||||||
|
sinks.remove(sink); |
||||||
|
} |
||||||
|
|
||||||
|
public static class Promise<I, O> |
||||||
|
{ |
||||||
|
|
||||||
|
private I input; |
||||||
|
private final Consumer<O> output; |
||||||
|
|
||||||
|
public Promise(I input, Consumer<O> output) |
||||||
|
{ |
||||||
|
this.input = input; |
||||||
|
this.output = output; |
||||||
|
} |
||||||
|
|
||||||
|
public Promise(Consumer<O> output) |
||||||
|
{ |
||||||
|
this.output = output; |
||||||
|
} |
||||||
|
|
||||||
|
public void setInput(I input) |
||||||
|
{ |
||||||
|
this.input = input; |
||||||
|
} |
||||||
|
|
||||||
|
public void complete(O out) |
||||||
|
{ |
||||||
|
output.accept(out); |
||||||
|
} |
||||||
|
|
||||||
|
public I getInput() |
||||||
|
{ |
||||||
|
return input; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
// protected void submit(O output)
|
||||||
|
// {
|
||||||
|
// if (nextStage != null)
|
||||||
|
// nextStage.accept(output);
|
||||||
|
// }
|
||||||
|
} |
@ -0,0 +1,59 @@ |
|||||||
|
/* |
||||||
|
* Copyright (C) 2023 jimj316 |
||||||
|
* |
||||||
|
* This program is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU General Public License as published by |
||||||
|
* the Free Software Foundation, either version 3 of the License, or |
||||||
|
* (at your option) any later version. |
||||||
|
* |
||||||
|
* This program is distributed in the hope that it will be useful, |
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||||
|
* GNU General Public License for more details. |
||||||
|
* |
||||||
|
* You should have received a copy of the GNU General Public License |
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/ |
||||||
|
package moe.nekojimi.chords; |
||||||
|
|
||||||
|
import com.amihaiemil.eoyaml.Yaml; |
||||||
|
import com.amihaiemil.eoyaml.YamlInput; |
||||||
|
import com.amihaiemil.eoyaml.YamlMapping; |
||||||
|
import java.io.File; |
||||||
|
import java.io.FileNotFoundException; |
||||||
|
import java.io.IOException; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @author jimj316 |
||||||
|
*/ |
||||||
|
public class Settings |
||||||
|
{ |
||||||
|
|
||||||
|
private final YamlMapping mapping; |
||||||
|
|
||||||
|
public Settings(File file) throws FileNotFoundException, IOException |
||||||
|
{ |
||||||
|
YamlInput input = Yaml.createYamlInput(file); |
||||||
|
mapping = input.readYamlMapping(); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
public String getDiscordToken() |
||||||
|
{ |
||||||
|
return mapping.string("discord-token"); |
||||||
|
} |
||||||
|
|
||||||
|
public String getLocalAddr() |
||||||
|
{ |
||||||
|
return mapping.string("local-addr"); |
||||||
|
} |
||||||
|
|
||||||
|
public String getYtdlCommand() |
||||||
|
{ |
||||||
|
String ret = mapping.string("ytdl-cmd"); |
||||||
|
if (ret == null) |
||||||
|
ret = "/usr/bin/youtube-dl"; |
||||||
|
return ret; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,210 @@ |
|||||||
|
/* |
||||||
|
* Copyright (C) 2022 jimj316 |
||||||
|
* |
||||||
|
* This program is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU General Public License as published by |
||||||
|
* the Free Software Foundation, either version 3 of the License, or |
||||||
|
* (at your option) any later version. |
||||||
|
* |
||||||
|
* This program is distributed in the hope that it will be useful, |
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||||
|
* GNU General Public License for more details. |
||||||
|
* |
||||||
|
* You should have received a copy of the GNU General Public License |
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/ |
||||||
|
package moe.nekojimi.chords; |
||||||
|
|
||||||
|
import com.amihaiemil.eoyaml.YamlMapping; |
||||||
|
import java.net.URL; |
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Objects; |
||||||
|
import moe.nekojimi.chords.commands.Invocation; |
||||||
|
import moe.nekojimi.musicsearcher.Result; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @author jimj316 |
||||||
|
*/ |
||||||
|
public class TrackRequest implements Comparable<TrackRequest> |
||||||
|
{ |
||||||
|
private Invocation invocation; |
||||||
|
|
||||||
|
private String query; |
||||||
|
private URL url; |
||||||
|
|
||||||
|
private Result result; |
||||||
|
|
||||||
|
private final List<Track> tracks = new ArrayList<>(); |
||||||
|
|
||||||
|
private String requestedBy; |
||||||
|
private String requestedIn; |
||||||
|
|
||||||
|
private double priority = 1.0; |
||||||
|
|
||||||
|
public YamlMapping toYAML() |
||||||
|
{ |
||||||
|
throw new UnsupportedOperationException("Not supported yet."); |
||||||
|
} |
||||||
|
|
||||||
|
public static TrackRequest fromYAML(YamlMapping yaml) |
||||||
|
{ |
||||||
|
TrackRequest ret = new TrackRequest(); |
||||||
|
|
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
public Result getResult() |
||||||
|
{ |
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
public void setResult(Result result) |
||||||
|
{ |
||||||
|
this.result = result; |
||||||
|
} |
||||||
|
|
||||||
|
public Invocation getInvocation() |
||||||
|
{ |
||||||
|
return invocation; |
||||||
|
} |
||||||
|
|
||||||
|
public void setInvocation(Invocation invocation) |
||||||
|
{ |
||||||
|
this.invocation = invocation; |
||||||
|
requestedBy = invocation.getRequestMessage().getAuthor().getName(); |
||||||
|
requestedIn = invocation.getRequestMessage().getChannel().getId(); |
||||||
|
} |
||||||
|
|
||||||
|
// public Message getRequestMessage()
|
||||||
|
// {
|
||||||
|
// return requestMessage;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public void setRequestMessage(Message requestMessage)
|
||||||
|
// {
|
||||||
|
// this.requestMessage = requestMessage;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public Message getResponseMessage()
|
||||||
|
// {
|
||||||
|
// return responseMessage;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public void setResponseMessage(Message responseMessage)
|
||||||
|
// {
|
||||||
|
// this.responseMessage = responseMessage;
|
||||||
|
// }
|
||||||
|
|
||||||
|
public String getQuery() |
||||||
|
{ |
||||||
|
return query; |
||||||
|
} |
||||||
|
|
||||||
|
public void setQuery(String query) |
||||||
|
{ |
||||||
|
this.query = query; |
||||||
|
} |
||||||
|
|
||||||
|
public URL getUrl() |
||||||
|
{ |
||||||
|
if (url != null) |
||||||
|
return url; |
||||||
|
else if (result != null) |
||||||
|
return (result.getLink()); |
||||||
|
else |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
public void setUrl(URL url) |
||||||
|
{ |
||||||
|
this.url = url; |
||||||
|
} |
||||||
|
|
||||||
|
public List<Track> getTracks() |
||||||
|
{ |
||||||
|
return tracks; |
||||||
|
} |
||||||
|
|
||||||
|
public void addTrack(Track track) |
||||||
|
{ |
||||||
|
tracks.add(track); |
||||||
|
} |
||||||
|
|
||||||
|
public void clearTracks() |
||||||
|
{ |
||||||
|
tracks.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
public String getRequestedBy() |
||||||
|
{ |
||||||
|
return requestedBy; |
||||||
|
} |
||||||
|
|
||||||
|
public String getRequestedIn() |
||||||
|
{ |
||||||
|
return requestedIn; |
||||||
|
} |
||||||
|
|
||||||
|
public String toString() |
||||||
|
{ |
||||||
|
String trackName; |
||||||
|
if (tracks.isEmpty()) |
||||||
|
trackName = "Request"; |
||||||
|
else |
||||||
|
{ |
||||||
|
trackName = tracks.get(0).toString(); |
||||||
|
if (tracks.size() > 1) |
||||||
|
trackName += " & " + (tracks.size() - 1) + " more tracks"; |
||||||
|
} |
||||||
|
|
||||||
|
String requestName = ""; |
||||||
|
if (!requestedBy.isEmpty()) |
||||||
|
requestName = "for " + requestedBy; |
||||||
|
|
||||||
|
return (trackName + " " + requestName).trim(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public int hashCode() |
||||||
|
{ |
||||||
|
int hash = 7; |
||||||
|
hash = 83 * hash + Objects.hashCode(this.url); |
||||||
|
return hash; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean equals(Object obj) |
||||||
|
{ |
||||||
|
if (this == obj) |
||||||
|
return true; |
||||||
|
if (obj == null) |
||||||
|
return false; |
||||||
|
if (getClass() != obj.getClass()) |
||||||
|
return false; |
||||||
|
final TrackRequest other = (TrackRequest) obj; |
||||||
|
if (!Objects.equals(this.url, other.url)) |
||||||
|
return false; |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
@Override |
||||||
|
public int compareTo(TrackRequest o) |
||||||
|
{ |
||||||
|
return -Double.compare(priority, o.priority); // backwards so higher priority comes first
|
||||||
|
} |
||||||
|
|
||||||
|
public double getPriority() |
||||||
|
{ |
||||||
|
return priority; |
||||||
|
} |
||||||
|
|
||||||
|
public void setPriority(double priority) |
||||||
|
{ |
||||||
|
this.priority = priority; |
||||||
|
} |
||||||
|
|
||||||
|
} |
Loading…
Reference in new issue