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())
}
}
178 changes: 178 additions & 0 deletions YoutubeProvider/src/main/kotlin/recloudstream/YoutubeProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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

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
)

override val mainPage = mainPageOf(
"live" to "Live",
"trending_podcasts_episodes" to "Podcasts",
"trending_gaming" to "Gaming",
"trending_music" to "Music",
"trending_movies_and_shows" to "Movies & TV"
)

private val service = ServiceList.YouTube

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)

extractor.forceContentCountry(ContentCountry(Locale.getDefault().country))

val pageData = if (page == 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please add a comment explaining the 'linked list logic' briefly. It is good practice to make it easy to maintain.

Something like: "To fetch the next page we must have a reference to the current page. To do that we store a cache of the current page." would work well.

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

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

return newHomePageResponse(
listOf(
HomePageList(
request.name.ifEmpty { "Trending" },
Copy link
Contributor

Choose a reason for hiding this comment

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

This isEmpty should never be true, you can remove it.

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)
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> {
val service = ServiceList.YouTube
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 videoId = extractVideoId(url)
?: throw RuntimeException("Invalid YouTube URL")

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,
videoId
) {
plot = info.description.content
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()
}
}

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

private fun extractVideoId(url: String): String? {
Copy link
Contributor

Choose a reason for hiding this comment

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

The Youtube extractor already has this logic, but much better support for various links.
I suggest you remove extractVideoId and let the extractor handle the ID logic.

val patterns = listOf(
"(?:youtube\\.com/watch\\?v=|youtu\\.be/|youtube\\.com/embed/)([a-zA-Z0-9_-]{11})",
"v=([a-zA-Z0-9_-]{11})"
)

return patterns.firstNotNullOfOrNull {
it.toRegex().find(url)?.groupValues?.get(1)
}
}
}
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