Finish music commands. Add track announcer. Add weather command because why not.

merge-requests/1/head
Julius de Jeu 2019-06-02 20:13:33 +02:00
parent 1a0b3a1cd0
commit 22688dd404
15 changed files with 741 additions and 24 deletions

View File

@ -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())
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}

View File

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

View File

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

View File

@ -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}")
}
}

View File

@ -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()
}
}

View File

@ -0,0 +1,5 @@
package nl.voidcorp.discord.music
import net.dv8tion.jda.api.entities.Guild
class NullMusicAnnouncer(guild: Guild) : MusicAnnouncer(null, guild)

View File

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

View File

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