Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions YoutubeProvider/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Use an integer for version numbers
version = 1

cloudstream {
// All of these properties are optional, you can safely remove any of them.

description = "Watch Youtube in Cloudstream"
authors = listOf("KaifTaufiq")

/**
* Status int as one of the following:
* 0: Down
* 1: Ok
* 2: Slow
* 3: Beta-only
**/
status = 1 // Will be 3 if unspecified

tvTypes = listOf("Other", "Live", "TvSeries")
iconUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/09/YouTube_full-color_icon_%282017%29.svg/3840px-YouTube_full-color_icon_%282017%29.svg.png"

isCrossPlatform = true
}
11 changes: 11 additions & 0 deletions YoutubeProvider/src/main/kotlin/recloudstream/YoutubePlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package recloudstream

import com.lagradost.cloudstream3.plugins.BasePlugin
import com.lagradost.cloudstream3.plugins.CloudstreamPlugin

@CloudstreamPlugin
class YoutubePlugin : BasePlugin() {
override fun load() {
registerMainAPI(YoutubeProvider())
}
}
331 changes: 331 additions & 0 deletions YoutubeProvider/src/main/kotlin/recloudstream/YoutubeProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
package recloudstream

import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.utils.*
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.kiosk.KioskExtractor
import org.schabi.newpipe.extractor.InfoItem
//import org.schabi.newpipe.extractor.localization.ContentCountry
import org.schabi.newpipe.extractor.stream.StreamInfo
import java.util.Locale
import kotlin.concurrent.thread

