Compare commits

...

64 Commits

Author SHA1 Message Date
Nekojimi 5ec62d4d43 Add support for uploading tracks to play them. 9 months ago
Nekojimi a9c5bb37f8 Disable streaming mode for now. 9 months ago
Nekojimi 6759afe76b WIP: initial work on streaming instead of downloading. 9 months ago
Nekojimi c8684dbe75 Fix exception raised if something is added to the queue while Chords isn't present. 9 months ago
Nekojimi 11c8991943 Fix track skipping not clearing the current track. 9 months ago
Nekojimi 95d2e41a03 Add support for Tracks to store new input stream formats, not just files. 9 months ago
Nekojimi 536743c6c1 MusicHandler: properly skip tracks on error. 9 months ago
Nekojimi 143f5dacda Prevent error if queue command is used while not in a channel. 9 months ago
Nekojimi 758f6a0d48 Fix issue where no URL would be retrieved for search results. 9 months ago
Nekojimi a901163af3 Retry opening audio streams after all exceptions, not just IO failure. 9 months ago
Nekojimi abd676fa49 Retry opening track files after failure, and gracefully handle final failure by skipping the track. 9 months ago
Nekojimi dd977cc899 Fuck my entire existence 9 months ago
Nekojimi 1d78f2a3fb Download 8 fragments in parallel. 9 months ago
Nekojimi c2c748470f Possibly optimise download speed by rate-limiting update messages? 9 months ago
Nekojimi 268af0ff3f Stop pretending to be Android. 9 months ago
Nekojimi 26010946c2 Temporarily fix Downloader. 9 months ago
Nekojimi ab26e65fdb Remove bad references to Recommender. 9 months ago
Nekojimi 571139a59c Screw it, more changes 9 months ago
Nekojimi 95f838c392 Fix play command search result selection. 10 months ago
Nekojimi 7d3795b10a Remove debug audio output. 11 months ago
Nekojimi 3899792e7f Update README.md 11 months ago
Nekojimi fdbd8937f5 Change pipeline to use pull architecture. 11 months ago
Nekojimi b41f5c97a3 Delete 'pom.xml.orig' 1 year ago
Nekojimi 1ad6617914 Update '.gitignore' 1 year ago
Nekojimi 16c2c61e76 Fix auto-disconnect when last user leaves. 1 year ago
Nekojimi 2a25e1368d Mega-refactor: change song to track everywhere. 1 year ago
Nekojimi abfab9a18d Clean up code. 1 year ago
Nekojimi 07b45309c1 Ensure tracks play in the correct order by using PriorityQueue. 1 year ago
Nekojimi 310158a39b Add support for downloading entire playlists. 1 year ago
Nekojimi de807b4524 Add equals and hashcode to Format. 1 year ago
Nekojimi d9a7da27f2 Fix issue with skip command not ending current track. 1 year ago
Nekojimi be6036cd5d Merge branch 'jda-v5' 2 years ago
Nekojimi c7ab883d5e Update JDA to v5.0. 2 years ago
Nekojimi 3ff544d699 Report download progress while downloading. 2 years ago
Nekojimi 9fa5e36cdc Handle new format listing style in yt-dlp. 2 years ago
Nekojimi d50657ef90 Move local address handling to first part of initialisation. 2 years ago
Nekojimi f1cc31dcd9 Send acknowledgement message for new songs immediately, instead of after download is scheduled. 2 years ago
Nekojimi 0914cadaf5 Move some text-parsing methods to Util class. 2 years ago
Nekojimi 490101fcad Update JDA to v5.0. 2 years ago
Nekojimi 1dba55620f Print local address to debug on start. 2 years ago
Nekojimi 0c29d56317 Made LocalBindSocketFactory much more hardcore. 2 years ago
Nekojimi 6ef49c9e51 Fixed some silly mistakes with local address handling. 2 years ago
Nekojimi 7c4ea13e3c Change local address handling to also apply to JDA's HTTPClient. 2 years ago
Nekojimi 7cd118985a Add new settings system, and allow youtube-dl command to be set via it. 2 years ago
Nekojimi 2ec6da49b5 Fix main class in pom.xml. 2 years ago
Nekojimi f043ab906d Add debug warning if music player starts pulling tricky shit again 2 years ago
Nekojimi 6f23b60511 Update references to main class. 2 years ago
Nekojimi 1b7d4adeb9 Rename main class to "Chords". 2 years ago
Nekojimi 000e0e5d5c Fix reading of API token. 2 years ago
Nekojimi 52e34493bd Add JCommander to dependencies. 2 years ago
Nekojimi 824d08caad Add command line option to bind to a local socket. Also, add command line parsing. 2 years ago
Nekojimi b0415b0324 Downloader: link preferred bitrate to JDA requested format. 3 years ago
Nekojimi ae7fe8cc7b Add back support for restarting the current track. 3 years ago
Nekojimi 4e654d2d18 Read format info from Youtube JSON response. 3 years ago
Nekojimi ca3ebd712b Add new invocation system to command structure. 3 years ago
Nekojimi 916e73eebd Add playlist playback support, loading & saving. 3 years ago
Nekojimi f6fde8d694 Remove debug messages. 3 years ago
Nekojimi 4ec47df625 Fix issues with queue not playing. 3 years ago
Nekojimi 9a6520f426 Merge commit '594ffa9ba9bb36e2505a6cf25423817a2f3f0bf3' 3 years ago
Nekojimi 594ffa9ba9 Move song queue to new QueueManager class. 3 years ago
Nekojimi 6c3d66069c Properly clear presence message when the queue runs dry. 3 years ago
Nekojimi 1fa14ba168 Update .gitignore to properly ignore target. 3 years ago
Nekojimi 4c62889ba1 Add new song requests system, and update request messages. 3 years ago
Nekojimi 14492728ab Fix no-argument !join command. 3 years ago
  1. 8
      .gitignore
  2. 11
      README.md
  3. BIN
      chords.png
  4. BIN
      chords.xcf
  5. 1
      nb-configuration.xml
  6. 6
      nbactions.xml
  7. 9
      pom.xml
  8. 3
      settings.yml
  9. 556
      src/main/java/moe/nekojimi/chords/Chords.java
  10. 52
      src/main/java/moe/nekojimi/chords/CommandOptions.java
  11. 505
      src/main/java/moe/nekojimi/chords/Downloader.java
  12. 133
      src/main/java/moe/nekojimi/chords/Format.java
  13. 112
      src/main/java/moe/nekojimi/chords/LocalBindSocketFactory.java
  14. 290
      src/main/java/moe/nekojimi/chords/Main.java
  15. 285
      src/main/java/moe/nekojimi/chords/MusicHandler.java
  16. 80
      src/main/java/moe/nekojimi/chords/Playlist.java
  17. 187
      src/main/java/moe/nekojimi/chords/QueueManager.java
  18. 211
      src/main/java/moe/nekojimi/chords/QueueThing.java
  19. 59
      src/main/java/moe/nekojimi/chords/Settings.java
  20. 108
      src/main/java/moe/nekojimi/chords/Track.java
  21. 43
      src/main/java/moe/nekojimi/chords/TrackPlayer.java
  22. 210
      src/main/java/moe/nekojimi/chords/TrackRequest.java
  23. 58
      src/main/java/moe/nekojimi/chords/Util.java
  24. 25
      src/main/java/moe/nekojimi/chords/commands/Command.java
  25. 29
      src/main/java/moe/nekojimi/chords/commands/HelpCommand.java
  26. 85
      src/main/java/moe/nekojimi/chords/commands/Invocation.java
  27. 73
      src/main/java/moe/nekojimi/chords/commands/JoinCommand.java
  28. 8
      src/main/java/moe/nekojimi/chords/commands/LeaveCommand.java
  29. 46
      src/main/java/moe/nekojimi/chords/commands/PlayCommand.java
  30. 50
      src/main/java/moe/nekojimi/chords/commands/QueueCommand.java
  31. 22
      src/main/java/moe/nekojimi/chords/commands/RemoveCommand.java
  32. 14
      src/main/java/moe/nekojimi/chords/commands/RestartCommand.java
  33. 14
      src/main/java/moe/nekojimi/chords/commands/SkipCommand.java

8
.gitignore vendored

