From 9d772d4c1dc8b0db1b8f2b7eb634334a16ad4d86 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Thu, 18 Jan 2024 23:05:22 +0000 Subject: [PATCH] Add Twitch (#8) --- TwitchProvider/build.gradle.kts | 24 +++ TwitchProvider/src/main/AndroidManifest.xml | 2 + .../main/kotlin/recloudstream/TwitchPlugin.kt | 14 ++ .../kotlin/recloudstream/TwitchProvider.kt | 148 ++++++++++++++++++ build.gradle.kts | 2 +- 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 TwitchProvider/build.gradle.kts create mode 100644 TwitchProvider/src/main/AndroidManifest.xml create mode 100644 TwitchProvider/src/main/kotlin/recloudstream/TwitchPlugin.kt create mode 100644 TwitchProvider/src/main/kotlin/recloudstream/TwitchProvider.kt diff --git a/TwitchProvider/build.gradle.kts b/TwitchProvider/build.gradle.kts new file mode 100644 index 0000000..a80fbd0 --- /dev/null +++ b/TwitchProvider/build.gradle.kts @@ -0,0 +1,24 @@ +// use an integer for version numbers +version = 1 + +cloudstream { + // All of these properties are optional, you can safely remove them + + description = "Watch livestreams from Twitch" + authors = listOf("CranberrySoup") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 1 // will be 3 if unspecified + + // List of video source types. Users are able to filter for extensions in a given category. + // You can find a list of avaliable types here: + // https://recloudstream.github.io/dokka/-cloudstream/com.lagradost.cloudstream3/-tv-type/index.html + tvTypes = listOf("Live") + iconUrl = "https://www.google.com/s2/favicons?domain=twitch.tv&sz=%size%" +} diff --git a/TwitchProvider/src/main/AndroidManifest.xml b/TwitchProvider/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1863f02 --- /dev/null +++ b/TwitchProvider/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/TwitchProvider/src/main/kotlin/recloudstream/TwitchPlugin.kt b/TwitchProvider/src/main/kotlin/recloudstream/TwitchPlugin.kt new file mode 100644 index 0000000..1b71161 --- /dev/null +++ b/TwitchProvider/src/main/kotlin/recloudstream/TwitchPlugin.kt @@ -0,0 +1,14 @@ +package recloudstream + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class TwitchPlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(TwitchProvider()) + registerExtractorAPI(TwitchProvider.TwitchExtractor()) + } +} \ No newline at end of file diff --git a/TwitchProvider/src/main/kotlin/recloudstream/TwitchProvider.kt b/TwitchProvider/src/main/kotlin/recloudstream/TwitchProvider.kt new file mode 100644 index 0000000..c0fed91 --- /dev/null +++ b/TwitchProvider/src/main/kotlin/recloudstream/TwitchProvider.kt @@ -0,0 +1,148 @@ +package recloudstream + +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.getQualityFromName +import com.lagradost.cloudstream3.utils.loadExtractor +import org.jsoup.nodes.Element +import java.lang.RuntimeException + +class TwitchProvider : MainAPI() { + override var mainUrl = "https://twitchtracker.com" // Easiest to scrape + override var name = "Twitch" + override val supportedTypes = setOf(TvType.Live) + + override var lang = "uni" + + override val hasMainPage = true + private val gamesName = "games" + + override val mainPage = mainPageOf( + "$mainUrl/channels/live" to "Top global live streams", + "$mainUrl/games" to gamesName + ) + private val isHorizontal = true + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + return when (request.name) { + gamesName -> HomePageResponse(parseGames(), hasNext = false) // Get top games + else -> { + val doc = app.get(request.data, params = mapOf("page" to page.toString())).document + val channels = doc.select("table#channels tr").map { element -> + element.toLiveSearchResponse() + } + HomePageResponse( + listOf( + HomePageList( + request.name, + channels, + isHorizontalImages = isHorizontal + ) + ), hasNext = true + ) + } + } + } + + private fun Element.toLiveSearchResponse(): LiveSearchResponse { + val anchor = this.select("a") + val linkName = anchor.attr("href").substringAfterLast("/") + val name = anchor.firstOrNull { it.text().isNotBlank() }?.text() + val image = this.select("img").attr("src") + return LiveSearchResponse(name ?: "", linkName, this@TwitchProvider.name, TvType.Live, image) + } + + private suspend fun parseGames(): List { + val doc = app.get("$mainUrl/games").document + return doc.select("div.ranked-item") + .take(5) + .mapNotNull { element -> // No apmap to prevent getting 503 by cloudflare + val game = element.select("div.ri-name > a") + val url = fixUrl(game.attr("href")) + val name = game.text() + val searchResponses = parseGame(url).ifEmpty { return@mapNotNull null } + HomePageList(name, searchResponses, isHorizontalImages = isHorizontal) + } + } + + private suspend fun parseGame(url: String): List { + val doc = app.get(url).document + return doc.select("td.cell-slot.sm").map { element -> + element.toLiveSearchResponse() + } + } + + override suspend fun load(url: String): LoadResponse { + val realUrl = url.substringAfterLast("/") + val doc = app.get("$mainUrl/$realUrl", referer = mainUrl).document + val name = doc.select("div#app-title").text() + if (name.isBlank()) { + throw RuntimeException("Could not load page, please try again.\n") + } + val rank = doc.select("div.rank-badge > span").last()?.text()?.toIntOrNull() + val image = doc.select("div#app-logo > img").attr("src") + val poster = doc.select("div.embed-responsive > img").attr("src").ifEmpty { image } + val description = doc.select("div[style='word-wrap:break-word;font-size:12px;']").text() + val language = doc.select("a.label.label-soft").text().ifEmpty { null } + val isLive = !doc.select("div.live-indicator-container").isEmpty() + + val tags = listOfNotNull( + isLive.let { if (it) "Live" else "Offline" }, + language, + rank?.let { "Rank: $it" }, + ) + + val twitchUrl = "https://twitch.tv/$realUrl" + + return LiveStreamLoadResponse( + name, twitchUrl, this.name, twitchUrl, plot = description, posterUrl = image, backgroundPosterUrl = poster, tags = tags + ) + } + + override suspend fun search(query: String): List? { + val document = app.get("$mainUrl/search", params = mapOf("q" to query), referer = mainUrl).document + return document.select("table.tops tr").map { it.toLiveSearchResponse() } + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + return loadExtractor(data, subtitleCallback, callback) + } + + class TwitchExtractor : ExtractorApi() { + override val mainUrl = "https://twitch.tv/" + override val name = "Twitch" + override val requiresReferer = false + + data class ApiResponse( + val success: Boolean, + val urls: Map? + ) + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val response = app.get("https://pwn.sh/tools/streamapi.py?url=$url").parsed() + response.urls?.forEach { (name, url) -> + val quality = getQualityFromName(name.substringBefore("p")) + callback.invoke( + ExtractorLink( + this.name, + "${this.name} ${name.replace("${quality}p", "")}", + url, + "", + quality, + isM3u8 = true + )) + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 8038bbd..9cb4f60 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,7 +75,7 @@ subprojects { // but you dont need to include any of them if you dont need them // https://github.com/recloudstream/cloudstream/blob/master/app/build.gradle implementation(kotlin("stdlib")) // adds standard kotlin features, like listOf, mapOf etc - implementation("com.github.Blatzar:NiceHttp:0.4.4") // http library + implementation("com.github.Blatzar:NiceHttp:0.4.5") // http library implementation("org.jsoup:jsoup:1.16.2") // html parser } }