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..73936bfc8 --- /dev/null +++ b/YoutubeProvider/src/main/kotlin/recloudstream/YoutubeProvider.kt @@ -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 = emptyList() + + init { + 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() + 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() + 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 { + 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() + + 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() + + 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 + ) + } +} \ 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 } }