@ -19,7 +19,7 @@
*.zip
*.tar.gz
*.rar
target/
target/*
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
@ -55,5 +55,9 @@ dist/
nbdist/
.nb-gradle/
# Git probably
*.orig
# Secrets!
secrets.yml
secrets.yml
settings.yml

@ -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!

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

@ -14,5 +14,6 @@ That way multiple projects can share the same settings (useful for formatting ru
Any value defined here will override the pom.xml file value but is only applicable to the current project.
-->
<netbeans.hint.license>gpl30</netbeans.hint.license>
<org-netbeans-modules-javascript2-requirejs.enabled>true</org-netbeans-modules-javascript2-requirejs.enabled>
</properties>
</project-shared-configuration>

@ -10,7 +10,7 @@
<goal>org.codehaus.mojo:exec-maven-plugin:1.5.0:exec</goal>
</goals>
<properties>
<exec.args>-classpath %classpath moe.nekojimi.chords.Main ODkwNjU5MjI2NDE2OTg0MDY0.YUzBCw.jHZWpIZSYeaYA7Sc7h93W_jV-rk</exec.args>
<exec.args>-classpath %classpath moe.nekojimi.chords.Main -token ODkwNjU5MjI2NDE2OTg0MDY0.YUzBCw.jHZWpIZSYeaYA7Sc7h93W_jV-rk</exec.args>
<exec.executable>java</exec.executable>
</properties>
</action>
@ -24,7 +24,7 @@
<goal>org.codehaus.mojo:exec-maven-plugin:1.5.0:exec</goal>
</goals>
<properties>
<exec.args>-agentlib:jdwp=transport=dt_socket,server=n,address=${jpda.address} -classpath %classpath moe.nekojimi.chords.Main ODkwNjU5MjI2NDE2OTg0MDY0.YUzBCw.jHZWpIZSYeaYA7Sc7h93W_jV-rk</exec.args>
<exec.args>-agentlib:jdwp=transport=dt_socket,server=n,address=${jpda.address} -classpath %classpath moe.nekojimi.chords.Main -token ODkwNjU5MjI2NDE2OTg0MDY0.YUzBCw.jHZWpIZSYeaYA7Sc7h93W_jV-rk</exec.args>
<exec.executable>java</exec.executable>
<jpda.listen>true</jpda.listen>
</properties>
@ -39,7 +39,7 @@
<goal>org.codehaus.mojo:exec-maven-plugin:1.5.0:exec</goal>
</goals>
<properties>
<exec.args>-classpath %classpath ${packageClassName} ODkwNjU5MjI2NDE2OTg0MDY0.YUzBCw.jHZWpIZSYeaYA7Sc7h93W_jV-rk</exec.args>
<exec.args>-classpath %classpath moe.nekojimi.chords.Main -token ODkwNjU5MjI2NDE2OTg0MDY0.YUzBCw.jHZWpIZSYeaYA7Sc7h93W_jV-rk</exec.args>
<exec.executable>java</exec.executable>
</properties>
</action>

@ -20,7 +20,7 @@
<archive>
<manifest>
<mainClass>
moe.nekojimi.chords.Main
moe.nekojimi.chords.Chords
</mainClass>
</manifest>
</archive>
@ -35,7 +35,7 @@
<dependency>
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<version>4.3.0_277</version>
<version>5.0.0-beta.6</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
@ -54,6 +54,11 @@
<version>1.1.4</version>
<type>jar</type>
</dependency>
<dependency>
<groupId>com.beust</groupId>
<artifactId>jcommander</artifactId>
<version>1.78</version>
</dependency>
</dependencies>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

@ -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;
}
}

@ -5,107 +5,134 @@
*/
package moe.nekojimi.chords;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import com.beust.jcommander.Strings;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonReader;
import moe.nekojimi.chords.Downloader.DownloadTask;
import moe.nekojimi.musicsearcher.Result;
import net.dv8tion.jda.api.audio.AudioSendHandler;
/**
*
* @author jimj316
*/
public class Downloader implements Consumer<DownloadTask>
public class Downloader extends QueueThing<TrackRequest, Track>
{
private static final int BITRATE_TARGET = 64_000;
private static final int DOWNLOAD_TIMEOUT = 300;
private static final int INFO_TIMEOUT = 60;
private static final int FORMAT_TIMEOUT = 5;
private static final int BITRATE_TARGET = (int) AudioSendHandler.INPUT_FORMAT.getSampleRate();
private static final Pattern FORMAT_PATTERN = Pattern.compile("^([\\w]+)\\s+([\\w]+)\\s+(\\w+ ?\\w*)\\s+(.*)$");
public static final Pattern DESTINATION_PATTERN = Pattern.compile("Destination: (.*\\.wav)");
public static final Pattern STREAM_PATTERN = Pattern.compile("Destination: -");
public static final Pattern PROGRESS_PATTERN = Pattern.compile("\\[download\\].*?([\\d\\.]+)%");
private static final Pattern INFO_JSON_PATTERN = Pattern.compile("Writing video metadata as JSON to: (.*\\.info\\.json)");
private static final Pattern ETA_PATTERN = Pattern.compile("\\[download\\].*?ETA\\s+(\\d{1,2}:\\d{2})");
private static final Pattern DOWNLOAD_ITEM_PATTERN = Pattern.compile("\\[download\\] Downloading item (\\d+) of (\\d+)");
public static final String[] INFO_TITLE_KEYS =
{
"track", "title", "fulltitle"
};
public static final String[] INFO_ARTIST_KEYS =
{
"artist", "channel", "uploader"
};
private final List<DownloadTask> downloadQueue = new LinkedList<>();
private final List<TrackRequest> downloadingTracks = new LinkedList<>();
private final LinkedBlockingDeque<Runnable> workQueue = new LinkedBlockingDeque<>();
private final ThreadPoolExecutor exec = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, workQueue);
// private Consumer<Song> next;
private BiConsumer<Song, Exception> messageHandler;
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, workQueue);
private final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1);
// private Consumer<Track> next;
private BiConsumer<TrackRequest, Exception> messageHandler;
private File downloadDir = null;
private int trackNumber = 1;
public Downloader()
{
super(new LinkedList<>());
}
//
// @Override
// public void accept(TrackRequest request)
// {
// if all tracks of the request are already downloaded, just skip
// if (!request.getTracks().isEmpty() && request.getTracks().stream().allMatch((t) -> t.isDownloaded()))
// {
// for (Track track : request.getTracks())
// getDestination().accept(track);
// return;
// }
// inputQueue.add(task);
// }
@Override
public void accept(DownloadTask task)
protected boolean completePromise(Promise<TrackRequest, Track> promise)
{
// if already downloaded, just skip
if (task.getSong().isDownloaded())
{
task.getDestination().accept(task.getSong());
return;
}
TrackRequest request = promise.getInput();
if (request == null)
return false;
downloadQueue.add(task);
getInfo(task.getSong());
exec.submit(() ->
executor.submit(() ->
{
downloadingTracks.add(request);
try
{
getFormats(task.getSong());
download(task);
getInfo(request);
// boolean streamOutput = request.getTracks().size() == 1;
boolean streamOutput = false;
download(promise, streamOutput);
} catch (Exception ex)
{
ex.printStackTrace();
}
downloadingTracks.remove(request);
});
return true;
}
private void chooseFormats(Song song)
private List<Format> sortFormats(Collection<Format> input)
{
List<Format> formats = song.getFormats();
if (formats.isEmpty())
return;
// System.out.println("Choosing from " + formats.size() + " formats:");
// System.out.println(formats);
List<Format> formats = new ArrayList<>(input);
formats.sort((Format a, Format b) ->
{
// audio only preferred to video
// System.out.println("sort entered; a=" + a.toString() + " b=" + b.toString());
int comp = 0;
comp = Boolean.compare(a.isAudioOnly(), b.isAudioOnly());
// System.out.println("\tCompared on audio only: " + comp);
if (comp == 0)
{
// known preferred to unknown
if (a.getBitrate() == b.getBitrate())
if (a.getSampleRate() == b.getSampleRate())
comp = 0;
else if (a.getBitrate() <= 0)
else if (a.getSampleRate() <= 0)
comp = 1;
else if (b.getBitrate() <= 0)
else if (b.getSampleRate() <= 0)
comp = -1;
else // closer to the target bitrate is best
{
int aDist = Math.abs(a.getBitrate() - BITRATE_TARGET);
int bDist = Math.abs(b.getBitrate() - BITRATE_TARGET);
int aDist = Math.abs(a.getSampleRate() - BITRATE_TARGET);
int bDist = Math.abs(b.getSampleRate() - BITRATE_TARGET);
comp = Integer.compare(bDist, aDist);
// System.out.println("\tCompared on bitrate distance: " + comp);
}
}
if (comp == 0)
@ -113,53 +140,85 @@ public class Downloader implements Consumer<DownloadTask>
// known preferred to unknown
if (a.getSize() == b.getSize())
comp = 0;
if (a.getSize() <= 0)
else if (a.getSize() <= 0)
comp = 1;
else if (b.getSize() <= 0)
comp = -1;
else // smaller is better
{
comp = Long.compare(b.getSize(), a.getSize());
// System.out.println("\tCompared on filesize: " + comp);
}
}
// System.out.println("\tOverall: " + comp);
return -comp;
});
song.setFormats(formats);
// System.out.println("Sorting done! Formats:" + formats);
return formats;
}
private void getFormats(Song song)
private void getFormats(Track track)
{
//
if (!track.getFormats().isEmpty())
return;
try
{
String cmd = "/usr/bin/youtube-dl --skip-download -F " + song.getUrl().toString();
Process exec = runCommand(cmd, 5);
// String cmd = + " --skip-download -F " + track.getUrl().toString();
Process exec = runCommand(List.of(Chords.getSettings().getYtdlCommand(), "--skip-download", "-F", track.getUrl().toString()), FORMAT_TIMEOUT);
InputStream input = exec.getInputStream();
String output = new String(input.readAllBytes(), Charset.defaultCharset());
List<Format> formats = new ArrayList<>();
int codeCol = 0;
int extCol = 1;
int resCol = 2;
int noteCol = 3;
int sizeCol = -1;
int bitrateCol = -1;
List<String> list = output.lines().collect(Collectors.toList());
int i = 0;
while (!list.get(i).contains("Available formats"))
i++;
i++;
if (list.get(i).contains("FILESIZE"))
{
String[] split = list.get(i).split("\\s+");
for (int j = 0; j < split.length; j++)
{
switch (split[j])
{
case "ID":
codeCol = j;
break;
case "EXT":
extCol = j;
break;
case "RESOLUTION":
resCol = j;
break;
case "MORE":
noteCol = j;
break;
case "FILESIZE":
sizeCol = j;
break;
case "TBR":
bitrateCol = j;
}
}
i += 2;
}
for (; i < list.size(); i++)
{
String line = list.get(i);
String[] split = line.split("\\s\\s+", 4);
String[] split = line.split("\\s+", Math.max(4, noteCol - 1));
if (split.length < 4)
continue;
formats.add(new Format(split[0], split[1], split[2], split[3]));
final Format format = new Format(split[codeCol], split[extCol], split[resCol], split[noteCol]);
if (sizeCol >= 0)
format.setSize(Util.parseSize(split[sizeCol]));
if (bitrateCol >= 0)
format.setSampleRate(Util.parseSampleRate(split[bitrateCol]));
formats.add(format);
}
song.setFormats(formats);
// Matcher matcher = FORMAT_PATTERN.matcher(output);
// while (matcher.find())
// formats.add(new Format(matcher.group(1), matcher.group(2), matcher.group(3), matcher.group(4)));
// return formats;
track.setFormats(formats);
} catch (Exception ex)
{
@ -168,23 +227,88 @@ public class Downloader implements Consumer<DownloadTask>
}
}
private void getInfo(Song song)
private List<Track> getInfo(TrackRequest request)
{
List<Track> ret = new ArrayList<>();
try
{
String cmd = "/usr/bin/youtube-dl --skip-download --print-json " + song.getUrl().toString();
Process exec = runCommand(cmd, 5);
// String cmd = Chords.getSettings().getYtdlCommand() + " --skip-download --print-json " + request.getUrl().toString();
Process exec = runCommand(List.of(Chords.getSettings().getYtdlCommand(), "--skip-download", "--print-json", request.getUrl().toString()), INFO_TIMEOUT);
InputStream input = exec.getInputStream();
JsonReader reader = Json.createReader(input);
JsonObject object = reader.readObject();
if (song.getTitle() == null)
song.setTitle(object.getString("title", null));
if (song.getArtist() == null)
song.setArtist(object.getString("uploader", null));
// read each line as JSON, turn each into a track object
Scanner sc = new Scanner(input);
while (sc.hasNextLine())
{
Track track = new Track(request);
track.setNumber(trackNumber);
trackNumber++;
request.addTrack(track);
String line = sc.nextLine();
JsonReader reader = Json.createReader(new StringReader(line));
JsonObject object = reader.readObject();
Result result = request.getResult();
// look for metadata
if (track.getTitle() == null)
{
if (result != null && !result.getTitle().isBlank())
track.setTitle(result.getTitle());
for (String key : INFO_TITLE_KEYS)
{
if (object.containsKey(key) && !object.getString(key).isBlank())
{
track.setTitle(object.getString(key));
break;
}
}
}
if (track.getArtist() == null)
{
if (result != null && !result.getArtist().isBlank())
track.setArtist(result.getArtist());
for (String key : INFO_ARTIST_KEYS)
{
if (object.containsKey(key) && !object.getString(key).isBlank())
{
track.setArtist(object.getString(key));
break;
}
}
}
if (track.getTitle().contains("-"))
{
String[] split = track.getTitle().split("-", 2);
track.setArtist(split[0]);
track.setTitle(split[1]);
}
if (track.getArtist().contains(" - Topic"))
{
track.setArtist(track.getArtist().replace(" - Topic", ""));
}
JsonArray formatsJSON = object.getJsonArray("formats");
if (formatsJSON != null)
{
List<Format> formats = new ArrayList<>();
for (JsonObject formatJson : formatsJSON.getValuesAs(JsonObject.class))
{
Format format = Format.fromJSON(formatJson);
if (format != null)
formats.add(format);
}
track.setFormats(formats);
}
ret.add(track);
}
} catch (Exception ex)
{
Logger.getLogger(Downloader.class.getName()).log(Level.SEVERE, null, ex);
}
return ret;
}
private File getDownloadDir() throws IOException
@ -194,101 +318,216 @@ public class Downloader implements Consumer<DownloadTask>
return downloadDir;
}
private void download(DownloadTask task)
private Track getTrackFromRequest(TrackRequest request, int idx)
{
Song song = task.song;
chooseFormats(song);
// if there's less tracks in the request than expected, fill the array
while (idx >= request.getTracks().size())
{
Track track = new Track(request);
track.setNumber(trackNumber);
trackNumber++;
request.addTrack(track);
}
return request.getTracks().get(idx);
}
private void download(Promise<TrackRequest, Track> promise, boolean streamOutput) throws InterruptedException, ExecutionException
{
TrackRequest request = promise.getInput();
Set<Format> uniqueFormats = new HashSet<>();
for (Track track : request.getTracks())
{
uniqueFormats.addAll(track.getFormats());
}
List<Format> sortedFormats = sortFormats(uniqueFormats);
String formatCodes = "";
final List<Format> formats = song.getFormats();
for (int i = 0; i < 3 && i < song.getFormats().size(); i++)
final List<Format> formats = sortedFormats;
for (int i = 0; i < 5 && i < sortedFormats.size(); i++)
formatCodes += formats.get(i).getCode() + "/";
int downloadIdx = 0;
try
{
messageHandler.accept(song, null);
String cmd = "/usr/bin/youtube-dl -x"
+ " -f " + formatCodes + "worstaudio/bestaudio/worst/best"
+ " --audio-format=wav"
+ " --no-playlist"
+ " -o " + getDownloadDir().getAbsolutePath() + "/%(title)s.%(ext)s "
+ song.getUrl().toString();
Process exec = runCommand(cmd, 300);
InputStream in = exec.getInputStream();
String output = new String(in.readAllBytes(), Charset.defaultCharset());
String error = new String(exec.getErrorStream().readAllBytes(), Charset.defaultCharset());
System.out.println(output);
Matcher matcher = DESTINATION_PATTERN.matcher(output);
if (matcher.find())
song.setLocation(new File(matcher.group(1)));
else if (exec.exitValue() != 0)
throw new RuntimeException("youtube-dl failed with error " + exec.exitValue() + ", output:\n" + error);
// return true;
if (task.getDestination() != null)
task.getDestination().accept(song);
downloadQueue.remove(task);
messageHandler.accept(song, null);
messageHandler.accept(request, null);
List<String> cmd = new ArrayList<>();
cmd.add(Chords.getSettings().getYtdlCommand());
cmd.add("-x");
cmd.add("-f=" + formatCodes + "worstaudio/bestaudio/worst/best");
cmd.add("--audio-format=wav");
cmd.add("--no-playlist");
// cmd.add(" --extractor-args youtube:player_client=android";
cmd.add("-N8");
if (streamOutput)
{
cmd.add("--downloader=ffmpeg"); // download using FFMpeg
cmd.add("--downloader-args=ffmpeg:-f wav -c:a pcm_s16le"); // tell FFMpeg to convert to wav
cmd.add("-o"); // output to stdout
cmd.add("-");
} else
{
cmd.add("-o " + getDownloadDir().getAbsolutePath() + "/%(title)s.%(ext)s");
}
cmd.add(request.getUrl().toString());
Process exec = runCommand(cmd, streamOutput ? 0 : DOWNLOAD_TIMEOUT);
if (streamOutput)
{
Scanner sc = new Scanner(exec.getErrorStream());
while (sc.hasNextLine())
{
String line = sc.nextLine();
System.out.println(line);
Matcher streamMatcher = STREAM_PATTERN.matcher(line);
if (streamMatcher.find())
{
break;
}
}
if (!exec.isAlive() && exec.exitValue() != 0)
throw new RuntimeException("yt-dlp failed with error code " + exec.exitValue());
Track track = getTrackFromRequest(request, 0);
BufferedInputStream inBuf = new BufferedInputStream(exec.getInputStream());
inBuf.mark(128);
final byte[] headerBytes = inBuf.readNBytes(80);
String header = new String(headerBytes);
System.out.println("streaming data header: " + header);
if (!header.startsWith("RIFF"))
throw new RuntimeException("Streaming data has bad header!");
inBuf.reset();
track.setInputStream(inBuf);
promise.complete(track);
track.setProgress(100.0);
messageHandler.accept(request, null);
} else
{
Scanner sc = new Scanner(exec.getInputStream());
while (sc.hasNextLine())
{
String line = sc.nextLine();
System.out.println(line);
Matcher itemMatcher = DOWNLOAD_ITEM_PATTERN.matcher(line);
if (itemMatcher.find())
{
int idx = Integer.parseInt(itemMatcher.group(1)) - 1;
downloadIdx = idx;
}
Matcher progMatcher = PROGRESS_PATTERN.matcher(line);
if (progMatcher.find())
{
getTrackFromRequest(request, downloadIdx).setProgress(Double.parseDouble(progMatcher.group(1)));
messageHandler.accept(request, null);
}
Matcher destMatcher = DESTINATION_PATTERN.matcher(line);
if (destMatcher.find())
{
Track track = getTrackFromRequest(request, downloadIdx);
track.setLocation(new File(destMatcher.group(1)));
// this is currently our criteria for completion; submit the track and move on
promise.complete(track);
track.setProgress(100.0);
messageHandler.accept(request, null);
downloadIdx++;
}
}
boolean exited = exec.waitFor(DOWNLOAD_TIMEOUT, TimeUnit.SECONDS);
if (exited)
{
String error = new String(exec.getErrorStream().readAllBytes(), Charset.defaultCharset());
if (exec.exitValue() != 0)
throw new RuntimeException("youtube-dl failed with error " + exec.exitValue() + ", output:\n" + error);
} else
{
throw new RuntimeException("youtube-dl failed to exit.");
}
}
messageHandler.accept(request, null);
} catch (Exception ex)
{
Logger.getLogger(Downloader.class.getName()).log(Level.SEVERE, null, ex);
if (messageHandler != null)
messageHandler.accept(song, ex);
downloadQueue.remove(task);
messageHandler.accept(request, ex);
//downloadQueue.remove(task);
}
}
private Process runCommand(String cmd, int timeoutSecs) throws RuntimeException, IOException, InterruptedException
private Process runCommand(List<String> cmd, int timeoutSecs) throws RuntimeException, IOException, InterruptedException
{
System.out.println("Running command: " + cmd);
// Process exec = Runtime.getRuntime().exec().split(" "));
Process exec = new ProcessBuilder(cmd.split(" ")).redirectOutput(ProcessBuilder.Redirect.PIPE).redirectError(ProcessBuilder.Redirect.PIPE).start();
boolean done = exec.waitFor(timeoutSecs, TimeUnit.SECONDS);
if (!done)
Process exec = new ProcessBuilder(cmd).redirectOutput(ProcessBuilder.Redirect.PIPE).redirectError(ProcessBuilder.Redirect.PIPE).start();
if (timeoutSecs > 0)
{
exec.destroyForcibly();
throw new RuntimeException("Took too long, giving up.");
scheduler.schedule(() ->
{
if (exec.isAlive())
{
exec.destroyForcibly();
System.err.println("Process " + cmd + " took too long, killing process.");
}
}, timeoutSecs, TimeUnit.SECONDS);
}
return exec;
}
public BiConsumer<Song, Exception> getMessageHandler()
public BiConsumer<TrackRequest, Exception> getMessageHandler()
{
return messageHandler;
}
public void setMessageHandler(BiConsumer<Song, Exception> messageHandler)
public void setMessageHandler(BiConsumer<TrackRequest, Exception> messageHandler)
{
this.messageHandler = messageHandler;
}
public List<DownloadTask> getDownloadQueue()
public List<TrackRequest> getDownloadQueue()
{
return downloadQueue;
return new ArrayList<>(inputQueue);
}
public static class DownloadTask
{
private final Song song;
private final Consumer<Song> destination;
public DownloadTask(Song song, Consumer<Song> destination)
{
this.song = song;
this.destination = destination;
}
public Song getSong()
{
return song;
}
public Consumer<Song> getDestination()
{
return destination;
}
// public static class DownloadTask
// {
//
// private final TrackRequest request;
// private final Consumer<Track> destination;
//
// public DownloadTask(TrackRequest request, Consumer<Track> destination)
// {
// this.request = request;
// this.destination = destination;
// }
//
// public TrackRequest getTrack()
// {
// return request;
// }
//
// public Consumer<Track> getDestination()
// {
// return destination;
// }
//
// }
public List<TrackRequest> getDownloadingTracks()
{
return downloadingTracks;
}
}

