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
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
?: 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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<AudioTrack>()
|
||||
|
||||
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<AudioTrack>
|
||||
get() = if (player.playingTrack == null) listOf() else mutableListOf(player.playingTrack).apply { addAll(queue) }
|
||||
|
||||
fun skip() = player.stopTrack()
|
||||
|
||||
fun loop(): Boolean {
|
||||
loop = !loop
|
||||
return loop
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue