Skip to content

Commit

Permalink
Add Twitch (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
CranberrySoup authored Jan 18, 2024
1 parent 60e7a8d commit 9d772d4
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 1 deletion.
24 changes: 24 additions & 0 deletions TwitchProvider/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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%"
}
2 changes: 2 additions & 0 deletions TwitchProvider/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.example"/>
14 changes: 14 additions & 0 deletions TwitchProvider/src/main/kotlin/recloudstream/TwitchPlugin.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}
148 changes: 148 additions & 0 deletions TwitchProvider/src/main/kotlin/recloudstream/TwitchProvider.kt
Original file line number Diff line number Diff line change
@@ -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<HomePageList> {
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<LiveSearchResponse> {
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<SearchResponse>? {
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<String, String>?
)

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<ApiResponse>()
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
))
}
}
}
}
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down

0 comments on commit 9d772d4

Please sign in to comment.