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