class YoutubeProvider : MainAPI() {
override var mainUrl = "https://www.youtube.com"
override var name = "YouTube"
override var lang = "en"
override val hasMainPage = true
override val hasQuickSearch = true
override val supportedTypes = setOf(
TvType.Others,
TvType.Live,
TvType.TvSeries
)

private val service = ServiceList.YouTube

// Make mainPage dynamic and updatable
override var mainPage: List<MainPageData> = emptyList()

init {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the logic behind placing this inside init, however you should not do it.

Setting mainPage in a thread during init will lead to race conditions where the mainPage is read before it is set.
Running potentially crashing code inside init can also lead to unexpected exceptions.

What you should do instead is move this logic to inside getMainPage, but inside getMainPage do not set the mainPage. If you just return a HomePageResponse with the appropriate rows it will work.

val kiosks = service.kioskList.availableKiosks
mainPage = kiosks.map { id ->
val fallbackName = id.split("_").joinToString(" ") { word ->
word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
}
MainPageData(fallbackName, id)
}

thread {
val localizedPages = kiosks.map { id ->
var localizedLabel = id
try {
val extractor = service.kioskList.getExtractorById(id, null)
// extractor.forceContentCountry(Localisation to be handled later)
extractor.fetchPage()
localizedLabel = extractor.name
} catch (e: Exception) {
localizedLabel = id.split("_").joinToString(" ") { word ->
word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
}
}
MainPageData(localizedLabel, id)
}
mainPage = localizedPages
}
}

private val pageCache = mutableMapOf<String, org.schabi.newpipe.extractor.Page?>()
override suspend fun getMainPage(
page: Int,
request: MainPageRequest
): HomePageResponse {
val key = request.data
if (page == 1) pageCache.remove(key)

val extractor = getKioskExtractor(request.data)

// val userCountry = Locale.getDefault().country
// extractor.forceContentCountry(ContentCountry(userCountry.ifBlank { "US" }))

val pageData = try {
if (page == 1) {
extractor.fetchPage()

extractor.initialPage.also {
pageCache[key] = it.nextPage
}
} else {
val next = pageCache[key] ?: return newHomePageResponse(emptyList(), false)
extractor.getPage(next).also {
pageCache[key] = it.nextPage
}
}
} catch (e: Exception) {
return newHomePageResponse(emptyList(), false)
}

val results = pageData.items.map {
it.toSearchResponse()
}

val headerName = try {
extractor.name.ifEmpty { request.name }
} catch (e: Exception) {
request.name
}.ifEmpty { "Trending" }

return newHomePageResponse(
listOf(
HomePageList(
headerName,
results,
true
)
),
pageData.hasNextPage()
)
}

private val searchPageCache = mutableMapOf<String, org.schabi.newpipe.extractor.Page?>()
override suspend fun search(query: String, page: Int): SearchResponseList {
val extractor = service.getSearchExtractor(query)

// Localisation to be handled later
// extractor.forceContentCountry(ContentCountry(Locale.getDefault().country))

val pageData = if (!searchPageCache.containsKey(query)) {
extractor.fetchPage()
extractor.initialPage.also {
searchPageCache[query] = it.nextPage
}
} else {
val next = searchPageCache[query] ?: return newSearchResponseList(emptyList(), false)
extractor.getPage(next).also {
searchPageCache[query] = it.nextPage
}
}

val results = pageData.items.map {
it.toSearchResponse()
}

return newSearchResponseList(
results,
pageData.hasNextPage()
)
}

private fun getKioskExtractor(kioskId: String?): KioskExtractor<out InfoItem> {
return if (kioskId.isNullOrBlank()) {
service.kioskList.getDefaultKioskExtractor(null)
} else {
service.kioskList.getExtractorById(kioskId, null)
}
}

private fun InfoItem.toSearchResponse(): SearchResponse {
return newMovieSearchResponse(
name ?: "Unknown",
url ?: "",
TvType.Others
) {
posterUrl = thumbnails.lastOrNull()?.url
}
}

override suspend fun load(url: String): LoadResponse {
val urlType = getUrlType(url)

return when (urlType) {
UrlType.Video -> loadVideo(url)
UrlType.Channel -> loadChannel(url)
UrlType.Playlist -> loadPlaylist(url)
UrlType.Unknown -> throw RuntimeException("Unsupported YouTube URL")
}
}

private enum class UrlType {
Video, Channel, Playlist, Unknown
}

private fun getUrlType(url: String): UrlType {
return when {
url.contains("/watch?v=") || url.contains("youtu.be/") -> UrlType.Video
url.contains("/channel/") || url.contains("/@") || url.contains("/c/") -> UrlType.Channel
url.contains("/playlist?list=") || url.contains("/watch?v=") && url.contains("&list=") -> UrlType.Playlist
else -> UrlType.Unknown
}
}

private suspend fun loadVideo(url: String): LoadResponse {
val extractor = ServiceList.YouTube.getStreamExtractor(url)
extractor.fetchPage()

val info = StreamInfo.getInfo(extractor)

return newMovieLoadResponse(
info.name,
url,
if (info.streamType?.name?.contains("LIVE") == true)
TvType.Live else TvType.Others,
url
) {
plot = info.description.content.toString()
posterUrl = info.thumbnails.lastOrNull()?.url
duration = info.duration.toInt()

info.uploaderName?.takeIf { it.isNotBlank() }?.let { uploader ->
actors = listOf(
ActorData(
Actor(
uploader,
info.uploaderAvatars.lastOrNull()?.url ?: ""
)
)
)
}

tags = info.tags?.take(5)?.toList()
}
}

private suspend fun loadChannel(url: String): LoadResponse {
val extractor = ServiceList.YouTube.getChannelExtractor(url)
extractor.fetchPage()

val channelName = extractor.name
val channelDescription = extractor.description
val channelAvatar = extractor.avatars.lastOrNull()?.url
val channelBanner = extractor.banners.lastOrNull()?.url

val tabs = extractor.tabs
val videosTab = tabs.firstOrNull { it.url.contains("/videos") } ?: tabs.firstOrNull()
?: throw RuntimeException("No videos tab found")

val videosExtractor = ServiceList.YouTube.getChannelTabExtractor(videosTab)
val episodes = mutableListOf<Episode>()

var page = videosExtractor.initialPage
episodes.addAll(page.items.map { item ->
newEpisode(item.url) {
name = item.name
posterUrl = item.thumbnails.lastOrNull()?.url
}
})

while (page.hasNextPage()) {
page = videosExtractor.getPage(page.nextPage)
episodes.addAll(page.items.map { item ->
newEpisode(item.url) {
name = item.name
posterUrl = item.thumbnails.lastOrNull()?.url
}
})
}

return newTvSeriesLoadResponse(
channelName,
url,
TvType.TvSeries,
episodes
) {
plot = channelDescription
posterUrl = channelBanner
backgroundPosterUrl = channelBanner
tags = listOf("Channel")
actors = listOf(
ActorData(
Actor(
channelName,
channelAvatar ?: ""
)
)
)
}
}

private suspend fun loadPlaylist(url: String): LoadResponse {
val extractor = ServiceList.YouTube.getPlaylistExtractor(url)
extractor.fetchPage()

val playlistName = extractor.name
val playlistDescription = extractor.description.content.toString()
val playlistThumbnail = extractor.thumbnails.lastOrNull()?.url
val uploaderName = extractor.uploaderName

val episodes = mutableListOf<Episode>()

var page = extractor.getInitialPage()
episodes.addAll(page.items.map { item ->
newEpisode(item.url) {
name = item.name
posterUrl = item.thumbnails.lastOrNull()?.url
}
})

while (page.hasNextPage()) {
page = extractor.getPage(page.nextPage)
episodes.addAll(page.items.map { item ->
newEpisode(item.url) {
name = item.name
posterUrl = item.thumbnails.lastOrNull()?.url
}
})
}

return newTvSeriesLoadResponse(
playlistName,
url,
TvType.TvSeries,
episodes
) {
plot = playlistDescription
posterUrl = playlistThumbnail
tags = if (uploaderName.isNotBlank()) listOf("Channel: $uploaderName") else listOf("Playlist")
if (uploaderName.isNotBlank()) {
actors = listOf(
ActorData(
Actor(
uploaderName,
extractor.uploaderAvatars.lastOrNull()?.url ?: ""
)
)
)
}
}
}

override suspend fun loadLinks(
data: String,
isCasting: Boolean,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
): Boolean {
return loadExtractor(
"https://youtube.com/watch?v=$data",
subtitleCallback,
callback
)
}
}
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ buildscript {
classpath("com.android.tools.build:gradle:8.7.3")
// Cloudstream gradle plugin which makes everything work and builds plugins
classpath("com.github.recloudstream:gradle:-SNAPSHOT")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
}
}

Expand Down Expand Up @@ -82,6 +82,7 @@ subprojects {
// IMPORTANT: Do not bump Jackson above 2.13.1, as newer versions will
// break compatibility on older Android devices.
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") // JSON Parser
implementation("com.github.teamnewpipe:NewPipeExtractor:v0.25.2") // NewPipe Extractor
}
}

Expand Down