@ -7,8 +7,8 @@ package moe.nekojimi.chords;
import com.amihaiemil.eoyaml.Yaml;
import com.amihaiemil.eoyaml.YamlMapping;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.Objects;
import javax.json.JsonObject;
/**
*
@ -17,13 +17,14 @@ import java.util.regex.Pattern;
class Format
{
private static final Pattern SIZE_PATTERN = Pattern.compile("\\b([0-9]+\\.?[0-9]*)([kkMmGg])i?[bB]\\b");
private static final Pattern BITRATE_PATTERN = Pattern.compile("\\b([0-9]+)k(b(ps?))?\\b");
private final String code;
private final String extension;
private final String resolution;
private final String note;
private long size = -1;
private int samplerate = -1;
private boolean audioOnly = false;
public Format(String code, String extension, String resolution, String note)
{
@ -33,6 +34,28 @@ class Format
this.note = note;
}
/**
* Read format info from a youtube-dl JSON response.
*
* @param object the JSON object from youtube-dl.
* @return a new Format object if the JSON is valid, or null if not.
*/
public static Format fromJSON(JsonObject object)
{
String code = object.getString("format_id");
String ext = object.getString("ext");
// String res = object.getString()
String res = object.getString("format");
String note = "";
Format ret = new Format(code, ext, res, note);
int asr = object.getInt("asr", -1);
int size = object.getInt("filesize", -1);
ret.setSampleRate(asr);
ret.setSize(size);
return ret;
}
public static Format fromYaml(YamlMapping yaml)
{
Format format = new Format(
@ -55,46 +78,44 @@ class Format
public boolean isAudioOnly()
{
return resolution.trim().toLowerCase().contains("audio only");
if (audioOnly)
return true;
return resolution.trim().toLowerCase().contains("audio only") || note.trim().toLowerCase().contains("tiny");
}
public void setAudioOnly(boolean audioOnly)
{
this.audioOnly = audioOnly;
}
public long getSize()
{
if (size != -1)
return size;
// try to find eg. "1.32MiB" inside note
Matcher matcher = SIZE_PATTERN.matcher(note);
if (matcher.find())
{
double value = Double.parseDouble(matcher.group(1));
String mag = matcher.group(2).toUpperCase();
long mult = 1;
switch (mag)
{
case "K":
mult = 1024;
break;
case "M":
mult = 1024 * 1024;
break;
case "G":
mult = 1024 * 1024 * 1024;
break;
}
value *= mult;
return (long) value;
}
return -1;
}
public int getBitrate()
long size = Util.parseSize(note);
return size;
}
public int getSampleRate()
{
if (samplerate != -1)
return samplerate;
// try to find eg. "51k" inside note
Matcher matcher = BITRATE_PATTERN.matcher(note);
if (matcher.find())
{
return Integer.parseInt(matcher.group(1)) * 1000;
}
return -1;
return Util.parseSampleRate(note);
}
public void setSampleRate(int samplerate)
{
this.samplerate = samplerate;
}
public void setSize(long size)
{
this.size = size;
}
public String getCode()
@ -123,4 +144,42 @@ class Format
return "Format{" + "code=" + code + ", extension=" + extension + ", resolution=" + resolution + ", note=" + note + '}';
}
@Override
public int hashCode()
{
int hash = 7;
hash = 97 * hash + Objects.hashCode(this.extension);
hash = 97 * hash + Objects.hashCode(this.resolution);
hash = 97 * hash + (int) (this.size ^ (this.size >>> 32));
hash = 97 * hash + this.samplerate;
hash = 97 * hash + (this.audioOnly ? 1 : 0);
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 Format other = (Format) obj;
if (this.size != other.size)
return false;
if (this.samplerate != other.samplerate)
return false;
if (this.audioOnly != other.audioOnly)
return false;
if (!Objects.equals(this.code, other.code))
return false;
if (!Objects.equals(this.extension, other.extension))
return false;
if (!Objects.equals(this.resolution, other.resolution))
return false;
return true;
}
}

