Merge branch 'how_to_remove_music' into 'master'
Remove all music related stuff See merge request jdejeu/ottobotv2!12
This commit is contained in:
commit
891ae1ae0b
|
@ -1,21 +1,14 @@
|
||||||
package nl.voidcorp.discord
|
package nl.voidcorp.discord
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.source.bandcamp.BandcampAudioSourceManager
|
|
||||||
import com.sedmelluq.discord.lavaplayer.source.beam.BeamAudioSourceManager
|
|
||||||
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 net.dv8tion.jda.api.entities.Activity
|
import net.dv8tion.jda.api.entities.Activity
|
||||||
import net.dv8tion.jda.api.hooks.ListenerAdapter
|
import net.dv8tion.jda.api.hooks.ListenerAdapter
|
||||||
import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder
|
import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder
|
||||||
import nl.voidcorp.discord.music.CustomYoutubeSourceManager
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class Loader(listeners: List<ListenerAdapter>, playerManager: PlayerManager, store: ConfigStore) {
|
class Loader(listeners: List<ListenerAdapter>, store: ConfigStore) {
|
||||||
init {
|
init {
|
||||||
val token = System.getenv("DISCORD_TOKEN") ?: throw RuntimeException("'DISCORD_TOKEN' not set!")
|
val token = System.getenv("DISCORD_TOKEN") ?: throw RuntimeException("'DISCORD_TOKEN' not set!")
|
||||||
val builder = DefaultShardManagerBuilder(token)
|
val builder = DefaultShardManagerBuilder(token)
|
||||||
|
@ -27,12 +20,5 @@ class Loader(listeners: List<ListenerAdapter>, playerManager: PlayerManager, sto
|
||||||
jda.setActivityProvider {
|
jda.setActivityProvider {
|
||||||
Activity.playing("v${store.version} ($it)")
|
Activity.playing("v${store.version} ($it)")
|
||||||
}
|
}
|
||||||
|
|
||||||
playerManager.registerSourceManager(SoundCloudAudioSourceManager())
|
|
||||||
playerManager.registerSourceManager(BandcampAudioSourceManager())
|
|
||||||
playerManager.registerSourceManager(VimeoAudioSourceManager())
|
|
||||||
playerManager.registerSourceManager(TwitchStreamAudioSourceManager())
|
|
||||||
playerManager.registerSourceManager(BeamAudioSourceManager())
|
|
||||||
playerManager.registerSourceManager(CustomYoutubeSourceManager())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,19 +0,0 @@
|
||||||
package nl.voidcorp.discord.commands.music
|
|
||||||
|
|
||||||
import nl.voidcorp.discord.command.*
|
|
||||||
import nl.voidcorp.discord.music.PlayerManager
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class ForceLeave(val playerManager: PlayerManager) : Command(
|
|
||||||
"forceleave",
|
|
||||||
group = CommandGroup.MUSIC,
|
|
||||||
location = CommandSource.GUILD,
|
|
||||||
commandLevel = CommandLevel.MODERATOR
|
|
||||||
) {
|
|
||||||
override fun handle(event: CommandMessage): CommandResult {
|
|
||||||
event.guild!!.audioManager.closeAudioConnection()
|
|
||||||
playerManager.delGuildPlayer(event.guild)
|
|
||||||
return CommandResult.SUCCESS
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
package nl.voidcorp.discord.commands.music
|
|
||||||
|
|
||||||
import nl.voidcorp.discord.command.*
|
|
||||||
import nl.voidcorp.discord.music.AudioLoadHandler
|
|
||||||
import nl.voidcorp.discord.music.PlayerManager
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class Play(val playerManager: PlayerManager) :
|
|
||||||
Command("play", location = CommandSource.GUILD, group = CommandGroup.MUSIC, usage = "play song url (or song name prepended with ytsearch:)") {
|
|
||||||
override fun handle(event: CommandMessage): CommandResult {
|
|
||||||
val chan = event.member!!.voiceState!!.channel
|
|
||||||
if (chan == null) {
|
|
||||||
event.reply("Please join a voice channel to play music!")
|
|
||||||
return CommandResult.SUCCESS
|
|
||||||
} else if (event.params.drop(1).isEmpty()) {
|
|
||||||
event.reply("I'm going to need a url or a search term to actually find a song...")
|
|
||||||
return CommandResult.PARAMETERS
|
|
||||||
}
|
|
||||||
|
|
||||||
val am = event.guild!!.audioManager
|
|
||||||
|
|
||||||
|
|
||||||
val ts = playerManager.getGuildPlayer(event.guild)
|
|
||||||
|
|
||||||
if (!ts.playing) {
|
|
||||||
am.openAudioConnection(chan)
|
|
||||||
}
|
|
||||||
|
|
||||||
playerManager.loadItem(event.params.drop(1).joinToString(" "), AudioLoadHandler(ts))
|
|
||||||
|
|
||||||
|
|
||||||
return CommandResult.SUCCESS
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
package nl.voidcorp.discord.commands.music
|
|
||||||
|
|
||||||
import nl.voidcorp.discord.command.*
|
|
||||||
import nl.voidcorp.discord.music.PlayerManager
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class Skip(val playerManager: PlayerManager) :
|
|
||||||
Command("skip", location = CommandSource.GUILD, group = CommandGroup.MUSIC) {
|
|
||||||
override fun handle(event: CommandMessage): CommandResult {
|
|
||||||
playerManager.getGuildPlayer(event.guild!!).skip()
|
|
||||||
return CommandResult.SUCCESS
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
package nl.voidcorp.discord.music
|
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler
|
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
|
||||||
|
|
||||||
|
|
||||||
class AudioLoadHandler(private val trackScheduler: TrackScheduler) : AudioLoadResultHandler {
|
|
||||||
|
|
||||||
override fun loadFailed(exception: FriendlyException) {
|
|
||||||
throw exception
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun trackLoaded(track: AudioTrack) {
|
|
||||||
trackScheduler.queue(track)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun noMatches() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playlistLoaded(playlist: AudioPlaylist) {
|
|
||||||
if (playlist.isSearchResult) {
|
|
||||||
trackScheduler.queue(playlist.selectedTrack ?: playlist.tracks.first())
|
|
||||||
} else
|
|
||||||
for (t in playlist.tracks) {
|
|
||||||
trackScheduler.queue(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
package nl.voidcorp.discord.music
|
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame
|
|
||||||
import net.dv8tion.jda.api.audio.AudioSendHandler
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
|
|
||||||
class AudioPlayerSendHandler(private val audioPlayer: AudioPlayer) : AudioSendHandler {
|
|
||||||
private var lastFrame: AudioFrame? = null
|
|
||||||
|
|
||||||
override fun canProvide(): Boolean {
|
|
||||||
lastFrame = audioPlayer.provide()
|
|
||||||
return lastFrame != null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun provide20MsAudio() = ByteBuffer.wrap(lastFrame!!.data)
|
|
||||||
|
|
||||||
override fun isOpus(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,428 +0,0 @@
|
||||||
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,23 +0,0 @@
|
||||||
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 org.apache.logging.log4j.LogManager
|
|
||||||
|
|
||||||
abstract class MusicAnnouncer(open val channel: TextChannel?, private val guild: Guild) {
|
|
||||||
|
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
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.replace("@here", "@hеre").replace("@everyone", "@еveryone")}").queue()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun sendQueueTrack(track: AudioTrack) {
|
|
||||||
channel.sendMessage("Queue'd ${track.info.title.replace("@here", "@hеre").replace("@everyone", "@еveryone")}!").queue()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
package nl.voidcorp.discord.music
|
|
||||||
|
|
||||||
import net.dv8tion.jda.api.entities.Guild
|
|
||||||
|
|
||||||
class NullMusicAnnouncer(guild: Guild) : MusicAnnouncer(null, guild)
|
|
|
@ -1,48 +0,0 @@
|
||||||
package nl.voidcorp.discord.music
|
|
||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager
|
|
||||||
import net.dv8tion.jda.api.entities.Guild
|
|
||||||
import nl.voidcorp.discord.storage.GuildRepo
|
|
||||||
import nl.voidcorp.discord.storage.GuildStore
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class PlayerManager(val repo: GuildRepo) : DefaultAudioPlayerManager() {
|
|
||||||
val guildPlayMap = mutableMapOf<Long, TrackScheduler>()
|
|
||||||
fun getGuildPlayer(guild: Guild): TrackScheduler {
|
|
||||||
return if (guildPlayMap.containsKey(guild.idLong)) {
|
|
||||||
guildPlayMap[guild.idLong] ?: error("oof?")
|
|
||||||
} else {
|
|
||||||
val player = createPlayer()
|
|
||||||
val store = repo.findByGuildId(guild.idLong) ?: GuildStore(guild.idLong)
|
|
||||||
val channel = store.musicChannels.firstOrNull()
|
|
||||||
?: store.botChannels.firstOrNull()
|
|
||||||
?: guild.textChannels.firstOrNull {
|
|
||||||
it.name.contains("music", true) && it.name.contains(
|
|
||||||
"bot",
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}?.idLong
|
|
||||||
?: guild.textChannels.firstOrNull { it.name.contains("music", true) }?.idLong
|
|
||||||
?: guild.textChannels.firstOrNull { it.name.contains("bot", true) }?.idLong
|
|
||||||
player.volume = 50
|
|
||||||
|
|
||||||
val ts = TrackScheduler(
|
|
||||||
player,
|
|
||||||
if (channel != null) MusicAnnouncerImpl(
|
|
||||||
guild.getTextChannelById(channel)!!,
|
|
||||||
guild
|
|
||||||
) else NullMusicAnnouncer(guild)
|
|
||||||
) {
|
|
||||||
delGuildPlayer(guild)
|
|
||||||
}
|
|
||||||
|
|
||||||
player.addListener(ts)
|
|
||||||
guild.audioManager.sendingHandler = AudioPlayerSendHandler(player)
|
|
||||||
guildPlayMap[guild.idLong] = ts
|
|
||||||
ts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delGuildPlayer(guild: Guild) = guildPlayMap.remove(guild.idLong)
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
package nl.voidcorp.discord.music
|
|
||||||
|
|
||||||
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 java.util.*
|
|
||||||
|
|
||||||
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 (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.close()
|
|
||||||
delet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val playing
|
|
||||||
get() = player.playingTrack != null
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -8,4 +8,4 @@ spring.jpa.generate-ddl=true
|
||||||
spring.jpa.hibernate.ddl-auto=create-drop
|
spring.jpa.hibernate.ddl-auto=create-drop
|
||||||
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
|
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
|
||||||
|
|
||||||
ottobot.version=1.7
|
ottobot.version=1.8
|
Loading…
Reference in a new issue