429 lines
17 KiB
Kotlin
429 lines
17 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
}
|