@ -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,290 +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 java.io.File;
import java.net.URL;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import javax.security.auth.login.LoginException;
import moe.nekojimi.chords.commands.*;
import moe.nekojimi.musicsearcher.Result;
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 class Main extends ListenerAdapter
{
private MusicHandler musicHandler;
private final Downloader downloader;
private final Searcher searcher;
private JDA jda;
private final Map<String, Command> commands = new HashMap<>();
private final Command helpCommand;
private VoiceChannel currentVoiceChannel = null;
private int trackNumber = 1;
/**
* @param args the command line arguments
*/
public static void main(String[] args) throws LoginException
{
// 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
);
JDABuilder builder = JDABuilder.createDefault(args[0], intents);
// Disable parts of the cache
builder.disableCache(CacheFlag.MEMBER_OVERRIDES, CacheFlag.VOICE_STATE);
// 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);
JDA jda = builder.build();
listener.setJda(jda);
}
public Main()
{
log("INFO", "Starting up...");
downloader = new Downloader();
downloader.setMessageHandler((Song song, Exception ex) ->
{
TextChannel channel = jda.getTextChannelById(song.getRequestedIn());
// String bracketNo = "[" + song.getNumber() + "] ";
if (channel != null)
if (ex == null)
if (song.getLocation() != null)
{
channel.sendMessage(/*bracketNo + */"Finished downloading " + song + " for " + song.getRequestedBy() + ", added to queue!").queue();
log("DOWN", "Downloaded " + song);
} else
{
Format format = song.getBestFormat();
String formatDetails = "";
if (format != null)
{
formatDetails = " (" + format.getBitrate() / 1000 + "k, " + String.format("%.2f", format.getSize() / (1024.0 * 1024.0)) + "MiB)";
}
channel.sendMessage(/*bracketNo + */"Now downloading " + song + formatDetails + " for " + song.getRequestedBy() + " ...").queue();
log("DOWN", "Downloading " + song + "...");
}
else
{
channel.sendMessage(/*bracketNo + */"Failed to download " + song + " for " + song.getRequestedBy() + "! Reason: " + ex.getMessage()).queue();
log("DOWN", "Failed to download " + song + "! Reason: " + ex.getMessage());
}
});
searcher = MetaSearcher.loadYAML(new File("searchproviders.yml"));
addCommand(new JoinCommand(this));
addCommand(new LeaveCommand(this));
addCommand(new PlayCommand(this));
addCommand(new QueueCommand(this));
addCommand(new RemoveCommand(this));
addCommand(new RestartCommand(this));
addCommand(new SkipCommand(this));
helpCommand = new HelpCommand(this);
addCommand(helpCommand);
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
{
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(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;
}
/**
* 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();
// 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;
}
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 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 int getTrackNumber()
{
return trackNumber;
}
}

@ -6,241 +6,199 @@
package moe.nekojimi.chords;
import java.io.*;
import moe.nekojimi.chords.Util;
import java.nio.ByteBuffer;
import java.util.LinkedList;
import java.util.Queue;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.sound.sampled.*;
import net.dv8tion.jda.api.audio.AudioSendHandler;
import org.apache.commons.io.input.buffer.CircularByteBuffer;
/**
*
* @author jimj316
*/
public class MusicHandler implements AudioSendHandler, Closeable, Consumer<Song>
public class MusicHandler implements AudioSendHandler, Closeable, Consumer<Track>
{
private final LinkedList<Song> songQueue = new LinkedList<>();
// private QueueThing<?, Track> queueManager;
private QueueManager queueManager;
// private final LinkedList<Track> trackQueue = new LinkedList<>();
// private final Queue<byte[]> queue = new ConcurrentLinkedQueue<>();
private final CircularByteBuffer audioBuffer = new CircularByteBuffer(3840 * 1024);
private boolean playing = true;
// private final CircularByteBuffer audioBuffer = new CircularByteBuffer(3840 * 1024);
private boolean shouldPlay = true;
private int byteCount;
private boolean arrayErr = false;
private Consumer<Track> nowPlayingConsumer;
private Song currentSong;
private TrackPlayer player;
public void setNowPlayingConsumer(Consumer<Track> nowPlayingConsumer)
{
this.nowPlayingConsumer = nowPlayingConsumer;
}
private File debugOutFile;
private BufferedOutputStream debugOut;
private Track currentTrack;
// private TrackPlayer player;
private final List<TrackPlayer> playingTracks = new ArrayList<>();
public MusicHandler()
{
try
{
debugOutFile = new File("debug.wav");
if (debugOutFile.exists())
debugOutFile.delete();
debugOutFile.createNewFile();
debugOut = new BufferedOutputStream(new FileOutputStream(debugOutFile));
} catch (IOException ex)
{
Logger.getLogger(MusicHandler.class.getName()).log(Level.SEVERE, null, ex);
}
}
public void addSong(Song song)
{
System.out.println("Song added to queue: " + song.getLocation().getAbsolutePath());
songQueue.add(song);
if (!canProvide() && playing)
nextSong();
}
public boolean removeSong(int i)
@Override
public void accept(Track t)
{
try
{
songQueue.remove(i);
return true;
} catch (ArrayIndexOutOfBoundsException ex)
{
return false;
}
play(t);
}
public boolean removeSong(Song song)
void setQueueManager(QueueManager manager)
{
return songQueue.remove(song);
queueManager = manager;
}
public boolean restartSong()
public void playOver(Track track)
{
songQueue.addFirst(currentSong);
currentSong = null;
return nextSong(true);
}
private boolean nextSong()
public boolean play(Track track)
{
return nextSong(false);
return play(track, false);
}
public boolean nextSong(boolean immediate)
public boolean play(Track track, boolean immediate)
{
if (track == currentTrack)
return false;
if (immediate)
{
System.out.println("Immediate next - clearing buffer");
audioBuffer.clear();
playingTracks.clear();
}
try
{
if (currentSong != null)
if (currentTrack != null)
{
if (!currentSong.isKept())
currentSong.delete();
currentSong = null;
if (!currentTrack.isKept())
currentTrack.delete();
currentTrack = null;
}
currentSong = songQueue.poll();
if (currentSong == null)
currentTrack = track;
if (nowPlayingConsumer != null)
nowPlayingConsumer.accept(currentTrack);
if (currentTrack == null)
{
System.out.println("End of queue.");
debugOut.flush();
return false;
}
System.out.println("Playing song " + currentSong.getLocation().getAbsolutePath());
// System.out.println("Playing track " + currentTrack.getLocation().getAbsolutePath());
arrayErr = false;
byteCount = 3840;
player = new TrackPlayer(currentSong);
TrackPlayer player = new TrackPlayer(currentTrack);
playingTracks.add(player);
// System.out.println("Queue filled to " + audioBuffer.getCurrentNumberOfBytes());
return true;
} catch (UnsupportedAudioFileException | IOException ex)
{
Logger.getLogger(Main.class.getName()).log(Level.SEVERE, null, ex);
Logger.getLogger(Chords.class.getName()).log(Level.SEVERE, null, ex);
currentTrack = null;
requestTrack();
} finally
{
}
return false;
}
public boolean requestTrack()
{
if (isPlaying())
System.out.println("How did we get here?");
System.out.println("MusicHandler requesting track...");
List<QueueThing.Promise<Track, Track>> request = queueManager.request(1, this);
// Queuemanager will syncronously attempt to call play()
return !playingTracks.isEmpty();
}
public boolean isPlaying()
{
return playing;
return !playingTracks.isEmpty();
}
public boolean isShouldPlay()
{
return shouldPlay;
}
public void setPlaying(boolean playing)
public void setShouldPlay(boolean shouldPlay)
{
if (!this.playing && playing)
nextSong();
this.playing = playing;
if (!this.shouldPlay && shouldPlay)
requestTrack();
this.shouldPlay = shouldPlay;
}
@Override
public boolean canProvide()
{
return player != null && player.has(1);
if (playingTracks.isEmpty())
return false;
for (TrackPlayer player : playingTracks)
if (player.has(1))
return true;
return false;
// If we have something in our buffer we can provide it to the send system
// return audioBuffer.getCurrentNumberOfBytes() > byteCount && playing;
// return audioBuffer.getCurrentNumberOfBytes() > byteCount && shouldPlay;
}
@Override
public ByteBuffer provide20MsAudio()
{
ByteBuffer ret = ByteBuffer.allocate(byteCount);
while (ret.position() < byteCount && player != null)
while (ret.position() < byteCount && !playingTracks.isEmpty())
{
// System.out.println("Position: " + ret.position() + " Remaining: " + ret.remaining());
try
boolean outOfInput = true;
List<ByteBuffer> mixes = new ArrayList<>();
List<TrackPlayer> emptyPlayers = new ArrayList<>();
for (TrackPlayer player : playingTracks)
{
ByteBuffer read = player.read(ret.remaining());
// System.out.println("SAMPLES from player: " + Util.printSamples(read));
System.out.println("Read: " + read.remaining());
ret.put(read);
} catch (TrackPlayer.OutOfInputException | IOException ex)
try
{
ByteBuffer read = player.read(ret.remaining());
if (ret.limit() + read.position() >= byteCount)
outOfInput = false;
mixes.add(read);
// ret.put(read);
} catch (TrackPlayer.OutOfInputException | IOException ex)
{
// System.out.println("Track player " + player + " stopped giving input: " + ex.getMessage());
emptyPlayers.add(player);
// System.out.println("Track ended, starting next.");
// outOfInput = true;
}
}
playingTracks.removeAll(emptyPlayers);
ret.put(mixBuffers(mixes));
if (outOfInput)
{
System.out.println("Track ended, starting next.");
boolean foundNext = nextSong();
boolean foundNext = requestTrack();
if (!foundNext)
{
System.out.println("Out of tracks!");
break;
}
}
}
System.out.println("Buffer filled, submitting.");
// System.out.println("Buffer filled, submitting.");
ret.rewind(); // required apparently, if returned buf has pos > 0 you get silence
assert ret.hasArray(); // output MUST be array backed
return ret;
}
// private void fillBuffer(boolean canSkip)
// {
// // use what we have in our buffer to send audio as PCM
// while (audioBuffer.getCurrentNumberOfBytes() < DESIRED_BUFFER_SIZE)
// if (!readData())
// if (!canSkip || !nextSong())
// break;
// }
//
// private boolean readData()
// {
// if (din == null)
// return false;
// try
// {
// // if (din.available() == 0)
// // return false;
// int bytesToRead = DESIRED_BUFFER_SIZE - audioBuffer.getCurrentNumberOfBytes();
// int space = audioBuffer.getSpace();
// if (din.available() > 0 && din.available() < bytesToRead)
// bytesToRead = din.available();
// if (bytesToRead > space)
// bytesToRead = space;
// if (bytesToRead == 0)
// return false;
// byte[] bytes = new byte[bytesToRead];
// // byte[] bytes = din.readNBytes(bytesToRead);
// int read = din.read(bytes);
//// System.out.println("Wanted: " + byteCount + " Space:" + space + " Available: " + din.available() + " To read: " + bytesToRead + " Read: " + read);
// if (read < 0)
// return false;
//// queue.add(bytes);
//
// audioBuffer.add(bytes, 0, read);
// } catch (IOException ex)
// {
// Logger.getLogger(Main.class.getName()).log(Level.SEVERE, null, ex);
// return false;
// } catch (ArrayIndexOutOfBoundsException ex)
// {
// if (!arrayErr)
// arrayErr = true;
// else
// {
// Logger.getLogger(Main.class.getName()).log(Level.SEVERE, null, ex);
// return false;
// }
// }
// return true;
// }
public Queue<Song> getSongQueue()
public Track getCurrentTrack()
{
return songQueue;
}
public Song getCurrentSong()
{
return currentSong;
return currentTrack;
}
@Override
@ -254,9 +212,48 @@ public class MusicHandler implements AudioSendHandler, Closeable, Consumer<Song>
{
}
@Override
public void accept(Song t)
private ByteBuffer mixBuffers(List<ByteBuffer> mixes)
{
addSong(t);
// System.out.println("Mixing " + mixes.size() + " buffers");
if (mixes.size() == 1)
return mixes.get(0);
int maxSize = 0;
for (ByteBuffer buf : mixes)
{
if (buf.limit() > maxSize)
maxSize = buf.position();
}
ByteBuffer ret = ByteBuffer.allocate(maxSize);
for (int i = 0; i < ret.limit(); i++)
{
int byteTotal = 0;
int mixCount = 0;
for (ByteBuffer buf : mixes)
{
if (i < buf.limit())
{
byteTotal += buf.get(i);
mixCount++;
}
}
double avg = ((double) byteTotal) / mixCount;
byte byteVal = (byte) Math.round(avg);
ret.put(byteVal);
}
ret.rewind();
return ret;
}
public boolean skipTrack()
{
if (!isPlaying())
return false;
playingTracks.clear();
currentTrack = null;
requestTrack();
return true;
}
}

@ -16,19 +16,28 @@
*/
package moe.nekojimi.chords;
import com.amihaiemil.eoyaml.Yaml;
import com.amihaiemil.eoyaml.YamlMapping;
import java.util.ArrayList;
import java.util.List;
import com.amihaiemil.eoyaml.YamlSequence;
import com.amihaiemil.eoyaml.YamlSequenceBuilder;
import java.net.MalformedURLException;
import java.util.*;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
*
* @author jimj316
*/
public class Playlist
public class Playlist implements Consumer<Track>
{
private static final int SHUFFLE_DONT_REPEAT_LAST = 3;
private final String name;
private final List<Song> songs = new ArrayList<>();
private final List<Track> tracks = new ArrayList<>();
private final LinkedList<Track> playHistory = new LinkedList<>();
public Playlist(String name)
{
@ -37,18 +46,37 @@ public class Playlist
public YamlMapping toYaml()
{
throw new UnsupportedOperationException("Not supported yet.");
YamlSequenceBuilder trackList = Yaml.createYamlSequenceBuilder();
for (Track track : tracks)
trackList = trackList.add(track.toYaml());
return Yaml.createYamlMappingBuilder()
.add("name", name)
.add("tracks", trackList.build())
.build();
}
public static Playlist fromYaml(YamlMapping yaml)
{
throw new UnsupportedOperationException("Not supported yet.");
Playlist ret = new Playlist(yaml.string("name"));
YamlSequence trackList = yaml.value("tracks").asSequence();
for (int i = 0; i < trackList.size(); i++)
{
try
{
ret.addTrack(Track.fromYaml(trackList.yamlMapping(i)));
} catch (MalformedURLException ex)
{
Logger.getLogger(Playlist.class.getName()).log(Level.SEVERE, null, ex);
}
}
return ret;
}
public void addSong(Song song)
public void addTrack(Track track)
{
song.setKept(true);
songs.add(song);
track.setKept(true);
tracks.add(track);
}
public String getName()
@ -56,9 +84,39 @@ public class Playlist
return name;
}
public List<Song> getSongs()
public List<Track> getTracks()
{
return tracks;
}
public Track getNextTrack()
{
Track ret;
// copy the track list
List<Track> toShuffle = new LinkedList<>(tracks);
// remove play history from candidates, latest first, unless we'd have less than 2 options
for (int i = playHistory.size() - 1; i >= 0; i--)
{
if (toShuffle.size() <= 2)
break;
toShuffle.remove(playHistory.get(i));
}
Collections.shuffle(toShuffle);
ret = toShuffle.get(0);
playHistory.add(ret);
if (playHistory.size() > SHUFFLE_DONT_REPEAT_LAST)
playHistory.remove();
return ret;
}
@Override
public void accept(Track t)
{
return songs;
addTrack(t);
}
}

@ -0,0 +1,187 @@
/*
* 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.util.List;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.function.Consumer;
/**
*
* @author jimj316
*/
public class QueueManager extends QueueThing<Track, Track>
{
private final int QUEUE_TARGET_SIZE = 5;
private Track restartingTrack = null;
// private final PriorityQueue<Track> jukeboxQueue = new PriorityQueue<>();
private QueueThing<?, Track> trackSource;
private Playlist playlist;
private MusicHandler handler;
public QueueManager()
{
super(new PriorityQueue<Track>());
queueTargetSize = QUEUE_TARGET_SIZE;
// jukeboxQueue = new LinkedList<>();
}
@Override
protected void notifyNewInput()
{
super.notifyNewInput();
// if (inputQueue.size() < QUEUE_TARGET_SIZE)
// demandInput(QUEUE_TARGET_SIZE - inputQueue.size());
if (handler != null && !handler.isPlaying() || handler.getCurrentTrack() == null)
handler.requestTrack();
}
@Override
protected boolean completePromise(Promise<Track, Track> request)
{
final Track input = request.getInput();
if (input != null)
{
request.complete(input);
return true;
}
return false;
}
@Override
public List<Promise<Track, Track>> request(int count, Consumer<Track> destination)
{
if (restartingTrack != null)
{
Promise ret = new Promise<>(restartingTrack, destination);
restartingTrack = null;
return List.of(ret);
}
List<Promise<Track, Track>> ret = super.request(count, destination);
if (inputQueue.size() < QUEUE_TARGET_SIZE)
demandInput(QUEUE_TARGET_SIZE - inputQueue.size());
return ret;
}
/**
* Called by the music handler when the current track has ended, or if
* playNext is called with nothing playing.
*
* @return the next track to play, or null to stop playing.
*/
// public Track nextTrackNeeded()
// {
// Track ret;
// // if we're restarting the current track: store, clear, and return it
// if (restartingTrack != null)
// {
// ret = restartingTrack;
// restartingTrack = null;
// }
// // if there's anything in the queue, play that first
// else if (!jukeboxQueue.isEmpty())
// {
// ret = jukeboxQueue.poll();
// }
// // otherwise if there's a playlist, shuffle from that
// else if (playlist != null)
// {
// ret = playlist.getNextTrack();
// }
// // otherwise stop playing
// else
// ret = null;
//
// return ret;
// }
public MusicHandler getHandler()
{
return handler;
}
public void addTrack(Track track)
{
System.out.println("Track added to queue: " + track.getLocation().getAbsolutePath());
inputQueue.add(track);
}
public boolean removeTrack(int i)
{
try
{
return inputQueue.remove((Track) inputQueue.toArray()[i]);
} catch (ArrayIndexOutOfBoundsException ex)
{
return false;
}
}
public boolean removeTrack(Track track)
{
return inputQueue.remove(track);
}
public void setHandler(MusicHandler handler)
{
this.handler = handler;
handler.setQueueManager(this);
}
public Queue<Track> getJukeboxQueue()
{
return inputQueue;
}
public Playlist getPlaylist()
{
return playlist;
}
public void setPlaylist(Playlist playlist)
{
this.playlist = playlist;
}
public boolean restartTrack()
{
restartingTrack = handler.getCurrentTrack();
if (restartingTrack != null)
{
handler.requestTrack();
return true;
} else
return false;
}
public QueueThing<?, Track> getTrackSource()
{
return trackSource;
}
public void setTrackSource(QueueThing<?, Track> trackSource)
{
this.trackSource = trackSource;
}
}

@ -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;
}
}

