Finish music commands. Add track announcer. Add weather command because why not.
This commit is contained in:
parent
1a0b3a1cd0
commit
22688dd404
|
@ -1,10 +1,17 @@
|
||||||
package nl.voidcorp.discord
|
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.entities.Activity
|
||||||
import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder
|
import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder
|
||||||
import nl.voidcorp.discord.events.CommandListener
|
import nl.voidcorp.discord.events.CommandListener
|
||||||
import nl.voidcorp.discord.events.OttoListener
|
import nl.voidcorp.discord.events.OttoListener
|
||||||
|
import nl.voidcorp.discord.music.CustomYoutubeSourceManager
|
||||||
import nl.voidcorp.discord.music.PlayerManager
|
import nl.voidcorp.discord.music.PlayerManager
|
||||||
import nl.voidcorp.discord.storage.ConfigStore
|
import nl.voidcorp.discord.storage.ConfigStore
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
@ -23,6 +30,12 @@ class Loader(listener: CommandListener, playerManager: PlayerManager, store: Con
|
||||||
jda.setActivityProvider {
|
jda.setActivityProvider {
|
||||||
Activity.playing("v${store.version} ($it)")
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -27,12 +27,11 @@ abstract class Command(
|
||||||
|
|
||||||
fun onCommand(event: MessageReceivedEvent, prefix: String): CommandResult {
|
fun onCommand(event: MessageReceivedEvent, prefix: String): CommandResult {
|
||||||
val starts =
|
val starts =
|
||||||
event.message.contentRaw.drop(prefix.length).trim()
|
(event.message.contentRaw.drop(prefix.length).trim().split("\\s".toRegex())
|
||||||
.startsWith(name) or aliases.any {
|
.first() == name) or (aliases.any {
|
||||||
event.message.contentRaw.drop(prefix.length).trim().startsWith(
|
event.message.contentRaw.drop(prefix.length).trim().split("\\s".toRegex())
|
||||||
it
|
.first() == it
|
||||||
)
|
})
|
||||||
}
|
|
||||||
return if (!starts) CommandResult.NOPE else when (location) {
|
return if (!starts) CommandResult.NOPE else when (location) {
|
||||||
CommandSource.PRIVATE -> if (event.channelType == ChannelType.PRIVATE) guildStuff(
|
CommandSource.PRIVATE -> if (event.channelType == ChannelType.PRIVATE) guildStuff(
|
||||||
event,
|
event,
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
19
src/main/kotlin/nl/voidcorp/discord/commands/music/Loop.kt
Normal file
19
src/main/kotlin/nl/voidcorp/discord/commands/music/Loop.kt
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<RequestConfig, RequestConfig>) {
|
||||||
|
httpInterfaceManager.configureRequests(configurator)
|
||||||
|
searchProvider.configureRequests(configurator)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureBuilder(configurator: Consumer<HttpClientBuilder>) {
|
||||||
|
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<String, String> {
|
||||||
|
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<AudioTrack>()
|
||||||
|
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("<table>$html</table>", "")
|
||||||
|
|
||||||
|
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<AudioTrack>
|
||||||
|
): 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<String, String>)
|
||||||
|
|
||||||
|
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 = "(?<v>[a-zA-Z0-9_-]{11})"
|
||||||
|
private const val PLAYLIST_ID_REGEX = "(?<list>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,23 @@
|
||||||
package nl.voidcorp.discord.music
|
package nl.voidcorp.discord.music
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
||||||
|
import net.dv8tion.jda.api.entities.Guild
|
||||||
import net.dv8tion.jda.api.entities.TextChannel
|
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) {
|
private val logger = LogManager.getLogger("Music - ${guild.name}")
|
||||||
logger.info(track.info.uri)
|
|
||||||
|
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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package nl.voidcorp.discord.music
|
||||||
|
|
||||||
|
import net.dv8tion.jda.api.entities.Guild
|
||||||
|
|
||||||
|
class NullMusicAnnouncer(guild: Guild) : MusicAnnouncer(null, guild)
|
|
@ -25,10 +25,15 @@ class PlayerManager(val repo: GuildRepo) : DefaultAudioPlayerManager() {
|
||||||
}?.idLong
|
}?.idLong
|
||||||
?: guild.textChannels.firstOrNull { it.name.contains("music", true) }?.idLong
|
?: guild.textChannels.firstOrNull { it.name.contains("music", true) }?.idLong
|
||||||
?: guild.textChannels.firstOrNull { it.name.contains("bot", true) }?.idLong
|
?: guild.textChannels.firstOrNull { it.name.contains("bot", true) }?.idLong
|
||||||
?: guild.defaultChannel!!.idLong
|
|
||||||
player.volume = 50
|
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)
|
delGuildPlayer(guild)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,28 +4,38 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayer
|
||||||
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter
|
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason
|
||||||
import nl.voidcorp.discord.logger
|
|
||||||
import java.util.*
|
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() {
|
AudioEventAdapter() {
|
||||||
private val queue = ArrayDeque<AudioTrack>()
|
private val queue = ArrayDeque<AudioTrack>()
|
||||||
|
|
||||||
|
private var loop = false
|
||||||
|
|
||||||
fun queue(track: AudioTrack) {
|
fun queue(track: AudioTrack) {
|
||||||
if (!player.startTrack(track, true)) {
|
if (!player.startTrack(track, true)) {
|
||||||
queue.addLast(track)
|
queue.addLast(track)
|
||||||
|
announcer.sendQueueTrack(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onTrackStart(player: AudioPlayer, track: AudioTrack) {
|
override fun onTrackStart(player: AudioPlayer, track: AudioTrack) {
|
||||||
announcer.sendPlayTrack(track)
|
announcer.sendPlayTrack(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTrackEnd(player: AudioPlayer, track: AudioTrack, endReason: AudioTrackEndReason) {
|
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)
|
player.startTrack(queue.pop(), true)
|
||||||
} else if (queue.isEmpty()) {
|
} else if (queue.isEmpty()) {
|
||||||
announcer.channel.guild.audioManager.closeAudioConnection()
|
announcer.close()
|
||||||
delet()
|
delet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,11 +43,13 @@ class TrackScheduler(private val player: AudioPlayer, private val announcer: Mus
|
||||||
val playing
|
val playing
|
||||||
get() = player.playingTrack != null
|
get() = player.playingTrack != null
|
||||||
|
|
||||||
fun skip() {
|
val totalList: List<AudioTrack>
|
||||||
if (queue.isEmpty()) {
|
get() = if (player.playingTrack == null) listOf() else mutableListOf(player.playingTrack).apply { addAll(queue) }
|
||||||
player.stopTrack()
|
|
||||||
} else {
|
fun skip() = player.stopTrack()
|
||||||
player.startTrack(queue.pop(), false)
|
|
||||||
}
|
fun loop(): Boolean {
|
||||||
|
loop = !loop
|
||||||
|
return loop
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue