diff --git a/YoutubeProvider/build.gradle.kts b/YoutubeProvider/build.gradle.kts new file mode 100644 index 000000000..e14550015 --- /dev/null +++ b/YoutubeProvider/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/YoutubeProvider/src/main/kotlin/recloudstream/YoutubePlugin.kt b/YoutubeProvider/src/main/kotlin/recloudstream/YoutubePlugin.kt new file mode 100644 index 000000000..ac390bf9e --- /dev/null +++ b/YoutubeProvider/src/main/kotlin/recloudstream/YoutubePlugin.kt @@ -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()) + } +} \ No newline at end of file diff --git a/YoutubeProvider/src/main/kotlin/recloudstream/YoutubeProvider.kt b/YoutubeProvider/src/main/kotlin/recloudstream/YoutubeProvider.kt new file mode 100644 index 000000000..c2633335f --- /dev/null +++ b/YoutubeProvider/src/main/kotlin/recloudstream/YoutubeProvider.kt @@ -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() + 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) { + 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" }, + results, + true + ) + ), + pageData.hasNextPage() + ) + } + + private val searchPageCache = mutableMapOf() + 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 { + 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? { + 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) + } + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 06ff920d5..fe27b89c4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } } @@ -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 } }