@ -10,6 +10,9 @@ import com.amihaiemil.eoyaml.YamlMapping;
import com.amihaiemil.eoyaml.YamlSequence;
import com.amihaiemil.eoyaml.YamlSequenceBuilder;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
@ -19,22 +22,33 @@ import java.util.List;
*
* @author jimj316
*/
public class Song
public class Track implements Comparable<Track>
{
private String title;
private String artist;
private final URL url;
private File location = null;
private InputStream inputStream = null;
private int number;
private List<Format> formats = new ArrayList<>();
private String requestedBy;
private String requestedIn;
private boolean kept = false;
public Song(URL url)
private double progress = -1;
private double eta = -1;
private final TrackRequest request;
public Track(URL url, TrackRequest request)
{
this.url = url;
this.request = request;
}
public Track(TrackRequest request)
{
this.request = request;
this.url = request.getUrl();
}
public YamlMapping toYaml()
@ -50,29 +64,30 @@ public class Song
.add("location", location.getAbsolutePath())
.add("num", Integer.toString(number))
.add("formats", build.build())
.add("requestedBy", requestedBy)
.add("requestedIn", requestedIn)
// .add("requestedBy", requestedBy)
// .add("requestedIn", requestedIn)
.add("kept", Boolean.toString(kept))
.build();
}
public static Song fromYaml(YamlMapping map) throws MalformedURLException
public static Track fromYaml(YamlMapping map) throws MalformedURLException
{
Song song = new Song(new URL(map.string("url")));
song.setArtist(map.string("artist"));
song.setLocation(new File(map.string("location")));
song.setNumber(map.integer("num"));
song.setKept(Boolean.parseBoolean(map.string("kept")));
song.setRequestedBy(map.string("requestedBy"));
song.setRequestedIn(map.string("requestedIn"));
Track track = new Track(new URL(map.string("url")), null);
track.setArtist(map.string("artist"));
track.setTitle(map.string("title"));
track.setLocation(new File(map.string("location")));
track.setNumber(map.integer("num"));
track.setKept(Boolean.parseBoolean(map.string("kept")));
// track.setRequestedBy(map.string("requestedBy"));
// track.setRequestedIn(map.string("requestedIn"));
List<Format> formats = new ArrayList<>();
YamlSequence formatSeq = map.yamlSequence("formats");
for (int i = 0; i < formats.size(); i++)
formats.add(Format.fromYaml(formatSeq.yamlMapping(i)));
song.setFormats(formats);
track.setFormats(formats);
return song;
return track;
}
public boolean isDownloaded()
@ -125,31 +140,23 @@ public class Song
this.location = location;
}
void delete()
public InputStream getInputStream() throws FileNotFoundException
{
if (location != null)
location.delete();
location = null;
if (inputStream == null && location != null)
inputStream = new FileInputStream(location);
return inputStream;
}
public String getRequestedBy()
public void setInputStream(InputStream inputStream)
{
return requestedBy;
this.inputStream = inputStream;
}
public void setRequestedBy(String requestedBy)
{
this.requestedBy = requestedBy;
}
public String getRequestedIn()
{
return requestedIn;
}
public void setRequestedIn(String requestedIn)
void delete()
{
this.requestedIn = requestedIn;
if (location != null)
location.delete();
location = null;
}
public boolean isKept()
@ -162,6 +169,11 @@ public class Song
this.kept = kept;
}
public TrackRequest getRequest()
{
return request;
}
@Override
public String toString()
{
@ -175,7 +187,7 @@ public class Song
if (ret.isEmpty())
ret = "track " + number;
return "[" + number + "] " + ret;
return /*"[" + number + "] " + */ ret;
// return url.toExternalForm();
}
@ -196,4 +208,30 @@ public class Song
return formats.get(0);
}
public double getProgress()
{
return progress;
}
public void setProgress(double progress)
{
this.progress = progress;
}
public double getEta()
{
return eta;
}
public void setEta(double eta)
{
this.eta = eta;
}
@Override
public int compareTo(Track o)
{
return Integer.compare(number, o.number);
}
}

