From f9e0d033be315d9237c322eb06461392dca76435 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 25 May 2026 17:45:15 -0600 Subject: [PATCH] Add expect/actual for YoutubeExtractor NewPipeExtractor won't work in non-JVM platforms. There is a KMP version of it but it is to small and I don't think we should really use it right now. --- library/build.gradle.kts | 11 +- .../extractors/YoutubeExtractor.android.kt | 105 +++++++++++++++ .../extractors/YoutubeExtractor.kt | 122 ++---------------- .../extractors/YoutubeExtractor.jvm.kt | 105 +++++++++++++++ 4 files changed, 232 insertions(+), 111 deletions(-) create mode 100644 library/src/androidMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.android.kt create mode 100644 library/src/jvmMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.jvm.kt diff --git a/library/build.gradle.kts b/library/build.gradle.kts index b5f525e8385..a45191150e3 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -61,9 +61,18 @@ kotlin { implementation(libs.fuzzywuzzy) // Match Extractors implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript - implementation(libs.newpipeextractor) implementation(libs.tmdb.java) // TMDB API v3 Wrapper Made with RetroFit } + + // We will eventually add a new jvmCommonMain source set + // for things shared between Android and JVM. + androidMain.dependencies { + implementation(libs.newpipeextractor) + } + + jvmMain.dependencies { + implementation(libs.newpipeextractor) + } } } diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.android.kt new file mode 100644 index 00000000000..345aa7185f0 --- /dev/null +++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.android.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.newAudioFile +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamType + +actual open class YoutubeExtractor actual constructor() : ExtractorApi() { + + actual override val mainUrl = "https://www.youtube.com" + actual override val name = "YouTube" + actual override val requiresReferer = false + + actual override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val videoId = extractYouTubeId(url) + val watchUrl = "$mainUrl/watch?v=$videoId" + + val info = StreamInfo.getInfo(watchUrl) + val isLive = info.streamType == StreamType.LIVE_STREAM + || info.streamType == StreamType.AUDIO_LIVE_STREAM + || info.streamType == StreamType.POST_LIVE_STREAM + || info.streamType == StreamType.POST_LIVE_AUDIO_STREAM + + if (isLive && info.hlsUrl != null) { + callback( + newExtractorLink( + source = name, + name = "YouTube Live", + url = info.hlsUrl + ) { + type = ExtractorLinkType.M3U8 + } + ) + } else { + processVideo(info, subtitleCallback, callback) + } + } + + private suspend fun processVideo( + info: StreamInfo, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ): Boolean { + val videoStreams = info.videoOnlyStreams.orEmpty() + if (videoStreams.isEmpty()) return false + + val audioStreams = info.audioStreams.orEmpty() + videoStreams.forEach { video -> + callback( + newExtractorLink( + source = name, + name = "YouTube ${normalizeCodec(video.codec)}", + url = video.content + ) { + quality = video.height + audioTracks = audioStreams.map { newAudioFile(it.content) } + } + ) + } + + info.subtitles.forEach { subtitle -> + subtitleCallback( + newSubtitleFile( + lang = subtitle.displayLanguageName + ?: subtitle.languageTag + ?: "Unknown", + url = subtitle.content + ) + ) + } + + return true + } + + private fun extractYouTubeId(url: String): String { + val regex = Regex( + "(?:youtu\\.be/|youtube(?:-nocookie)?\\.com/(?:.*v=|v/|u/\\w/|embed/|shorts/|live/))([\\w-]{11})" + ) + + return regex.find(url)?.groupValues?.get(1) + ?: throw IllegalArgumentException("Invalid YouTube URL: $url") + } + + private fun normalizeCodec(codec: String?): String { + if (codec.isNullOrBlank()) return "" + val c = codec.lowercase() + return when { + c.startsWith("av01") -> "AV1" + c.startsWith("vp9") -> "VP9" + c.startsWith("avc1") || c.startsWith("h264") -> "H264" + c.startsWith("hev1") || c.startsWith("hvc1") || c.startsWith("hevc") -> "H265" + else -> codec.substringBefore('.').uppercase() + } + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt index dd8511eaee2..fb310401ae5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt @@ -1,14 +1,20 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.newAudioFile -import com.lagradost.cloudstream3.newSubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.newExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import org.schabi.newpipe.extractor.stream.StreamInfo -import org.schabi.newpipe.extractor.stream.StreamType + +expect open class YoutubeExtractor() : ExtractorApi { + override val mainUrl: String + override val name: String + override val requiresReferer: Boolean + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) +} class YoutubeShortLinkExtractor : YoutubeExtractor() { override val mainUrl = "https://youtu.be" @@ -21,107 +27,3 @@ class YoutubeMobileExtractor : YoutubeExtractor() { class YoutubeNoCookieExtractor : YoutubeExtractor() { override val mainUrl = "https://www.youtube-nocookie.com" } - -open class YoutubeExtractor : ExtractorApi() { - - override val mainUrl = "https://www.youtube.com" - override val name = "YouTube" - override val requiresReferer = false - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val videoId = extractYouTubeId(url) - val watchUrl = "$mainUrl/watch?v=$videoId" - - val info = StreamInfo.getInfo(watchUrl) - - val isLive = - info.streamType == StreamType.LIVE_STREAM - || info.streamType == StreamType.AUDIO_LIVE_STREAM - || info.streamType == StreamType.POST_LIVE_STREAM - || info.streamType == StreamType.POST_LIVE_AUDIO_STREAM - - if (isLive && info.hlsUrl != null) { - callback( - newExtractorLink( - source = name, - name = "YouTube Live", - url = info.hlsUrl - ) { - type = ExtractorLinkType.M3U8 - } - ) - } else { - processVideo(info, subtitleCallback, callback) - } - } - - private suspend fun processVideo( - info: StreamInfo, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ): Boolean { - - val videoStreams = info.videoOnlyStreams.orEmpty() - - if (videoStreams.isEmpty()) return false - - val audioStreams = info.audioStreams.orEmpty() - - videoStreams.forEach { video -> - - callback( - newExtractorLink( - source = name, - name = "YouTube ${normalizeCodec(video.codec)}", - url = video.content - ) { - quality = video.height - audioTracks = audioStreams.map { newAudioFile(it.content) } - } - ) - } - - - info.subtitles.forEach { subtitle -> - subtitleCallback( - newSubtitleFile( - lang = subtitle.displayLanguageName - ?: subtitle.languageTag - ?: "Unknown", - url = subtitle.content - ) - ) - } - - return true - } - - // ---------------- HELPERS ---------------- - - private fun extractYouTubeId(url: String): String { - val regex = Regex( - "(?:youtu\\.be/|youtube(?:-nocookie)?\\.com/(?:.*v=|v/|u/\\w/|embed/|shorts/|live/))([\\w-]{11})" - ) - return regex.find(url)?.groupValues?.get(1) - ?: throw IllegalArgumentException("Invalid YouTube URL: $url") - } - - private fun normalizeCodec(codec: String?): String { - if (codec.isNullOrBlank()) return "" - - val c = codec.lowercase() - - return when { - c.startsWith("av01") -> "AV1" - c.startsWith("vp9") -> "VP9" - c.startsWith("avc1") || c.startsWith("h264") -> "H264" - c.startsWith("hev1") || c.startsWith("hvc1") || c.startsWith("hevc") -> "H265" - else -> codec.substringBefore('.').uppercase() - } - } -} \ No newline at end of file diff --git a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.jvm.kt b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.jvm.kt new file mode 100644 index 00000000000..345aa7185f0 --- /dev/null +++ b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.jvm.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.newAudioFile +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamType + +actual open class YoutubeExtractor actual constructor() : ExtractorApi() { + + actual override val mainUrl = "https://www.youtube.com" + actual override val name = "YouTube" + actual override val requiresReferer = false + + actual override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val videoId = extractYouTubeId(url) + val watchUrl = "$mainUrl/watch?v=$videoId" + + val info = StreamInfo.getInfo(watchUrl) + val isLive = info.streamType == StreamType.LIVE_STREAM + || info.streamType == StreamType.AUDIO_LIVE_STREAM + || info.streamType == StreamType.POST_LIVE_STREAM + || info.streamType == StreamType.POST_LIVE_AUDIO_STREAM + + if (isLive && info.hlsUrl != null) { + callback( + newExtractorLink( + source = name, + name = "YouTube Live", + url = info.hlsUrl + ) { + type = ExtractorLinkType.M3U8 + } + ) + } else { + processVideo(info, subtitleCallback, callback) + } + } + + private suspend fun processVideo( + info: StreamInfo, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ): Boolean { + val videoStreams = info.videoOnlyStreams.orEmpty() + if (videoStreams.isEmpty()) return false + + val audioStreams = info.audioStreams.orEmpty() + videoStreams.forEach { video -> + callback( + newExtractorLink( + source = name, + name = "YouTube ${normalizeCodec(video.codec)}", + url = video.content + ) { + quality = video.height + audioTracks = audioStreams.map { newAudioFile(it.content) } + } + ) + } + + info.subtitles.forEach { subtitle -> + subtitleCallback( + newSubtitleFile( + lang = subtitle.displayLanguageName + ?: subtitle.languageTag + ?: "Unknown", + url = subtitle.content + ) + ) + } + + return true + } + + private fun extractYouTubeId(url: String): String { + val regex = Regex( + "(?:youtu\\.be/|youtube(?:-nocookie)?\\.com/(?:.*v=|v/|u/\\w/|embed/|shorts/|live/))([\\w-]{11})" + ) + + return regex.find(url)?.groupValues?.get(1) + ?: throw IllegalArgumentException("Invalid YouTube URL: $url") + } + + private fun normalizeCodec(codec: String?): String { + if (codec.isNullOrBlank()) return "" + val c = codec.lowercase() + return when { + c.startsWith("av01") -> "AV1" + c.startsWith("vp9") -> "VP9" + c.startsWith("avc1") || c.startsWith("h264") -> "H264" + c.startsWith("hev1") || c.startsWith("hvc1") || c.startsWith("hevc") -> "H265" + else -> codec.substringBefore('.').uppercase() + } + } +}