From 22688dd404f1e79c663267a639f3a8918bcfeebd Mon Sep 17 00:00:00 2001 From: Julius de Jeu Date: Sun, 2 Jun 2019 20:13:33 +0200 Subject: [PATCH] Finish music commands. Add track announcer. Add weather command because why not. --- src/main/kotlin/nl/voidcorp/discord/Loader.kt | 17 +- .../nl/voidcorp/discord/command/Command.kt | 11 +- .../discord/commands/fun/WeatherCommand.kt | 27 ++ .../management/AddBotChannelCommand.kt | 43 ++ .../management/AddMusicChannelCommand.kt | 43 ++ .../management/RemoveBotChannelCommand.kt | 38 ++ .../management/RemoveMusicChannelCommand.kt | 38 ++ .../voidcorp/discord/commands/music/Loop.kt | 19 + .../discord/commands/music/PlaylistCommand.kt | 21 + .../music/CustomYoutubeSourceManager.kt | 428 ++++++++++++++++++ .../voidcorp/discord/music/MusicAnnouncer.kt | 19 +- .../discord/music/MusicAnnouncerImpl.kt | 15 + .../discord/music/NullMusicAnnouncer.kt | 5 + .../voidcorp/discord/music/PlayerManager.kt | 9 +- .../voidcorp/discord/music/TrackScheduler.kt | 32 +- 15 files changed, 741 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/nl/voidcorp/discord/commands/fun/WeatherCommand.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/commands/management/AddBotChannelCommand.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/commands/management/AddMusicChannelCommand.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveBotChannelCommand.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveMusicChannelCommand.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/commands/music/Loop.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/commands/music/PlaylistCommand.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/music/CustomYoutubeSourceManager.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncerImpl.kt create mode 100644 src/main/kotlin/nl/voidcorp/discord/music/NullMusicAnnouncer.kt diff --git a/src/main/kotlin/nl/voidcorp/discord/Loader.kt b/src/main/kotlin/nl/voidcorp/discord/Loader.kt index 1692f1c..74900e5 100644 --- a/src/main/kotlin/nl/voidcorp/discord/Loader.kt +++ b/src/main/kotlin/nl/voidcorp/discord/Loader.kt @@ -1,10 +1,17 @@ package nl.voidcorp.discord -import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers +import com.sedmelluq.discord.lavaplayer.source.bandcamp.BandcampAudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.beam.BeamAudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.twitch.TwitchStreamAudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.vimeo.VimeoAudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager import net.dv8tion.jda.api.entities.Activity import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder import nl.voidcorp.discord.events.CommandListener import nl.voidcorp.discord.events.OttoListener +import nl.voidcorp.discord.music.CustomYoutubeSourceManager import nl.voidcorp.discord.music.PlayerManager import nl.voidcorp.discord.storage.ConfigStore import org.springframework.stereotype.Service @@ -23,6 +30,12 @@ class Loader(listener: CommandListener, playerManager: PlayerManager, store: Con jda.setActivityProvider { Activity.playing("v${store.version} ($it)") } - AudioSourceManagers.registerRemoteSources(playerManager) + + playerManager.registerSourceManager(SoundCloudAudioSourceManager()) + playerManager.registerSourceManager(BandcampAudioSourceManager()) + playerManager.registerSourceManager(VimeoAudioSourceManager()) + playerManager.registerSourceManager(TwitchStreamAudioSourceManager()) + playerManager.registerSourceManager(BeamAudioSourceManager()) + playerManager.registerSourceManager(CustomYoutubeSourceManager()) } } \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/command/Command.kt b/src/main/kotlin/nl/voidcorp/discord/command/Command.kt index e67c822..f8531c6 100644 --- a/src/main/kotlin/nl/voidcorp/discord/command/Command.kt +++ b/src/main/kotlin/nl/voidcorp/discord/command/Command.kt @@ -27,12 +27,11 @@ abstract class Command( fun onCommand(event: MessageReceivedEvent, prefix: String): CommandResult { val starts = - event.message.contentRaw.drop(prefix.length).trim() - .startsWith(name) or aliases.any { - event.message.contentRaw.drop(prefix.length).trim().startsWith( - it - ) - } + (event.message.contentRaw.drop(prefix.length).trim().split("\\s".toRegex()) + .first() == name) or (aliases.any { + event.message.contentRaw.drop(prefix.length).trim().split("\\s".toRegex()) + .first() == it + }) return if (!starts) CommandResult.NOPE else when (location) { CommandSource.PRIVATE -> if (event.channelType == ChannelType.PRIVATE) guildStuff( event, diff --git a/src/main/kotlin/nl/voidcorp/discord/commands/fun/WeatherCommand.kt b/src/main/kotlin/nl/voidcorp/discord/commands/fun/WeatherCommand.kt new file mode 100644 index 0000000..af3f7a3 --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/commands/fun/WeatherCommand.kt @@ -0,0 +1,27 @@ +package nl.voidcorp.discord.commands.`fun` + +import nl.voidcorp.discord.command.Command +import nl.voidcorp.discord.command.CommandGroup +import nl.voidcorp.discord.command.CommandMessage +import nl.voidcorp.discord.command.CommandResult +import org.springframework.stereotype.Service +import java.net.URI +import java.net.URLEncoder + +@Service +class WeatherCommand : Command("weather", aliases = listOf("rain"), group = CommandGroup.FUN) { + override fun handle(event: CommandMessage): CommandResult { + val location = + if (event.params.drop(1).isEmpty()) { + "delft" + } else { + event.params.drop(1).joinToString(" ").replace("<@501009066479452170>", "woerden") + } + + val url = URI("http://wttr.in/${URLEncoder.encode(location, "utf-8")}_Fpm.png").toURL().openStream() + event.message.channel.sendFile(url, "$location.png").content("Weather in ${location.replace("+", " ")}").queue() + + return CommandResult.SUCCESS + } + +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/commands/management/AddBotChannelCommand.kt b/src/main/kotlin/nl/voidcorp/discord/commands/management/AddBotChannelCommand.kt new file mode 100644 index 0000000..2e467df --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/commands/management/AddBotChannelCommand.kt @@ -0,0 +1,43 @@ +package nl.voidcorp.discord.commands.management + +import nl.voidcorp.discord.command.* +import nl.voidcorp.discord.storage.GuildStore +import org.springframework.stereotype.Service + +@Service +class AddBotChannelCommand : Command( + "addbotchannel", + aliases = listOf("abc"), + usage = "addbotchannel #channel (or just the id)", + commandLevel = CommandLevel.MODERATOR, + group = CommandGroup.ADMIN, + location = CommandSource.GUILD +) { + val regex = "(?:<#?)?(\\d+)>?".toRegex() + + override fun handle(event: CommandMessage): CommandResult { + val guild = repo.findByGuildId(event.guild!!.idLong) ?: GuildStore(event.guild.idLong) + if (event.params.drop(1).isEmpty()) { + val roles = guild.botChannels.map { event.guild.getTextChannelById(it)?.id ?: "Missing channel $it" } + .joinToString(prefix = "Bot channels: ") { "<#$it>" } + event.reply(roles) + return CommandResult.SUCCESS + } + val l = mutableListOf() + for (p in event.params.drop(1)) { + val res = regex.matchEntire(p) + if (res != null && res.groupValues.size == 2) { + if (event.guild.getTextChannelById(res.groupValues[1]) != null) { + guild.botChannels.plusAssign(res.groupValues[1].toLong()) + val channel = event.guild.getTextChannelById(res.groupValues[1])!! + l += channel.id + } else event.reply("There is no channel with id `${res.groupValues[1]}`") + } + } + repo.save(guild) + if (l.isNotEmpty()) + event.reply(l.joinToString(prefix = "Added the following bot channels: ") { "<#$it>" }) + + return CommandResult.SUCCESS + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/commands/management/AddMusicChannelCommand.kt b/src/main/kotlin/nl/voidcorp/discord/commands/management/AddMusicChannelCommand.kt new file mode 100644 index 0000000..528642a --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/commands/management/AddMusicChannelCommand.kt @@ -0,0 +1,43 @@ +package nl.voidcorp.discord.commands.management + +import nl.voidcorp.discord.command.* +import nl.voidcorp.discord.storage.GuildStore +import org.springframework.stereotype.Service + +@Service +class AddMusicChannelCommand : Command( + "addmusicchannel", + aliases = listOf("amc"), + usage = "addmusicchannel #channel (or just the id)", + commandLevel = CommandLevel.MODERATOR, + group = CommandGroup.ADMIN, + location = CommandSource.GUILD +) { + val regex = "(?:<#?)?(\\d+)>?".toRegex() + + override fun handle(event: CommandMessage): CommandResult { + val guild = repo.findByGuildId(event.guild!!.idLong) ?: GuildStore(event.guild.idLong) + if (event.params.drop(1).isEmpty()) { + val roles = guild.musicChannels.map { event.guild.getTextChannelById(it)?.id ?: "Missing channel $it" } + .joinToString(prefix = "Music channels: ") { "<#$it>" } + event.reply(roles) + return CommandResult.SUCCESS + } + val l = mutableListOf() + for (p in event.params.drop(1)) { + val res = regex.matchEntire(p) + if (res != null && res.groupValues.size == 2) { + if (event.guild.getTextChannelById(res.groupValues[1]) != null) { + guild.musicChannels.plusAssign(res.groupValues[1].toLong()) + val channel = event.guild.getTextChannelById(res.groupValues[1])!! + l += channel.id + } else event.reply("There is no channel with id `${res.groupValues[1]}`") + } + } + repo.save(guild) + if (l.isNotEmpty()) + event.reply(l.joinToString(prefix = "Added the following music channels: ") { "<#$it>" }) + + return CommandResult.SUCCESS + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveBotChannelCommand.kt b/src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveBotChannelCommand.kt new file mode 100644 index 0000000..723d0e8 --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveBotChannelCommand.kt @@ -0,0 +1,38 @@ +package nl.voidcorp.discord.commands.management + +import nl.voidcorp.discord.command.* +import nl.voidcorp.discord.storage.GuildStore +import org.springframework.stereotype.Service + +@Service +class RemoveBotChannelCommand : Command( + "removebotchannel", + commandLevel = CommandLevel.ADMIN, + location = CommandSource.GUILD, + group = CommandGroup.ADMIN, + aliases = listOf( "rbc") + +) { + + val regex = "(?:<#?)?(\\d+)>?".toRegex() + override fun handle(event: CommandMessage): CommandResult { + if (event.params.drop(1).isEmpty()) return CommandResult.PARAMETERS + val guild = repo.findByGuildId(event.guild!!.idLong) ?: GuildStore(event.guild.idLong) + val l = mutableListOf() + for (p in event.params.drop(1)) { + val res = regex.matchEntire(p) + if (res != null && res.groupValues.size == 2) { + if (guild.botChannels.contains(res.groupValues[1].toLong())) { + guild.botChannels.minusAssign(res.groupValues[1].toLong()) + val role = event.guild.getTextChannelById(res.groupValues[1])?.id ?: "some channel?" + l += role + } else event.reply("There is no role with id `${res.groupValues[1]}`") + } + } + repo.save(guild) + if (l.isNotEmpty()) + event.reply(l.joinToString(prefix = "Removed the following channels as bot channels: ") { "<#$it>" }) + + return CommandResult.SUCCESS + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveMusicChannelCommand.kt b/src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveMusicChannelCommand.kt new file mode 100644 index 0000000..a368e88 --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/commands/management/RemoveMusicChannelCommand.kt @@ -0,0 +1,38 @@ +package nl.voidcorp.discord.commands.management + +import nl.voidcorp.discord.command.* +import nl.voidcorp.discord.storage.GuildStore +import org.springframework.stereotype.Service + +@Service +class RemoveMusicChannelCommand : Command( + "removemusicchannel", + commandLevel = CommandLevel.ADMIN, + location = CommandSource.GUILD, + group = CommandGroup.ADMIN, + aliases = listOf( "rmc") + +) { + + val regex = "(?:<#?)?(\\d+)>?".toRegex() + override fun handle(event: CommandMessage): CommandResult { + if (event.params.drop(1).isEmpty()) return CommandResult.PARAMETERS + val guild = repo.findByGuildId(event.guild!!.idLong) ?: GuildStore(event.guild.idLong) + val l = mutableListOf() + for (p in event.params.drop(1)) { + val res = regex.matchEntire(p) + if (res != null && res.groupValues.size == 2) { + if (guild.musicChannels.contains(res.groupValues[1].toLong())) { + guild.musicChannels.minusAssign(res.groupValues[1].toLong()) + val role = event.guild.getTextChannelById(res.groupValues[1])?.id ?: "some channel?" + l += role + } else event.reply("There is no role with id `${res.groupValues[1]}`") + } + } + repo.save(guild) + if (l.isNotEmpty()) + event.reply(l.joinToString(prefix = "Removed the following channels as music channels: ") { "<#$it>" }) + + return CommandResult.SUCCESS + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/commands/music/Loop.kt b/src/main/kotlin/nl/voidcorp/discord/commands/music/Loop.kt new file mode 100644 index 0000000..87d19b8 --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/commands/music/Loop.kt @@ -0,0 +1,19 @@ +package nl.voidcorp.discord.commands.music + +import nl.voidcorp.discord.command.* +import nl.voidcorp.discord.music.PlayerManager +import org.springframework.stereotype.Service + +@Service +class Loop(val playerManager: PlayerManager) : + Command("loop", location = CommandSource.GUILD, group = CommandGroup.MUSIC) { + override fun handle(event: CommandMessage): CommandResult { + val loop = playerManager.getGuildPlayer(event.guild!!).loop() + if (loop){ + event.reply("Now looping!") + }else{ + event.reply("No longer looping...") + } + return CommandResult.SUCCESS + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/commands/music/PlaylistCommand.kt b/src/main/kotlin/nl/voidcorp/discord/commands/music/PlaylistCommand.kt new file mode 100644 index 0000000..99b6d2c --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/commands/music/PlaylistCommand.kt @@ -0,0 +1,21 @@ +package nl.voidcorp.discord.commands.music + +import nl.voidcorp.discord.command.* +import nl.voidcorp.discord.music.PlayerManager +import org.springframework.stereotype.Service + +@Service +class PlaylistCommand(val playerManager: PlayerManager) : + Command("playlist", location = CommandSource.GUILD, group = CommandGroup.MUSIC) { + override fun handle(event: CommandMessage): CommandResult { + val player = playerManager.getGuildPlayer(event.guild!!) + val list = player.totalList.take(10) + .mapIndexed { index, audioTrack -> "${index + 1} - ${audioTrack.info.title}" } + .joinToString("\n") + if (list.isNotBlank()) + event.reply(list) + else + event.reply("The playlist is still empty...") + return CommandResult.SUCCESS + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/music/CustomYoutubeSourceManager.kt b/src/main/kotlin/nl/voidcorp/discord/music/CustomYoutubeSourceManager.kt new file mode 100644 index 0000000..661c2c4 --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/music/CustomYoutubeSourceManager.kt @@ -0,0 +1,428 @@ +package nl.voidcorp.discord.music + +import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager +import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager +import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioTrack +import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeMixProvider +import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeSearchProvider +import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools +import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools.convertToMapLayout +import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException +import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser +import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools +import com.sedmelluq.discord.lavaplayer.tools.io.HttpConfigurable +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager +import com.sedmelluq.discord.lavaplayer.track.* +import org.apache.commons.io.IOUtils +import org.apache.http.client.config.RequestConfig +import org.apache.http.client.methods.HttpGet +import org.apache.http.client.utils.URIBuilder +import org.apache.http.client.utils.URLEncodedUtils +import org.apache.http.impl.client.HttpClientBuilder +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.slf4j.LoggerFactory +import java.io.DataInput +import java.io.DataOutput +import java.io.IOException +import java.net.URISyntaxException +import java.net.URLEncoder +import java.nio.charset.Charset +import java.util.* +import java.util.function.Consumer +import java.util.regex.Pattern + +/** + * Audio source manager that implements finding Youtube videos or playlists based on an URL or ID. + */ +class CustomYoutubeSourceManager : YoutubeAudioSourceManager(), AudioSourceManager, HttpConfigurable { + + private val extractors = arrayOf( + Extractor(directVideoIdPattern) { id -> loadTrackWithVideoId(id, false) }, + Extractor( + Pattern.compile("^$PLAYLIST_ID_REGEX$") + ) { id -> loadPlaylistWithId(id, null) }, + Extractor( + Pattern.compile("^$PROTOCOL_REGEX$DOMAIN_REGEX/.*") + ) { + println(it) + this.loadFromMainDomain(it) }, + Extractor( + Pattern.compile("^$PROTOCOL_REGEX$SHORT_DOMAIN_REGEX/.*") + ) { this.loadFromShortDomain(it) } + ) + + private val httpInterfaceManager: HttpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager() + private val searchProvider: YoutubeSearchProvider = YoutubeSearchProvider(this) + private val mixProvider: YoutubeMixProvider = YoutubeMixProvider(this) + + + override fun getSourceName(): String { + return "youtube" + } + + override fun loadItem(manager: DefaultAudioPlayerManager, reference: AudioReference): AudioItem? { + return try { + loadItemOnce(reference) + } catch (exception: FriendlyException) { + // In case of a connection reset exception, try once more. + if (HttpClientTools.isRetriableNetworkException(exception.cause)) { + loadItemOnce(reference) + } else { + throw exception + } + } + + } + + private fun loadFromShortDomain(identifier: String): AudioItem { + val urlInfo = getUrlInfo(identifier, true) + return loadFromUrlWithVideoId(urlInfo.path.substring(1), urlInfo) + } + + private fun loadFromMainDomain(identifier: String): AudioItem? { + val urlInfo = getUrlInfo(identifier, true) + println(urlInfo.parameters) + if ("/watch" == urlInfo.path) { + val videoId = urlInfo.parameters["v"] + + if (videoId != null) { + return loadFromUrlWithVideoId(videoId, urlInfo) + } + } else if ("/playlist" == urlInfo.path) { + val playlistId = urlInfo.parameters["list"] + + if (playlistId != null) { + return loadPlaylistWithId(playlistId, null) + } + } else if ("/watch_videos" == urlInfo.path) { + val videoIds = urlInfo.parameters["video_ids"] + if (videoIds != null) { + return loadAnonymous(videoIds) + } + } + + return null + } + + override fun isTrackEncodable(track: AudioTrack): Boolean { + return true + } + + override fun encodeTrack(track: AudioTrack, output: DataOutput) { + // No custom values that need saving + } + + override fun decodeTrack(trackInfo: AudioTrackInfo, input: DataInput): AudioTrack { + return YoutubeAudioTrack(trackInfo, this) + } + + override fun shutdown() { + ExceptionTools.closeWithWarnings(httpInterfaceManager) + + mixProvider.shutdown() + } + + override fun configureRequests(configurator: java.util.function.Function) { + httpInterfaceManager.configureRequests(configurator) + searchProvider.configureRequests(configurator) + } + + override fun configureBuilder(configurator: Consumer) { + httpInterfaceManager.configureBuilder(configurator) + searchProvider.configureBuilder(configurator) + } + + private fun loadItemOnce(reference: AudioReference): AudioItem? { + return loadNonSearch(reference.identifier) ?: searchProvider.loadSearchResult( + reference.identifier.trim()) + + } + + + private fun loadAnonymous(videoIds: String): AudioItem? { + try { + httpInterface.use { httpInterface -> + httpInterface.execute(HttpGet("https://www.youtube.com/watch_videos?video_ids=$videoIds")) + .use { response -> + val statusCode = response.statusLine.statusCode + val context = httpInterface.context + if (statusCode != 200) { + throw IOException("Invalid status code for playlist response: $statusCode") + } + // youtube currently transforms watch_video links into a link with a video id and a list id. + // because thats what happens, we can simply re-process with the redirected link + val redirects = context.redirectLocations + return if (redirects != null && !redirects.isEmpty()) { + loadNonSearch(redirects[0].toString()) + } else { + throw FriendlyException( + "Unable to process youtube watch_videos link", FriendlyException.Severity.SUSPICIOUS, + IllegalStateException("Expected youtube to redirect watch_videos link to a watch?v={id}&list={list_id} link, but it did not redirect at all") + ) + } + } + } + } catch (e: Exception) { + throw ExceptionTools.wrapUnfriendlyExceptions(e) + } + + } + + + private fun loadFromUrlWithVideoId(videoId: String, urlInfo: UrlInfo): AudioItem { + var videoId = videoId + if (videoId.length > 11) { + // YouTube allows extra junk in the end, it redirects to the correct video. + videoId = videoId.substring(0, 11) + } + + if (!directVideoIdPattern.matcher(videoId).matches()) { + return AudioReference.NO_TRACK + } else if (urlInfo.parameters.containsKey("list")) { + val playlistId = urlInfo.parameters["list"] + + return if (playlistId!!.startsWith("RD")) { + mixProvider.loadMixWithId(playlistId, videoId) + } else { + loadLinkedPlaylistWithId(urlInfo.parameters.getValue("list"), videoId) + } + } else { + return loadTrackWithVideoId(videoId, false) + } + } + + private fun loadNonSearch(identifier: String): AudioItem? { + println(identifier) + for (extractor in extractors) { + if (extractor.pattern.matcher(identifier).matches()) { + val item = extractor.loader(identifier) + + if (item != null) { + return item + } + } + } + + return null + } + + private fun loadLinkedPlaylistWithId(playlistId: String, videoId: String): AudioItem { + val playlist = loadPlaylistWithId(playlistId, videoId) + + return playlist ?: loadTrackWithVideoId(videoId, false) + } + + private fun determineFailureReasonFromStatus(status: String?, reason: String?, mustExist: Boolean): Boolean { + if ("fail" == status) { + if (("This video does not exist." == reason || "This video is unavailable." == reason) && !mustExist) { + return true + } else if (reason != null) { + throw FriendlyException(reason, FriendlyException.Severity.COMMON, null) + } + } else if ("ok" == status) { + return false + } + + throw FriendlyException( + "Track is unavailable for an unknown reason.", FriendlyException.Severity.SUSPICIOUS, + IllegalStateException("Main page had no video, but video info has no error.") + ) + } + + @Throws(IOException::class) + private fun getTrackInfoFromEmbedPage(httpInterface: HttpInterface, videoId: String): JsonBrowser { + val basicInfo = loadTrackBaseInfoFromEmbedPage(httpInterface, videoId) + basicInfo.put("args", loadTrackArgsFromVideoInfoPage(httpInterface, videoId, basicInfo.get("sts").text())) + return basicInfo + } + + @Throws(IOException::class) + private fun loadTrackBaseInfoFromEmbedPage(httpInterface: HttpInterface, videoId: String): JsonBrowser { + httpInterface.execute(HttpGet("https://www.youtube.com/embed/$videoId")).use { response -> + val statusCode = response.statusLine.statusCode + if (statusCode != 200) { + throw IOException("Invalid status code for embed video page response: $statusCode") + } + + val html = IOUtils.toString(response.entity.content, Charset.forName(CHARSET)) + val configJson = DataFormatTools.extractBetween(html, "'PLAYER_CONFIG': ", "});writeEmbed();") + + if (configJson != null) { + return JsonBrowser.parse(configJson) + } + } + + throw FriendlyException( + "Track information is unavailable.", FriendlyException.Severity.SUSPICIOUS, + IllegalStateException("Expected player config is not present in embed page.") + ) + } + + @Throws(IOException::class) + private fun loadTrackArgsFromVideoInfoPage( + httpInterface: HttpInterface, + videoId: String, + sts: String + ): Map { + val videoApiUrl = "https://youtube.googleapis.com/v/$videoId" + val encodedApiUrl = URLEncoder.encode(videoApiUrl, CHARSET) + val url = + "https://www.youtube.com/get_video_info?sts=" + sts + "&video_id=" + videoId + "&eurl=" + encodedApiUrl + + "hl=en_GB" + + httpInterface.execute(HttpGet(url)).use { response -> + val statusCode = response.statusLine.statusCode + if (statusCode != 200) { + throw IOException("Invalid status code for video info response: $statusCode") + } + + return convertToMapLayout(URLEncodedUtils.parse(response.entity)) + } + } + + private fun loadPlaylistWithId(playlistId: String, selectedVideoId: String?): AudioPlaylist? { + log.debug("Starting to load playlist with ID {}", playlistId) + + try { + httpInterface.use { httpInterface -> + httpInterface.execute(HttpGet("https://www.youtube.com/playlist?list=$playlistId")).use { response -> + val statusCode = response.statusLine.statusCode + if (statusCode != 200) { + throw IOException("Invalid status code for playlist response: $statusCode") + } + + val document = Jsoup.parse(response.entity.content, CHARSET, "") + return buildPlaylist(httpInterface, document, selectedVideoId) + } + } + } catch (e: Exception) { + throw ExceptionTools.wrapUnfriendlyExceptions(e) + } + + } + + @Throws(IOException::class) + private fun buildPlaylist( + httpInterface: HttpInterface, + document: Document, + selectedVideoId: String? + ): AudioPlaylist? { + val isAccessible = !document.select("#pl-header").isEmpty() + + if (!isAccessible) { + return if (selectedVideoId != null) { + null + } else { + throw FriendlyException("The playlist is private.", FriendlyException.Severity.COMMON, null) + } + } + + val container = document.select("#pl-header").first().parent() + + val playlistName = container.select(".pl-header-title").first().text() + + val tracks = ArrayList() + var loadMoreUrl = extractPlaylistTracks(container, container, tracks) + var loadCount = 0 + val pageCount = 6 + + // Also load the next pages, each result gives us a JSON with separate values for list html and next page loader html + while (loadMoreUrl != null && ++loadCount < pageCount) { + httpInterface.execute(HttpGet("https://www.youtube.com$loadMoreUrl")).use { response -> + val statusCode = response.statusLine.statusCode + if (statusCode != 200) { + throw IOException("Invalid status code for playlist response: $statusCode") + } + + val json = JsonBrowser.parse(response.entity.content) + + val html = json.get("content_html").text() + val videoContainer = Jsoup.parse("$html
", "") + + val moreHtml = json.get("load_more_widget_html").text() + val moreContainer = if (moreHtml != null) Jsoup.parse(moreHtml) else null + + loadMoreUrl = extractPlaylistTracks(videoContainer, moreContainer, tracks) + } + } + + return BasicAudioPlaylist(playlistName, tracks, findSelectedTrack(tracks, selectedVideoId), false) + } + + private fun extractPlaylistTracks( + videoContainer: Element, + loadMoreContainer: Element?, + tracks: MutableList + ): String? { + for (video in videoContainer.select(".pl-video")) { + val lengthElements = video.select(".timestamp span") + + // If the timestamp element does not exist, it means the video is private + if (!lengthElements.isEmpty()) { + val videoId = video.attr("data-video-id").trim { it <= ' ' } + val title = video.attr("data-title").trim { it <= ' ' } + val author = video.select(".pl-video-owner a").text().trim { it <= ' ' } + val duration = DataFormatTools.durationTextToMillis(lengthElements.first().text()) + + tracks.add(buildTrackObject(videoId, title, author, false, duration)) + } + } + + if (loadMoreContainer != null) { + val more = loadMoreContainer.select(".load-more-button") + if (!more.isEmpty()) { + return more.first().attr("data-uix-load-more-href") + } + } + + return null + } + + + private class UrlInfo constructor(val path: String, val parameters: Map) + + private class Extractor constructor( + val pattern: Pattern, + val loader: (String) -> AudioItem? + ) + + companion object { + private val log = LoggerFactory.getLogger(YoutubeAudioSourceManager::class.java) + internal val CHARSET = "UTF-8" + + private const val PROTOCOL_REGEX = "(?:http://|https://|)" + private const val DOMAIN_REGEX = "(?:www\\.|m\\.|music\\.|)youtube\\.com" + private const val SHORT_DOMAIN_REGEX = "(?:www\\.|)youtu\\.be" + private const val VIDEO_ID_REGEX = "(?[a-zA-Z0-9_-]{11})" + private const val PLAYLIST_ID_REGEX = "(?(PL|LL|FL|UU)[a-zA-Z0-9_-]+)" + + private val directVideoIdPattern = Pattern.compile("^$VIDEO_ID_REGEX$") + + private fun getUrlInfo(url: String, retryValidPart: Boolean): UrlInfo { + var url = url + try { + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://$url" + } + + val builder = URIBuilder(url) + + return UrlInfo(builder.path, builder.queryParams.map { it.name to it.value }.toMap()) + + } catch (e: URISyntaxException) { + return if (retryValidPart) { + getUrlInfo(url.substring(0, e.index - 1), false) + } else { + throw FriendlyException("Not a valid URL: $url", FriendlyException.Severity.COMMON, e) + } + } + + } + + } +} diff --git a/src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncer.kt b/src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncer.kt index f732da5..45f0425 100644 --- a/src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncer.kt +++ b/src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncer.kt @@ -1,12 +1,23 @@ package nl.voidcorp.discord.music import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import net.dv8tion.jda.api.entities.Guild import net.dv8tion.jda.api.entities.TextChannel -import nl.voidcorp.discord.logger +import org.apache.logging.log4j.LogManager -class MusicAnnouncer(val channel: TextChannel) { +abstract class MusicAnnouncer(open val channel: TextChannel?, private val guild: Guild) { - fun sendPlayTrack(track: AudioTrack) { - logger.info(track.info.uri) + private val logger = LogManager.getLogger("Music - ${guild.name}") + + fun close() { + guild.audioManager.closeAudioConnection() + } + + open fun sendPlayTrack(track: AudioTrack) { + logger.info("Playing ${track.info.title} (${track.identifier})") + } + + open fun sendQueueTrack(track: AudioTrack) { + logger.info("Queued ${track.info.title}") } } \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncerImpl.kt b/src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncerImpl.kt new file mode 100644 index 0000000..f6eae9b --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/music/MusicAnnouncerImpl.kt @@ -0,0 +1,15 @@ +package nl.voidcorp.discord.music + +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import net.dv8tion.jda.api.entities.Guild +import net.dv8tion.jda.api.entities.TextChannel + +class MusicAnnouncerImpl(override val channel: TextChannel, guild: Guild) : MusicAnnouncer(channel, guild) { + override fun sendPlayTrack(track: AudioTrack) { + channel.sendMessage("Now playing ${track.info.title}").queue() + } + + override fun sendQueueTrack(track: AudioTrack) { + channel.sendMessage("Queue'd ${track.info.title}!").queue() + } +} \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/music/NullMusicAnnouncer.kt b/src/main/kotlin/nl/voidcorp/discord/music/NullMusicAnnouncer.kt new file mode 100644 index 0000000..22435e1 --- /dev/null +++ b/src/main/kotlin/nl/voidcorp/discord/music/NullMusicAnnouncer.kt @@ -0,0 +1,5 @@ +package nl.voidcorp.discord.music + +import net.dv8tion.jda.api.entities.Guild + +class NullMusicAnnouncer(guild: Guild) : MusicAnnouncer(null, guild) \ No newline at end of file diff --git a/src/main/kotlin/nl/voidcorp/discord/music/PlayerManager.kt b/src/main/kotlin/nl/voidcorp/discord/music/PlayerManager.kt index 2608cbb..2c3c3e9 100644 --- a/src/main/kotlin/nl/voidcorp/discord/music/PlayerManager.kt +++ b/src/main/kotlin/nl/voidcorp/discord/music/PlayerManager.kt @@ -25,10 +25,15 @@ class PlayerManager(val repo: GuildRepo) : DefaultAudioPlayerManager() { }?.idLong ?: guild.textChannels.firstOrNull { it.name.contains("music", true) }?.idLong ?: guild.textChannels.firstOrNull { it.name.contains("bot", true) }?.idLong - ?: guild.defaultChannel!!.idLong player.volume = 50 - val ts = TrackScheduler(player, MusicAnnouncer(guild.getTextChannelById(channel)!!)) { + val ts = TrackScheduler( + player, + if (channel != null) MusicAnnouncerImpl( + guild.getTextChannelById(channel)!!, + guild + ) else NullMusicAnnouncer(guild) + ) { delGuildPlayer(guild) } diff --git a/src/main/kotlin/nl/voidcorp/discord/music/TrackScheduler.kt b/src/main/kotlin/nl/voidcorp/discord/music/TrackScheduler.kt index 6453ea0..7fc829c 100644 --- a/src/main/kotlin/nl/voidcorp/discord/music/TrackScheduler.kt +++ b/src/main/kotlin/nl/voidcorp/discord/music/TrackScheduler.kt @@ -4,28 +4,38 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayer import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter import com.sedmelluq.discord.lavaplayer.track.AudioTrack import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason -import nl.voidcorp.discord.logger import java.util.* -class TrackScheduler(private val player: AudioPlayer, private val announcer: MusicAnnouncer, val delet: () -> Unit) : +class TrackScheduler( + private val player: AudioPlayer, + private val announcer: MusicAnnouncer, + private val delet: () -> Unit +) : AudioEventAdapter() { private val queue = ArrayDeque() + private var loop = false + fun queue(track: AudioTrack) { if (!player.startTrack(track, true)) { queue.addLast(track) + announcer.sendQueueTrack(track) } } + override fun onTrackStart(player: AudioPlayer, track: AudioTrack) { announcer.sendPlayTrack(track) } override fun onTrackEnd(player: AudioPlayer, track: AudioTrack, endReason: AudioTrackEndReason) { - if (endReason.mayStartNext && queue.isNotEmpty()) { + if (loop && endReason.mayStartNext) { + queue.addLast(track.makeClone()) + } + if ((endReason.mayStartNext || endReason == AudioTrackEndReason.STOPPED) && queue.isNotEmpty()) { player.startTrack(queue.pop(), true) } else if (queue.isEmpty()) { - announcer.channel.guild.audioManager.closeAudioConnection() + announcer.close() delet() } } @@ -33,11 +43,13 @@ class TrackScheduler(private val player: AudioPlayer, private val announcer: Mus val playing get() = player.playingTrack != null - fun skip() { - if (queue.isEmpty()) { - player.stopTrack() - } else { - player.startTrack(queue.pop(), false) - } + val totalList: List + get() = if (player.playingTrack == null) listOf() else mutableListOf(player.playingTrack).apply { addAll(queue) } + + fun skip() = player.stopTrack() + + fun loop(): Boolean { + loop = !loop + return loop } } \ No newline at end of file