@ -5,6 +5,7 @@
*/
package moe.nekojimi.chords;
import java.io.BufferedInputStream;
import java.io.Closeable;
import java.io.IOException;
import java.nio.ByteBuffer;
@ -26,6 +27,8 @@ public class TrackPlayer implements Closeable
private static final int DESIRED_BUFFER_SIZE = 3840 * 500;
private static final int MAX_READ_FAILS = 3;
private static final int RETRY_COUNT = 8;
private static final int RETRY_DELAY = 100;
private final CircularByteBuffer audioBuffer = new CircularByteBuffer(3840 * 1024);
@ -33,10 +36,38 @@ public class TrackPlayer implements Closeable
private boolean arrayErr = false; // supresses ArrayIndexOutOfBoundsException after the first time, to prevent spam
public TrackPlayer(Song song) throws UnsupportedAudioFileException, IOException
public TrackPlayer(Track track) throws UnsupportedAudioFileException, IOException
{
AudioInputStream in = AudioSystem.getAudioInputStream(song.getLocation());
AudioFormat decodedFormat = AudioSendHandler.INPUT_FORMAT;
AudioInputStream in = null;
AudioFormat decodedFormat = null;
int retry = 0;
while (in == null)
{
try
{
in = AudioSystem.getAudioInputStream(new BufferedInputStream(track.getInputStream()));
decodedFormat = AudioSendHandler.INPUT_FORMAT;
break; // it worked!
} catch (Exception ex)
{
retry++;
if (retry < RETRY_COUNT)
{
System.err.println("Open file " + track.getLocation() + " failed because " + ex.getMessage() + " retry " + retry + "...");
try
{
Thread.sleep(((long) Math.pow(2, retry)) * RETRY_DELAY);
} catch (InterruptedException ex1)
{
}
} else
{
throw ex;
}
}
}
input = AudioSystem.getAudioInputStream(decodedFormat, in);
fillBuffer(false);
}
@ -47,7 +78,7 @@ public class TrackPlayer implements Closeable
fillBuffer(false);
}
boolean has(int byteCount)
public boolean has(int byteCount)
{
// return true;
return audioBuffer.getCurrentNumberOfBytes() >= byteCount;
@ -132,13 +163,13 @@ public class TrackPlayer implements Closeable
return true;
}
@Override
public void close() throws IOException
{
input.close();
input.close(); //q
}
public static class OutOfInputException extends RuntimeException
{

@ -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;
}
}

@ -1,6 +1,8 @@
package moe.nekojimi.chords;
import java.nio.ByteBuffer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*
* Copyright (C) 2022 jimj316
@ -25,6 +27,10 @@ import java.nio.ByteBuffer;
*/
public class Util
{
private static final Pattern SIZE_PATTERN = Pattern.compile("\\b([0-9]+\\.?[0-9]*)([kkMmGg])i?[bB]\\b");
private static final Pattern SAMPLE_RATE_PATTERN = Pattern.compile("\\b([0-9]+)k(b(ps?))?\\b");
public static String printSamples(ByteBuffer buf)
{
StringBuilder sb = new StringBuilder();
@ -34,4 +40,56 @@ public class Util
}
return sb.toString();
}
public static int parseSampleRate(String input)
{
Matcher matcher = SAMPLE_RATE_PATTERN.matcher(input);
if (matcher.find())
{
return Integer.parseInt(matcher.group(1)) * 1000;
}
return -1;
}
public static long parseSize(String note)
{
Matcher matcher = SIZE_PATTERN.matcher(note);
if (matcher.find())
{
double value = Double.parseDouble(matcher.group(1));
String mag = matcher.group(2).toUpperCase();
long mult = 1;
switch (mag)
{
case "K":
mult = 1024;
break;
case "M":
mult = 1024 * 1024;
break;
case "G":
mult = 1024 * 1024 * 1024;
break;
}
value *= mult;
return (long) value;
}
return -1L;
}
public static String formatProgressBar(double progressPercent, String[] symbols, int barLength)
{
String ret = "";
double filledSegments = (progressPercent / 100.0) * barLength;
for (int i = 0; i < barLength; i++)
{
double thisSegment = Math.min(1, Math.max(0, filledSegments - i));
int symbolIdx = (int) Math.floor(thisSegment * symbols.length);
String symbol = symbols[symbolIdx];
ret += symbol;
}
return ret;
}
}

@ -5,9 +5,7 @@
*/
package moe.nekojimi.chords.commands;
import java.util.List;
import moe.nekojimi.chords.Main;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import moe.nekojimi.chords.Chords;
/**
*
@ -16,26 +14,35 @@ import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
public abstract class Command
{
protected final Main bot;
protected final Chords bot;
protected final String keyword;
protected String documentation;
public Command(Main bot, String keyword)
public Command(Chords bot, String keyword)
{
this.bot = bot;
this.keyword = keyword;
}
public abstract void call(GuildMessageReceivedEvent event, List<String> arg);
public abstract void call(Invocation invocation);
public String getKeyword()
{
return keyword;
}
public String getDocumentation()
public String argumentDescription()
{
return documentation;
return ""; // most commands take no arguments
}
public String synopsis()
{
throw new UnsupportedOperationException("Not supported yet.");
}
//
public String help()
{
throw new UnsupportedOperationException("Not supported yet.");
}
}

@ -16,35 +16,36 @@
*/
package moe.nekojimi.chords.commands;
import java.util.List;
import moe.nekojimi.chords.Main;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import java.util.Map;
import java.util.Map.Entry;
import moe.nekojimi.chords.Chords;
public class HelpCommand extends Command
{
public HelpCommand(Main bot)
public HelpCommand(Chords bot)
{
super(bot, "help");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> arg)
public void call(Invocation invocation)
{
String help = "Commands available:\n"
+ "!join <Channel> - Joins a voice channel\n"
+ "!leave - Leaves the current voice channel\n"
+ "!play <URL> - Downloads the track at that URL and adds it to the queue.\n"
+ "!play <Search> - Searches for a track and displays a selection menu.\n"
+ "!queue - Show the songs currently playing and the current queue.\n"
+ "!remove <Index> - Remove the song at position <Index> from the queue.\n"
+ "!skip - Skip the current song and play the next one.\n"
+ "!restart - Try playing the current song again in case it goes wrong.\n";
// for (String key: commands.keySet())
// {
// help += "!" + key + ":"
// }
event.getChannel().sendMessage(help).queue();
+ "!queue - Show the tracks currently playing and the current queue.\n"
+ "!remove <Index> - Remove the track at position <Index> from the queue.\n"
+ "!skip - Skip the current track and play the next one.\n"
+ "!restart - Try playing the current track again in case it goes wrong.\n";
final Map<String, Command> commands = bot.getCommands();
for (Entry<String, Command> e : commands.entrySet())
{
help += "!" + e.getKey() + " ".repeat(10 - e.getKey().length()) + "- " + e.getValue().synopsis();
}
invocation.respond(help);
}
}

@ -0,0 +1,85 @@
/*
* 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.commands;
import java.util.List;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
/**
*
* @author jimj316
*/
public class Invocation
{
private Message requestMessage;
private Message responseMessage;
private final MessageReceivedEvent event;
private final List<String> args;
public Invocation(MessageReceivedEvent event, List<String> args)
{
this.event = event;
this.args = args;
}
public MessageReceivedEvent getEvent()
{
return event;
}
public List<String> getArgs()
{
return args;
}
@SuppressWarnings("null")
public void respond(String text)
{
if (responseMessage == null)
{
responseMessage = requestMessage.reply(text).complete();
} else
{
responseMessage = responseMessage.editMessage(text).complete();
}
}
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;
}
}

@ -6,36 +6,51 @@
package moe.nekojimi.chords.commands;
import java.util.List;
import java.util.Optional;
import moe.nekojimi.chords.Main;
import moe.nekojimi.chords.Chords;
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
public class JoinCommand extends Command
{
public JoinCommand(Main bot)
public JoinCommand(Chords bot)
{
super(bot, "join");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> args)
public void call(Invocation invocation)
{
TextChannel textChannel = event.getChannel();
MessageReceivedEvent event = invocation.getEvent();
List<String> args = invocation.getArgs();
MessageChannel textChannel = event.getChannel();
VoiceChannel channel = null;
if (args.isEmpty() || args.get(0).isBlank())
{
GuildVoiceState voiceState = event.getMember().getVoiceState();
if (voiceState != null && voiceState.inVoiceChannel())
channel = voiceState.getChannel();
else
final Member member = event.getMessage().getMember();
if (member != null)
{
GuildVoiceState voiceState = member.getVoiceState();
if (voiceState != null && voiceState.inAudioChannel())
channel = voiceState.getChannel().asVoiceChannel();
}
if (channel == null)
{
Guild guild = event.getMessage().getGuild();
List<VoiceChannel> voiceChannels = guild.getVoiceChannels();
Optional<VoiceChannel> findFirst = voiceChannels.stream().filter((t) -> !t.getMembers().isEmpty()).findFirst();
channel = findFirst.orElseThrow(() -> new RuntimeException("Cannot find any voice channels with people in!"));
System.out.println("Finding channel to join...");
for (VoiceChannel voiceChannel : voiceChannels)
{
List<Member> members = voiceChannel.getMembers();
if (!members.isEmpty())
channel = voiceChannel;
System.out.println("\t" + voiceChannel.getName() + " " + members.size());
}
}
if (channel == null)
throw new RuntimeException("Cannot find any voice channels with people in!");
} else
{
String arg0 = args.get(0);
@ -52,40 +67,20 @@ public class JoinCommand extends Command
if (channel == null) // I have no idea what you want mr user
{
onUnknownChannel(textChannel, arg0); // Let the user know about our failure
return;
// onUnknownChannel(textChannel, arg0); // Let the user know about our failure
throw new RuntimeException("Unable to connect to \"" + channel.getName() + "\", no such channel!");
}
}
bot.connectTo(channel); // We found a channel to connect to!
onConnecting(channel, textChannel); // Let the user know, we were successful!
}
/**
* Inform user about successful connection.
*
* @param channel
* The voice channel we connected to
* @param textChannel
* The text channel to send the message in
*/
public void onConnecting(VoiceChannel channel, TextChannel textChannel)
{
textChannel.sendMessage("Connecting to " + channel.getName()).queue(); // never forget to queue()!
invocation.respond("Connecting to " + channel.getName() + "...");
// onConnecting(channel, textChannel); // Let the user know, we were successful!
}
/**
* The channel to connect to is not known to us.
*
* @param channel
* The message channel (text channel abstraction) to send failure
* information to
* @param comment
* The information of this channel
*/
public void onUnknownChannel(MessageChannel channel, String comment)
@Override
public String argumentDescription()
{
channel.sendMessage("Unable to connect to ``" + comment + "``, no such channel!").queue(); // never forget to queue()!
return "<Channel?>";
}
}

@ -5,20 +5,18 @@
*/
package moe.nekojimi.chords.commands;
import java.util.List;
import moe.nekojimi.chords.Main;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import moe.nekojimi.chords.Chords;
public class LeaveCommand extends Command
{
public LeaveCommand(Main bot)
public LeaveCommand(Chords bot)
{
super(bot, "leave");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> arg)
public void call(Invocation invocation)
{
if (bot.getCurrentVoiceChannel() != null)
{

@ -21,10 +21,10 @@ import java.net.URL;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import moe.nekojimi.chords.Main;
import moe.nekojimi.chords.Chords;
import moe.nekojimi.chords.TrackRequest;
import moe.nekojimi.musicsearcher.Query;
import moe.nekojimi.musicsearcher.Result;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
/**
*
@ -37,18 +37,21 @@ public class PlayCommand extends Command
private List<Result> lastSearchResults;
public PlayCommand(Main main)
public PlayCommand(Chords main)
{
super(main, "play");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> arg)
public void call(Invocation invocation)
{
TrackRequest request = new TrackRequest();
request.setInvocation(invocation);
try
{
final URL url = new URL(arg.get(0));
bot.queueDownload(url, event);
final URL url = new URL(invocation.getArgs().get(0));
request.setUrl(url);
bot.queueDownload(request);
} catch (MalformedURLException mux)
{
@ -57,17 +60,18 @@ public class PlayCommand extends Command
{
try
{
int index = Integer.parseInt(arg.get(0));
int index = Integer.parseInt(invocation.getArgs().get(0));
int size = lastSearchResults.size();
if (index >= 1 && index <= size)
{
Result res = lastSearchResults.get(index - 1);
bot.queueDownload(res, event);
// event.getChannel().sendMessage("Song removed.").queue();
Result result = lastSearchResults.get(index - 1);
request.setResult(result);
bot.queueDownload(request);
// event.getChannel().sendMessage("Track removed.").queue();
} else if (size > 1)
event.getChannel().sendMessage("That's not a number between 1 and " + size + "!").queue();
invocation.respond("That's not a number between 1 and " + size + "!");
else if (size == 1)
event.getChannel().sendMessage("There's only one song and that's not one of them!").queue();
invocation.respond("There's only one track and that's not one of them!");
return;
} catch (NumberFormatException nfx)
@ -77,13 +81,13 @@ public class PlayCommand extends Command
}
// otherwise, try searching
CompletableFuture<List<Result>> search = bot.getSearcher().search(Query.fullText(arg.stream().reduce((t, u) -> t + " " + u).get()));
event.getChannel().sendMessage("Searching for \"" + arg + "\" ...").queue();
CompletableFuture<List<Result>> search = bot.getSearcher().search(Query.fullText(invocation.getArgs().stream().reduce((t, u) -> t + " " + u).get()));
invocation.respond("Searching for \"" + invocation.getArgs() + "\" ...");
search.orTimeout(30, TimeUnit.SECONDS).whenCompleteAsync((List<Result> results, Throwable exec) ->
{
if (exec != null)
{
event.getChannel().sendMessage("Failed to search! Reason: " + exec.getMessage()).queue();
invocation.respond("Failed to search! Reason: " + exec.getMessage());
return;
}
@ -91,13 +95,14 @@ public class PlayCommand extends Command
if (results.isEmpty())
{
event.getChannel().sendMessage("Found nothing! :(").queue();
invocation.respond("Found nothing! :(");
return;
}
if (results.get(0).getScore() >= SEARCH_SCORE_THRESHOLD_AUTOPLAY)
{
bot.queueDownload(results.get(0).getLink(), event);
request.setResult(results.get(0));
bot.queueDownload(request);
return;
}
@ -118,10 +123,15 @@ public class PlayCommand extends Command
i++;
}
resultString += "Type eg. `!play 1` to select";
event.getChannel().sendMessage(resultString).queue();
invocation.respond(resultString);
});
// event.getChannel().sendMessage("That's not a valid URL you idiot! " + ex.getMessage()).queue();
}
}
@Override
public String argumentDescription()
{
return "<URL / Search>";
}
}

@ -18,57 +18,69 @@ package moe.nekojimi.chords.commands;
import java.util.List;
import java.util.Queue;
import moe.nekojimi.chords.Downloader;
import moe.nekojimi.chords.Main;
import moe.nekojimi.chords.Song;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import moe.nekojimi.chords.Chords;
import moe.nekojimi.chords.Track;
import moe.nekojimi.chords.TrackRequest;
public class QueueCommand extends Command
{
public QueueCommand(Main bot)
public QueueCommand(Chords bot)
{
super(bot, "queue");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> arg)
public void call(Invocation invocation)
{
String message = ">>> ";
int i = 1;
final Song currentSong = bot.getMusicHandler().getCurrentSong();
if (currentSong != null)
message += ":notes: **Now playing: " + currentSong + "**\n";
Track currentTrack = null;
if (bot.getMusicHandler() != null)
currentTrack = bot.getMusicHandler().getCurrentTrack();
if (currentTrack != null)
message += ":notes: **Now playing: " + currentTrack + "**\n";
else
message += ":mute: **Not playing anything right now.**\n";
final Queue<Song> songQueue = bot.getMusicHandler().getSongQueue();
if (!songQueue.isEmpty())
final Queue<Track> trackQueue = bot.getQueueManager().getJukeboxQueue();
if (!trackQueue.isEmpty())
{
message += "__Ready to play:__\n";
for (Song song : songQueue)
for (Track track : trackQueue)
{
message += ":bread: **" + (i) + ":** " + song + "\n";
message += ":bread: **" + i + ":** " + track + "\n";
i++;
}
}
final List<Downloader.DownloadTask> downloadQueue = bot.getDownloader().getDownloadQueue();
if (!downloadQueue.isEmpty())
final List<TrackRequest> downloading = bot.getDownloader().getDownloadingTracks();
if (!downloading.isEmpty())
{
message += "__Downloading:__\n";
for (Downloader.DownloadTask task : downloadQueue)
for (TrackRequest request : downloading)
{
message += ":satellite: **" + (i) + ":** " + request + "\n";
i++;
}
}
final List<TrackRequest> downloadQueue = bot.getDownloader().getDownloadQueue();
if (!downloadQueue.isEmpty())
{
message += "__In queue for download:__\n";
for (TrackRequest request : downloadQueue)
{
message += ":inbox_tray: **" + (i) + ":** " + task.getSong() + "\n";
message += ":inbox_tray: **" + (i) + ":** " + request + "\n";
i++;
}
}
if (downloadQueue.isEmpty() && songQueue.isEmpty())
if (downloading.isEmpty() && trackQueue.isEmpty() && downloadQueue.isEmpty())
message += ":mailbox_with_no_mail: The track queue is empty.";
// :inbox_tray:
event.getChannel().sendMessage(message).queue();
invocation.respond(message);
}
}

@ -16,36 +16,34 @@
*/
package moe.nekojimi.chords.commands;
import java.util.List;
import moe.nekojimi.chords.Main;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import moe.nekojimi.chords.Chords;
public class RemoveCommand extends Command
{
public RemoveCommand(Main bot)
public RemoveCommand(Chords bot)
{
super(bot, "remove");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> arg)
public void call(Invocation invocation)
{
try
{
int i = Integer.parseInt(arg.get(0));
boolean removed = bot.getMusicHandler().removeSong(i - 1);
final int size = bot.getMusicHandler().getSongQueue().size();
int i = Integer.parseInt(invocation.getArgs().get(0));
boolean removed = bot.getQueueManager().removeTrack(i - 1);
final int size = bot.getQueueManager().getJukeboxQueue().size();
if (removed)
event.getChannel().sendMessage("Song removed.").queue();
invocation.respond("Track removed.");
else if (size > 1)
event.getChannel().sendMessage("That's not a number between 1 and " + size + "!").queue();
invocation.respond("That's not a number between 1 and " + size + "!");
else if (size == 1)
event.getChannel().sendMessage("There's only one song to remove and that's not one of them!").queue();
invocation.respond("There's only one track to remove and that's not one of them!");
} catch (NumberFormatException ex)
{
event.getChannel().sendMessage(arg + " isn't a number!").queue();
invocation.respond(invocation.getArgs().get(0) + " isn't a number!");
}
}

@ -16,27 +16,25 @@
*/
package moe.nekojimi.chords.commands;
import java.util.List;
import moe.nekojimi.chords.Main;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import moe.nekojimi.chords.Chords;
public class RestartCommand extends Command
{
public RestartCommand(Main bot)
public RestartCommand(Chords bot)
{
super(bot, "restart");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> arg)
public void call(Invocation invocation)
{
// TODO: this needs to clear the current data queue
boolean ok = bot.getMusicHandler().restartSong();
boolean ok = bot.getQueueManager().restartTrack();
if (ok)
event.getChannel().sendMessage("Restarted current song!").queue();
invocation.respond("Restarted current track!");
else
event.getChannel().sendMessage("Cannot restart!").queue();
invocation.respond("Cannot restart!");
}
}

@ -16,26 +16,24 @@
*/
package moe.nekojimi.chords.commands;
import java.util.List;
import moe.nekojimi.chords.Main;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import moe.nekojimi.chords.Chords;
public class SkipCommand extends Command
{
public SkipCommand(Main bot)
public SkipCommand(Chords bot)
{
super(bot, "skip");
}
@Override
public void call(GuildMessageReceivedEvent event, List<String> arg)
public void call(Invocation invocation)
{
boolean ok = bot.getMusicHandler().nextSong(true);
boolean ok = bot.getMusicHandler().skipTrack();
if (ok)
event.getChannel().sendMessage("Skipped to next song!").queue();
invocation.respond("Skipped to next track!");
else
event.getChannel().sendMessage("There's no more songs!").queue();
invocation.respond("There's no more tracks!");
}
}

Loading…
Cancel
Save