From 274943c1a67327f6e33660f0a524b6f71c6dd55c Mon Sep 17 00:00:00 2001 From: fire-light43 <282604282+fire-light43@users.noreply.github.com> Date: Tue, 12 May 2026 16:25:14 +0000 Subject: [PATCH 01/64] shared buffer to decrease alloc --- .../cloudstream3/utils/downloader/DownloadManager.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index d209d544b..7cb190667 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -804,6 +804,7 @@ object VideoDownloadManager { private suspend fun resolve( startByte: Long, endByte: Long?, + buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Long = withContext(Dispatchers.IO) { var currentByte: Long = startByte @@ -822,7 +823,6 @@ object VideoDownloadManager { ) val requestStream = request.body.byteStream() - val buffer = ByteArray(bufferSize) var read: Int try { @@ -853,6 +853,7 @@ object VideoDownloadManager { suspend fun resolveSafe( index: Int, retries: Int = 3, + buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Boolean { var start = chuckStartByte.getOrNull(index) ?: return false @@ -861,7 +862,7 @@ object VideoDownloadManager { for (i in 0 until retries) { try { // in case - start = resolve(start, end, callback) + start = resolve(start, end, buffer, callback) // no end defined, so we don't care exactly where it ended if (end == null) return true // we have download more or exactly what we needed @@ -1158,7 +1159,10 @@ object VideoDownloadManager { } } - // this will take up the first available job and resolve + // Reuse a download buffer to decrease unnecessary alloc + val buffer = ByteArray(items.bufferSize) + + // This will take up the first available job and resolve while (true) { if (!isActive) return@launch @@ -1188,7 +1192,7 @@ object VideoDownloadManager { // in case something has gone wrong set to failed if the fail is not caused by // user cancellation - if (!items.resolveSafe(index, callback = callback)) { + if (!items.resolveSafe(index, buffer = buffer, callback = callback)) { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed From 9a53e267acd106c72121b121d398bc435254df8d Mon Sep 17 00:00:00 2001 From: Alvin <89844706+IstarVin@users.noreply.github.com> Date: Mon, 18 May 2026 07:29:59 +0800 Subject: [PATCH 02/64] fix: only return subtitle if not null (#2794) --- .../com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 2dfd5ef4d..17bef3ec0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -1732,11 +1732,11 @@ class GeneratorPlayer : FullScreenPlayer() { ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null if (downloads) { - return sortSubs(subtitles).firstOrNull { + sortSubs(subtitles).firstOrNull { it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( langCode ) - } + }?.let { return it } } if (!settings) return null From ab857376378e5607696bffdb97c6f0f948e86fb5 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 18 May 2026 16:35:44 -0600 Subject: [PATCH 03/64] [skip ci] TraktProvider: use text rather than toString for app.get (#2804) toString is just an alias to text at the moment, but isn't really clear, and isn't really what it is meant for. --- .../com/lagradost/cloudstream3/metaproviders/TraktProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index 63f6d564c..8f921d75c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -289,7 +289,7 @@ open class TraktProvider : MainAPI() { "trakt-api-version" to "2", "trakt-api-key" to traktClientId, ) - ).toString() + ).text } private fun isUpcoming(dateString: String?): Boolean { @@ -455,4 +455,4 @@ open class TraktProvider : MainAPI() { @JsonProperty("is_bollywood") val isBollywood: Boolean = false, @JsonProperty("is_cartoon") val isCartoon: Boolean = false, ) -} \ No newline at end of file +} From 89cc63673b7d5596b6a708e26bdf8dbb3d5531fa Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 18 May 2026 17:00:10 -0600 Subject: [PATCH 04/64] Remove okhttp3.HttpUrl version of loadImage (#2790) It is currently unused and at some point we will want to move coil to use ktor and fully phase out the dependency on okhttp so we don't an unnecessary extra dependency on it. --- .../com/lagradost/cloudstream3/utils/ImageModuleCoil.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt index 9d5c75289..62cf1b996 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -27,7 +27,6 @@ import coil3.util.DebugLogger import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.network.buildDefaultClient -import okhttp3.HttpUrl import okio.Path.Companion.toOkioPath import java.io.File import java.nio.ByteBuffer @@ -138,12 +137,6 @@ object ImageLoader { builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) - fun ImageView.loadImage( - imageData: HttpUrl?, - headers: Map? = null, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) - fun ImageView.loadImage( imageData: File?, builder: ImageRequest.Builder.() -> Unit = {} From a24dc2600e2da74002586b27c24a880e1cebc036 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 18 May 2026 17:06:07 -0600 Subject: [PATCH 05/64] JsHunter/JsUnpacker: use Kotlin-native regex (#2803) --- .../lagradost/cloudstream3/utils/JsHunter.kt | 28 ++-- .../cloudstream3/utils/JsUnpacker.kt | 140 +++++------------- 2 files changed, 48 insertions(+), 120 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt index 1ec442d7b..8aabeb1f1 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt @@ -1,11 +1,9 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.mvvm.logError -import java.util.regex.Matcher -import java.util.regex.Pattern import kotlin.math.pow -//author: https://github.com/daarkdemon +// author: https://github.com/daarkdemon class JsHunter(private val hunterJS: String) { /** @@ -14,9 +12,8 @@ class JsHunter(private val hunterJS: String) { * @return true if it's H.U.N.T.E.R coded. */ fun detect(): Boolean { - val p = Pattern.compile("eval\\(function\\(h,u,n,t,e,r\\)") - val searchResults = p.matcher(hunterJS) - return searchResults.find() + val regex = Regex("eval\\(function\\(h,u,n,t,e,r\\)") + return regex.containsMatchIn(hunterJS) } /** @@ -24,20 +21,15 @@ class JsHunter(private val hunterJS: String) { * * @return the javascript unhunt or null. */ - fun dehunt(): String? { try { - val p: Pattern = - Pattern.compile( - """\}\("([^"]+)",[^,]+,\s*"([^"]+)",\s*(\d+),\s*(\d+)""", - Pattern.DOTALL - ) - val searchResults: Matcher = p.matcher(hunterJS) - if (searchResults.find() && searchResults.groupCount() == 4) { - val h = searchResults.group(1)!! - val n = searchResults.group(2)!! - val t = searchResults.group(3)!!.toInt() - val e = searchResults.group(4)!!.toInt() + val regex = Regex("""\}\("([^"]+)",[^,]+,\s*"([^"]+)",\s*(\d+),\s*(\d+)""", RegexOption.DOT_MATCHES_ALL) + val match = regex.find(hunterJS) + if (match != null && match.groupValues.size == 5) { + val h = match.groupValues[1] + val n = match.groupValues[2] + val t = match.groupValues[3].toInt() + val e = match.groupValues[4].toInt() return hunter(h, n, t, e) } } catch (e: Exception) { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt index 7ed2e9be2..2dbbfddb0 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt @@ -1,7 +1,6 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.mvvm.logError -import java.util.regex.Pattern import kotlin.math.pow // https://github.com/cylonu87/JsUnpacker @@ -15,9 +14,7 @@ class JsUnpacker(packedJS: String?) { */ fun detect(): Boolean { val js = packedJS!!.replace(" ", "") - val p = Pattern.compile("eval\\(function\\(p,a,c,k,e,[rd]") - val m = p.matcher(js) - return m.find() + return Regex("eval\\(function\\(p,a,c,k,e,[rd]").containsMatchIn(js) } /** @@ -28,41 +25,42 @@ class JsUnpacker(packedJS: String?) { fun unpack(): String? { val js = packedJS ?: return null try { - var p = - Pattern.compile("""\}\s*\('(.*)',\s*(.*?),\s*(\d+),\s*'(.*?)'\.split\('\|'\)""", Pattern.DOTALL) - var m = p.matcher(js) - if (m.find() && m.groupCount() == 4) { - val payload = m.group(1)?.replace("\\'", "'") ?: "" - val radixStr = m.group(2) - val countStr = m.group(3) - val symtab = (m.group(4)?.split("\\|".toRegex()) ?: emptyList()).toTypedArray() + val match = Regex( + """\}\s*\('(.*)',\s*(.*?),\s*(\d+),\s*'(.*?)'\.split\('\|'\)""", + RegexOption.DOT_MATCHES_ALL + ).find(js) + if (match != null && match.groupValues.size == 5) { + val payload = match.groupValues[1].replace("\\'", "'") + val radixStr = match.groupValues[2] + val countStr = match.groupValues[3] + val symtab = match.groupValues[4].split("|").toTypedArray() var radix = 36 var count = 0 try { - radix = radixStr?.toIntOrNull() ?: radix + radix = radixStr.toIntOrNull() ?: radix } catch (_: Exception) { } try { - count = countStr?.toIntOrNull() ?: 0 + count = countStr.toIntOrNull() ?: 0 } catch (_: Exception) { } if (symtab.size != count) { throw Exception("Unknown p.a.c.k.e.r. encoding") } val unbase = Unbase(radix) - p = Pattern.compile("""\b[a-zA-Z0-9_]+\b""") - m = p.matcher(payload) + val wordRegex = Regex("""\b[a-zA-Z0-9_]+\b""") val decoded = StringBuilder(payload) var replaceOffset = 0 - while (m.find()) { - val word = m.group(0) - val x = if (word == null) 0 else unbase.unbase(word) - var value: String? = null - if (x < symtab.size && x >= 0) { - value = symtab[x] - } - if (!value.isNullOrEmpty() && !word.isNullOrEmpty()) { - decoded.replace(m.start() + replaceOffset, m.end() + replaceOffset, value) + wordRegex.findAll(payload).forEach { wordMatch -> + val word = wordMatch.value + val x = unbase.unbase(word) + val value = if (x in symtab.indices) symtab[x] else null + if (!value.isNullOrEmpty()) { + decoded.replace( + wordMatch.range.first + replaceOffset, + wordMatch.range.last + 1 + replaceOffset, + value + ) replaceOffset += value.length - word.length } } @@ -80,6 +78,7 @@ class JsUnpacker(packedJS: String?) { " !\"#$%&\\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" private var alphabet: String? = null private var dictionary: HashMap? = null + fun unbase(str: String): Int { var ret = 0 if (alphabet == null) { @@ -96,18 +95,10 @@ class JsUnpacker(packedJS: String?) { init { if (radix > 36) { when { - radix < 62 -> { - alphabet = ALPHABET_62.substring(0, radix) - } - radix in 63..94 -> { - alphabet = ALPHABET_95.substring(0, radix) - } - radix == 62 -> { - alphabet = ALPHABET_62 - } - radix == 95 -> { - alphabet = ALPHABET_95 - } + radix < 62 -> alphabet = ALPHABET_62.substring(0, radix) + radix in 63..94 -> alphabet = ALPHABET_95.substring(0, radix) + radix == 62 -> alphabet = ALPHABET_62 + radix == 95 -> alphabet = ALPHABET_95 } dictionary = HashMap(95) for (i in 0 until alphabet!!.length) { @@ -124,74 +115,20 @@ class JsUnpacker(packedJS: String?) { this.packedJS = packedJS } - companion object { - val c = - listOf( - 0x63, - 0x6f, - 0x6d, - 0x2e, - 0x67, - 0x6f, - 0x6f, - 0x67, - 0x6c, - 0x65, - 0x2e, - 0x61, - 0x6e, - 0x64, - 0x72, - 0x6f, - 0x69, - 0x64, - 0x2e, - 0x67, - 0x6d, - 0x73, - 0x2e, - 0x61, - 0x64, - 0x73, - 0x2e, - 0x4d, - 0x6f, - 0x62, - 0x69, - 0x6c, - 0x65, - 0x41, - 0x64, - 0x73 - ) - val z = - listOf( - 0x63, - 0x6f, - 0x6d, - 0x2e, - 0x66, - 0x61, - 0x63, - 0x65, - 0x62, - 0x6f, - 0x6f, - 0x6b, - 0x2e, - 0x61, - 0x64, - 0x73, - 0x2e, - 0x41, - 0x64 - ) + val c = listOf( + 0x63, 0x6f, 0x6d, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x61, 0x6e, 0x64, + 0x72, 0x6f, 0x69, 0x64, 0x2e, 0x67, 0x6d, 0x73, 0x2e, 0x61, 0x64, 0x73, 0x2e, 0x4d, + 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x41, 0x64, 0x73 + ) + val z = listOf( + 0x63, 0x6f, 0x6d, 0x2e, 0x66, 0x61, 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x2e, 0x61, + 0x64, 0x73, 0x2e, 0x41, 0x64 + ) fun String.load(): String? { return try { var load = this - for (q in c.indices) { if (c[q % 4] > 270) { load += c[q % 3] @@ -199,7 +136,6 @@ class JsUnpacker(packedJS: String?) { load += c[q].toChar() } } - Class.forName(load.substring(load.length - c.size, load.length)).name } catch (_: Exception) { try { @@ -207,7 +143,7 @@ class JsUnpacker(packedJS: String?) { for (w in z.indices) { f += z[w].toChar() } - return Class.forName(f.substring(0b001, f.length)).name + Class.forName(f.substring(0b001, f.length)).name } catch (_: Exception) { null } From 7e406cb5eb351ee3fbdc040e0a9eeb4ed543d282 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 18 May 2026 17:13:57 -0600 Subject: [PATCH 06/64] CryptoJS: replace array copies with Kotlin stdlib equivalents (#2799) * Remove use of java.util.Arrays * Remove unused import * Replace more --- .../extractors/helper/CryptoJSHelper.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt index af59b6f7d..6ad0524e8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt @@ -2,13 +2,11 @@ package com.lagradost.cloudstream3.extractors.helper import com.lagradost.cloudstream3.base64DecodeArray import com.lagradost.cloudstream3.base64Encode -import java.util.Arrays import java.security.MessageDigest import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.IvParameterSpec -import java.nio.charset.StandardCharsets import kotlin.math.min /** @@ -48,9 +46,9 @@ object CryptoJS { // Create CryptoJS-like encrypted! val sBytes = APPEND.toByteArray() val b = ByteArray(sBytes.size + saltBytes.size + cipherText.size) - System.arraycopy(sBytes, 0, b, 0, sBytes.size) - System.arraycopy(saltBytes, 0, b, sBytes.size, saltBytes.size) - System.arraycopy(cipherText, 0, b, sBytes.size + saltBytes.size, cipherText.size) + sBytes.copyInto(destination = b, destinationOffset = 0) + saltBytes.copyInto(destination = b, destinationOffset = sBytes.size) + cipherText.copyInto(destination = b, destinationOffset = sBytes.size + saltBytes.size) return base64Encode(b) } @@ -63,8 +61,8 @@ object CryptoJS { */ fun decrypt(password: String, cipherText: String): String { val ctBytes = base64DecodeArray(cipherText) - val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16) - val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size) + val saltBytes = ctBytes.copyOfRange(8, 16) + val cipherTextBytes = ctBytes.copyOfRange(16, ctBytes.size) val key = ByteArray(KEY_SIZE / 8) val iv = ByteArray(IV_SIZE / 8) @@ -107,16 +105,18 @@ object CryptoJS { hash.reset() } - System.arraycopy( - block!!, 0, derivedBytes, numberOfDerivedWords * 4, - min(block.size, (targetKeySize - numberOfDerivedWords) * 4) + block!!.copyInto( + destination = derivedBytes, + destinationOffset = numberOfDerivedWords * 4, + startIndex = 0, + endIndex = min(block.size, (targetKeySize - numberOfDerivedWords) * 4) ) numberOfDerivedWords += block.size / 4 } - System.arraycopy(derivedBytes, 0, resultKey, 0, keySize * 4) - System.arraycopy(derivedBytes, keySize * 4, resultIv, 0, ivSize * 4) + derivedBytes.copyInto(destination = resultKey, destinationOffset = 0, startIndex = 0, endIndex = keySize * 4) + derivedBytes.copyInto(destination = resultIv, destinationOffset = 0, startIndex = keySize * 4, endIndex = (keySize * 4) + (ivSize * 4)) return derivedBytes // key + iv } @@ -126,4 +126,4 @@ object CryptoJS { SecureRandom().nextBytes(this) } } -} \ No newline at end of file +} From 9bc5027ea71aa7d7b1bd645f77e21e57eaca8af0 Mon Sep 17 00:00:00 2001 From: fire-light43 <282604282+fire-light43@users.noreply.github.com> Date: Tue, 19 May 2026 01:19:16 +0200 Subject: [PATCH 07/64] shared buffer to decrease alloc (#2787) --- .../cloudstream3/utils/downloader/DownloadManager.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index d209d544b..7cb190667 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -804,6 +804,7 @@ object VideoDownloadManager { private suspend fun resolve( startByte: Long, endByte: Long?, + buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Long = withContext(Dispatchers.IO) { var currentByte: Long = startByte @@ -822,7 +823,6 @@ object VideoDownloadManager { ) val requestStream = request.body.byteStream() - val buffer = ByteArray(bufferSize) var read: Int try { @@ -853,6 +853,7 @@ object VideoDownloadManager { suspend fun resolveSafe( index: Int, retries: Int = 3, + buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Boolean { var start = chuckStartByte.getOrNull(index) ?: return false @@ -861,7 +862,7 @@ object VideoDownloadManager { for (i in 0 until retries) { try { // in case - start = resolve(start, end, callback) + start = resolve(start, end, buffer, callback) // no end defined, so we don't care exactly where it ended if (end == null) return true // we have download more or exactly what we needed @@ -1158,7 +1159,10 @@ object VideoDownloadManager { } } - // this will take up the first available job and resolve + // Reuse a download buffer to decrease unnecessary alloc + val buffer = ByteArray(items.bufferSize) + + // This will take up the first available job and resolve while (true) { if (!isActive) return@launch @@ -1188,7 +1192,7 @@ object VideoDownloadManager { // in case something has gone wrong set to failed if the fail is not caused by // user cancellation - if (!items.resolveSafe(index, callback = callback)) { + if (!items.resolveSafe(index, buffer = buffer, callback = callback)) { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed From 862e2590d2dcf63d0a3f9603db7dc74b8d4dd777 Mon Sep 17 00:00:00 2001 From: Abodabodd <122629521+Abodabodd@users.noreply.github.com> Date: Tue, 19 May 2026 19:45:58 +0300 Subject: [PATCH 08/64] Update StreamWishExtractor.kt (#2770) --- .../cloudstream3/extractors/StreamWishExtractor.kt | 8 +++++++- .../com/lagradost/cloudstream3/utils/ExtractorApi.kt | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt index c721db6b9..58aa25c8c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.api.Log +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app @@ -12,7 +13,6 @@ import com.lagradost.cloudstream3.utils.getAndUnpack import com.lagradost.cloudstream3.utils.getPacked import com.lagradost.cloudstream3.network.WebViewResolver - class Mwish : StreamWishExtractor() { override val name = "Mwish" override val mainUrl = "https://mwish.pro" @@ -28,6 +28,12 @@ class Ewish : StreamWishExtractor() { override val mainUrl = "https://embedwish.com" } +@Prerelease +class Hgcloudto : StreamWishExtractor() { + override val name = "Hgcloud" + override val mainUrl = "https://Hgcloud.to" +} + class WishembedPro : StreamWishExtractor() { override val name = "Wishembed" override val mainUrl = "https://wishembed.pro" diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index ab80cf2ca..45e2ba71e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -106,6 +106,7 @@ import com.lagradost.cloudstream3.extractors.HDPlayerSystem import com.lagradost.cloudstream3.extractors.HDStreamAble import com.lagradost.cloudstream3.extractors.Habetar import com.lagradost.cloudstream3.extractors.Haxloppd +import com.lagradost.cloudstream3.extractors.Hgcloudto import com.lagradost.cloudstream3.extractors.HglinkTo import com.lagradost.cloudstream3.extractors.HgplayCDN import com.lagradost.cloudstream3.extractors.Hotlinger @@ -1194,6 +1195,7 @@ val extractorApis: MutableList = arrayListOf( MetaGnathTuggers(), Geodailymotion(), Mwish(), + Hgcloudto(), Dwish(), Ewish(), Kswplayer(), From a6000fbe04a1b2063b3fef37827844c5d9decc2f Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 21 May 2026 15:09:13 -0600 Subject: [PATCH 09/64] [skip ci] AppDebug: use kotlin.concurrent.Volatile (#2818) By default this uses kotlin.jvm.Volatile, which we should be using kotlin.concurrent.Volatile instead. --- .../kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt index e07f32c0a..180aee11a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.InternalAPI +import kotlin.concurrent.Volatile @InternalAPI object AppDebug { From 0f41ca2641017e02a7ce911f068f03d974d1d92f Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 21 May 2026 15:17:03 -0600 Subject: [PATCH 10/64] [skip ci] Don't pass locale to titlecase in String.capitalize (#2810) That is the default anyway. --- .../commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index c590165a1..6bb11ee34 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -27,7 +27,9 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody import java.net.URI import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.EnumSet +import java.util.Locale import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue @@ -97,7 +99,7 @@ object APIHolder { /** String extension function to Capitalize first char of string.*/ fun String.capitalize(): String { - return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } var apis: List = threadSafeListOf() From 638d749945acd8edde4138a2f32bf964a44da75e Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 21 May 2026 15:29:33 -0600 Subject: [PATCH 11/64] [skip ci] Remove usage of junit within the app itself (#2820) When I was testing compose, I realized that this causes issues where junit was wiring up instrumentation within the app, which overrode and conflicted with compose resource context. We don't need it as a dependency for just TestingUtils, so this refsctors it to just use AssertionError directly. --- app/build.gradle.kts | 4 +-- .../cloudstream3/utils/TestingUtils.kt | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae5301929..1b1aefab2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -206,9 +206,9 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) - implementation(libs.junit.ktx) - androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.junit.ktx) // Android Core & Lifecycle implementation(libs.core.ktx) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index 91c8a2fc1..8c50afee7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -3,10 +3,10 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.mvvm.logError import kotlinx.coroutines.* -import org.junit.Assert import kotlin.random.Random object TestingUtils { + open class TestResult(val success: Boolean) { companion object { val Pass = TestResult(true) @@ -48,6 +48,10 @@ object TestingUtils { messageLog.add(Message(LogLevel.Error, message)) } } + + private fun fail(message: String): Nothing = throw AssertionError(message) + private fun assertTrue(message: String, condition: Boolean) { if (!condition) fail(message) } + private fun assertNotNull(message: String, value: Any?) { if (value == null) fail(message) } class TestResultList(val results: List) : TestResult(true) class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true) @@ -87,7 +91,7 @@ object TestingUtils { } catch (e: Throwable) { when (e) { is NotImplementedError -> { - Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") + fail("Provider marked as hasMainPage, while in reality is has not been implemented") } is CancellationException -> { @@ -115,7 +119,7 @@ object TestingUtils { api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } } catch (e: Throwable) { if (e is NotImplementedError) { - Assert.fail("Provider has not implemented search()") + fail("Provider has not implemented search()") } else if (e is CancellationException) { throw e } @@ -125,7 +129,7 @@ object TestingUtils { } return if (searchResults.isNullOrEmpty()) { - Assert.fail("Api ${api.name} did not return any search responses") + fail("Api ${api.name} did not return any search responses") TestResult.Fail // Should not be reached } else { TestResultList(searchResults) @@ -216,7 +220,7 @@ object TestingUtils { // return TestResult(validResults) } catch (e: Throwable) { if (e is NotImplementedError) { - Assert.fail("Provider has not implemented load()") + fail("Provider has not implemented load()") } throw e } @@ -228,14 +232,14 @@ object TestingUtils { url: String?, logger: Logger ): TestResult { - Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) + assertNotNull("Api ${api.name} has invalid url on episode", url) if (url == null) return TestResult.Fail // Should never trigger var linksLoaded = 0 try { val success = api.loadLinks(url, false, {}) { link -> logger.log("Video loaded: ${link.name}") - Assert.assertTrue( + assertTrue( "Api ${api.name} returns link with invalid url ${link.url}", link.url.length > 4 ) @@ -245,12 +249,12 @@ object TestingUtils { logger.log("Links loaded: $linksLoaded") return TestResult(linksLoaded > 0) } else { - Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") + fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") } } catch (e: Throwable) { when (e) { is NotImplementedError -> { - Assert.fail("Provider has not implemented loadLinks()") + fail("Provider has not implemented loadLinks()") } else -> { @@ -276,7 +280,7 @@ object TestingUtils { // Test Homepage val homepage = testHomepage(api, logger) - Assert.assertTrue("Homepage failed to load", homepage.success) + assertTrue("Homepage failed to load", homepage.success) val homePageList = (homepage as? TestResultList)?.results ?: emptyList() // Test Search Results @@ -287,7 +291,7 @@ object TestingUtils { listOf("over", "iron", "guy")).take(3) val searchResults = testSearch(api, searchQueries, logger) - Assert.assertTrue("Failed to get search results", searchResults.success) + assertTrue("Failed to get search results", searchResults.success) searchResults as TestResultList // Test Load and LoadLinks @@ -321,4 +325,4 @@ object TestingUtils { } } } -} \ No newline at end of file +} From 419b902eadc2cdd0aa66e0e177891e450993fd1b Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 21 May 2026 15:45:04 -0600 Subject: [PATCH 12/64] [skip ci] Use this::class rather than javaClass in MainAPI (#2809) --- .../src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 6bb11ee34..0200fda4a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -474,7 +474,7 @@ abstract class MainAPI { } fun init() { - overrideData?.get(this.javaClass.simpleName)?.let { data -> + overrideData?.get(this::class.simpleName)?.let { data -> overrideWithNewData(data) } } From 72386cb98cdb96a9aa15eeb1aef7a7c8853d5620 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 21 May 2026 16:01:46 -0600 Subject: [PATCH 13/64] [skip ci] HlsPlaylistParser: don't use java.lang.StringBuilder directly (#2811) Just using StringBuilder will allow it to use kotlin.text.StringBuilder from Kotlin instead, which it already does in some places, making using java.lang.StringBuilder in here very inconsistent with other parts of the same class. --- .../com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt index 01e5bb862..bea75aa58 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt @@ -234,7 +234,7 @@ object HlsPlaylistParser { if (codecArray.isEmpty()) { return null } - val builder = java.lang.StringBuilder() + val builder = StringBuilder() for (codec in codecArray) { if (trackType == MimeTypes.getTrackTypeOfCodec(codec)) { if (builder.isNotEmpty()) { @@ -263,7 +263,7 @@ object HlsPlaylistParser { if (codecArray.isEmpty()) { return null } - val builder = java.lang.StringBuilder() + val builder = StringBuilder() for (codec in codecArray) { if (trackType != MimeTypes.getTrackTypeOfCodec(codec)) { if (builder.isNotEmpty()) { @@ -425,7 +425,7 @@ object HlsPlaylistParser { * @param limit The limit (exclusive) of the path in `uri`. */ private fun removeDotSegments( - uri: java.lang.StringBuilder, + uri: StringBuilder, offset: Int, limit: Int ): String { From f894b8f7ecdd9f78f338681087cd4aca73296bfe Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 22 May 2026 16:53:46 -0600 Subject: [PATCH 14/64] SubtitleHelper: replace usage of java.lang.Character (#2817) --- .../cloudstream3/utils/SubtitleHelper.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt index 8d5479cc0..7becf4d19 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt @@ -315,10 +315,23 @@ object SubtitleHelper { val flagOffset = 0x1F1E6 // regional indicator "[A]" val offset = flagOffset - asciiOffset - val firstChar: Int = Character.codePointAt(countryLetters, 0) + offset - val secondChar: Int = Character.codePointAt(countryLetters, 1) + offset + /** + * Unicode surrogate pairs encode code points above U+FFFF (outside the Basic Multilingual Plane). + * The code point is offset by 0x10000, then split into two 10-bit halves: + * high surrogate: upper 10 bits, biased into the range 0xD800-0xDBFF + * low surrogate: lower 10 bits (masked with 0x3FF), biased into the range 0xDC00-0xDFFF + */ + fun toSurrogatePair(codePoint: Int): String { + val high = ((codePoint - 0x10000) shr 10) + 0xD800 + val low = ((codePoint - 0x10000) and 0x3FF) + 0xDC00 + return "${high.toChar()}${low.toChar()}" + } - return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar)) + val upperLetters = countryLetters.uppercase() + val first = upperLetters[0].code + offset + val second = upperLetters[1].code + offset + + return toSurrogatePair(first) + toSurrogatePair(second) } // when (langTag = country) or (langTag contains country) From 4ab97e4605d1fceca47438aa893d6539ac57b1b0 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Sat, 23 May 2026 18:02:53 +0200 Subject: [PATCH 15/64] Delete issue workflow to prevent security issues --- .github/workflows/issue_action.yml | 98 ------------------------------ 1 file changed, 98 deletions(-) delete mode 100644 .github/workflows/issue_action.yml diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml deleted file mode 100644 index e354d657d..000000000 --- a/.github/workflows/issue_action.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Issue automatic actions - -on: - issues: - types: [opened] - -permissions: - contents: read - issues: write - -jobs: - issue-moderator: - runs-on: ubuntu-latest - steps: - - name: Generate access token - id: generate_token - uses: tibdex/github-app-token@v2 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - - - name: Similarity analysis - id: similarity - uses: actions-cool/issues-similarity-analysis@v1 - with: - token: ${{ steps.generate_token.outputs.token }} - filter-threshold: 0.60 - title-excludes: '' - comment-title: | - ### Your issue looks similar to these issues: - Please close if duplicate. - comment-body: '${index}. ${similarity} #${number}' - - - name: Label if possible duplicate - if: steps.similarity.outputs.similar-issues-found =='true' - uses: actions/github-script@v9 - with: - github-token: ${{ steps.generate_token.outputs.token }} - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ["possible duplicate"] - }) - - - uses: actions/checkout@v6 - - - name: Automatically close issues that dont follow the issue template - uses: lucasbento/auto-close-issues@v1.0.2 - with: - github-token: ${{ steps.generate_token.outputs.token }} - issue-close-message: | - @${issue.user.login}: hello! :wave: - This issue is being automatically closed because it does not follow the issue template." - closed-issues-label: "invalid" - - - name: Check if issue mentions a provider - id: provider_check - env: - GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}" - run: | - wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py" - pip3 install httpx - RES="$(python3 ./check_issue.py)" - echo "name=${RES}" >> $GITHUB_OUTPUT - - - name: Comment if issue mentions a provider - if: steps.provider_check.outputs.name != 'none' - uses: actions-cool/issues-helper@v3 - with: - actions: 'create-comment' - token: ${{ steps.generate_token.outputs.token }} - body: | - Hello ${{ github.event.issue.user.login }}. - Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). - - Found provider name: `${{ steps.provider_check.outputs.name }}` - - - name: Label if mentions provider - if: steps.provider_check.outputs.name != 'none' - uses: actions/github-script@v9 - with: - github-token: ${{ steps.generate_token.outputs.token }} - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ["possible provider issue"] - }) - - - name: Add eyes reaction to all issues - uses: actions-cool/emoji-helper@v1.0.0 - with: - type: 'issue' - token: ${{ steps.generate_token.outputs.token }} - emoji: 'eyes' From ac0a0d294199862037f8f61218280ee25cfcbfd7 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 23 May 2026 14:10:21 -0600 Subject: [PATCH 16/64] [skip ci] Replace Integer.parseInt with Kotlin-native equivalent (#2827) --- .../cloudstream3/extractors/OdnoklassnikiExtractor.kt | 2 +- .../kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt index 8af77c1df..69c3b7759 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt @@ -32,7 +32,7 @@ open class Odnoklassniki : ExtractorApi() { val embedUrl = url.replace("/video/","/videoembed/") val videoReq = app.get(embedUrl, headers=headers).text.replace("\\"", "\"").replace("\\\\", "\\") .replace(Regex("\\\\u([0-9A-Fa-f]{4})")) { matchResult -> - Integer.parseInt(matchResult.groupValues[1], 16).toChar().toString() + matchResult.groupValues[1].toInt(16).toChar().toString() } val videosStr = Regex(""""videos":(\[[^]]*])""").find(videoReq)?.groupValues?.get(1) ?: throw ErrorLoadingException("Video not found") val videos = AppUtils.tryParseJson>(videosStr) ?: throw ErrorLoadingException("Video not found") diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt index 5c871b54b..49560d456 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt @@ -38,13 +38,13 @@ class Vidsonic() : ExtractorApi() { .substringBefore(";") .replace("'", "") - // (improved) Java implementation of the JavaScript code from above + // (improved) Kotlin implementation of the JavaScript code from above val streamUrl = encodedStreamUrl .replace("|", "") // always two base16 digits together build one ASCII char .chunked(2) .map { - Integer.parseInt(it, 16).toChar() + it.toInt(16).toChar() } .joinToString("") .reversed() From dd016341c051dc0928ec4b62ebef6b733d32b8b6 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 23 May 2026 14:11:46 -0600 Subject: [PATCH 17/64] [skip ci] Replace ArrayList in extractor (#2826) --- .../kotlin/com/lagradost/cloudstream3/extractors/Userload.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt index 582be8afb..fad6e8571 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt @@ -6,8 +6,6 @@ import com.lagradost.cloudstream3.utils.* import org.mozilla.javascript.Context import org.mozilla.javascript.EvaluatorException import org.mozilla.javascript.Scriptable -import java.util.* - open class Userload : ExtractorApi() { override var name = "Userload" @@ -16,7 +14,7 @@ open class Userload : ExtractorApi() { private fun splitInput(input: String): List { var counter = 0 - val array = ArrayList() + val array = mutableListOf() var buffer = "" for (c in input) { when (c) { From 85cc10c2e0fe566b818f2b914a98d03be117f1b6 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 23 May 2026 14:20:50 -0600 Subject: [PATCH 18/64] [skip ci] Replace usage of String.format in library (#2819) --- .../com/lagradost/cloudstream3/extractors/Cda.kt | 3 +-- .../lagradost/cloudstream3/extractors/Gofile.kt | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt index fc155bdd9..e37c498f9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt @@ -14,7 +14,6 @@ open class Cda : ExtractorApi() { override var name = "Cda" override val requiresReferer = false - override suspend fun getUrl(url: String, referer: String?): List? { val mediaId = url .split("/").last() @@ -68,7 +67,7 @@ open class Cda : ExtractorApi() { a = URLDecoder.decode(a, "UTF-8") a = a.map { char -> if (char.code in 33..126) { - return@map String.format("%c", 33 + (char.code + 14) % 94) + return@map (33 + (char.code + 14) % 94).toChar().toString() } else { return@map char } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt index ced827eec..2b42e42c7 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt @@ -8,6 +8,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink +import kotlin.math.round open class Gofile : ExtractorApi() { override val name = "Gofile" @@ -67,10 +68,19 @@ open class Gofile : ExtractorApi() { ?: Qualities.Unknown.value } + private fun roundTo2Decimals(value: Double): String { + val rounded = round(value * 100) / 100.0 + val intPart = rounded.toLong() + val decPart = round((rounded - intPart) * 100).toLong() + return "$intPart.${decPart.toString().padStart(2, '0')}" + } + private fun formatBytes(bytes: Long): String { + val mb = 1024L * 1024 + val gb = mb * 1024 return when { - bytes < 1024L * 1024 * 1024 -> "%.2f MB".format(bytes.toDouble() / (1024 * 1024)) - else -> "%.2f GB".format(bytes.toDouble() / (1024 * 1024 * 1024)) + bytes < gb -> "${roundTo2Decimals(bytes.toDouble() / mb)} MB" + else -> "${roundTo2Decimals(bytes.toDouble() / gb)} GB" } } From c1b49d0dcb7f2006760fc24a16339c320533e7d4 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 23 May 2026 14:34:13 -0600 Subject: [PATCH 19/64] [skip ci] Replace charset string conversions with Kotlin native equivalents (#2807) --- .../kotlin/com/lagradost/cloudstream3/MainAPI.kt | 11 +++++++++-- .../com/lagradost/cloudstream3/extractors/ByseSX.kt | 3 +-- .../lagradost/cloudstream3/extractors/Rabbitstream.kt | 3 +-- .../cloudstream3/extractors/RapidVidExtractor.kt | 4 ++-- .../cloudstream3/extractors/VidMoxyExtractor.kt | 4 ++-- .../com/lagradost/cloudstream3/extractors/Videa.kt | 8 ++++---- .../lagradost/cloudstream3/utils/HlsPlaylistParser.kt | 2 +- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 0200fda4a..fae2bf5e7 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -688,9 +688,16 @@ abstract class MainAPI { } } -/** Might need a different implementation for desktop*/ fun base64Decode(string: String): String { - return String(base64DecodeArray(string), Charsets.ISO_8859_1) + // ISO-8859-1 decoding: each byte maps directly to its Unicode code point (0-255), + // so we mask each byte to unsigned and convert to the corresponding Char manually. + // decodeToString() can't be used here as it assumes UTF-8. + val bytes = base64DecodeArray(string) + return buildString(bytes.size) { + for (b in bytes) { + append((b.toInt() and 0xFF).toChar()) + } + } } @OptIn(ExperimentalEncodingApi::class) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt index 38d35da2e..cc7293b80 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt @@ -9,7 +9,6 @@ import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper import java.net.URI -import java.nio.charset.StandardCharsets import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec @@ -94,7 +93,7 @@ open class ByseSX : ExtractorApi() { cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) val plainBytes = cipher.doFinal(cipherBytes) - var jsonStr = String(plainBytes, StandardCharsets.UTF_8) + var jsonStr = plainBytes.decodeToString() if (jsonStr.startsWith("\uFEFF")) jsonStr = jsonStr.substring(1) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt index 98598dd28..3f7408eb3 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt @@ -12,7 +12,6 @@ import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper -import java.nio.charset.StandardCharsets import java.security.MessageDigest import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec @@ -171,7 +170,7 @@ open class Rabbitstream : ExtractorApi() { IvParameterSpec(decryptionKey.copyOfRange(32, decryptionKey.size)) ) val decryptedData = aesCBC?.doFinal(encrypted) ?: throw ErrorLoadingException("Cipher not found") - return String(decryptedData, StandardCharsets.UTF_8) + return decryptedData.decodeToString() } data class Tracks( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt index 9654e5f38..822a5eed7 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt @@ -35,14 +35,14 @@ open class RapidVid : ExtractorApi() { if (extractedValue != null) { val bytes = extractedValue.split("\\x").filter { it.isNotEmpty() }.map { it.toInt(16).toByte() }.toByteArray() - decoded = String(bytes, Charsets.UTF_8) + decoded = bytes.decodeToString() } else { val evalJWSsetup = Regex("""\};\s*(eval\(function[\s\S]*?)var played = \d+;""").find(videoReq)?.groupValues?.get(1) ?: throw ErrorLoadingException("File not found") val JWSsetup = getAndUnpack(getAndUnpack(evalJWSsetup)).replace("\\\\", "\\") extractedValue = Regex("""file":"(.*)","label""").find(JWSsetup)?.groupValues?.get(1)?.replace("\\\\x", "") val bytes = extractedValue?.chunked(2)?.map { it.toInt(16).toByte() }?.toByteArray() - decoded = bytes?.toString(Charsets.UTF_8) ?: throw ErrorLoadingException("File not found") + decoded = bytes?.decodeToString() ?: throw ErrorLoadingException("File not found") } callback.invoke( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt index 36acf7f7a..a23f6b683 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt @@ -35,14 +35,14 @@ open class VidMoxy : ExtractorApi() { if (extractedValue != null) { val bytes = extractedValue.split("\\x").filter { it.isNotEmpty() }.map { it.toInt(16).toByte() }.toByteArray() - decoded = String(bytes, Charsets.UTF_8) + decoded = bytes.decodeToString() } else { val evaljwSetup = Regex("""\};\s*(eval\(function[\s\S]*?)var played = \d+;""").find(videoReq)?.groupValues?.get(1) ?: throw ErrorLoadingException("File not found") val jwSetup = getAndUnpack(getAndUnpack(evaljwSetup)).replace("\\\\", "\\") extractedValue = Regex("""file":"(.*)","label""").find(jwSetup)?.groupValues?.get(1)?.replace("\\\\x", "") val bytes = extractedValue?.chunked(2)?.map { it.toInt(16).toByte() }?.toByteArray() - decoded = bytes?.toString(Charsets.UTF_8) ?: throw ErrorLoadingException("File not found") + decoded = bytes?.decodeToString() ?: throw ErrorLoadingException("File not found") } callback.invoke( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt index 47840a08a..59d7a7f2e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt @@ -47,7 +47,7 @@ class Videa : ExtractorApi() { rawBytes[4] == 0x6C.toByte() // 'l' val videaXml = if (isXml) { - String(rawBytes, Charsets.UTF_8) + rawBytes.decodeToString() } else { // Handle encrypted XML response val xsHeader = response.headers["X-Videa-Xs"] ?: return @@ -179,7 +179,7 @@ class Videa : ExtractorApi() { } val actualEncryptedBytes = if (isBase64) { - val base64String = String(encryptedBytes, Charsets.UTF_8) + val base64String = encryptedBytes.decodeToString() .replace("\r", "") .replace("\n", "") .replace(" ", "") @@ -189,7 +189,7 @@ class Videa : ExtractorApi() { encryptedBytes } - val keyBytes = key.toByteArray(Charsets.UTF_8) + val keyBytes = key.encodeToByteArray() // RC4 key-scheduling algorithm (KSA) val s = IntArray(256) { it } @@ -211,6 +211,6 @@ class Videa : ExtractorApi() { result[k] = ((actualEncryptedBytes[k].toInt() and 0xFF) xor keyStreamByte).toByte() } - return String(result, Charsets.UTF_8) + return result.decodeToString() } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt index bea75aa58..898550b24 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt @@ -1187,7 +1187,7 @@ object HlsPlaylistParser { return SchemeData( uuid = C.WIDEVINE_UUID, mimeType = "hls", - data = line.toByteArray(charset = Charsets.UTF_8) + data = line.encodeToByteArray() ) } else if (KEYFORMAT_PLAYREADY == keyFormat && "1" == keyFormatVersions) { val uriString = parseStringAttr(line, REGEX_URI, variableDefinitions) From c6c70d5751ce02a662a1207da5354f4e01d32cfe Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 23 May 2026 16:43:32 -0600 Subject: [PATCH 20/64] [skip ci] Remove an unused import (#2830) --- .../kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt index 611711e39..2cb9a5e5d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt @@ -8,7 +8,6 @@ import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.JsUnpacker import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink -import java.net.URI open class Streamhub : ExtractorApi() { override var mainUrl = "https://streamhub.to" From 0b642bb47f0d942fc7512d287e5bc17b62f2f9b5 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 23 May 2026 16:44:31 -0600 Subject: [PATCH 21/64] [skip ci] Use StringUtils.decodeUri in a couple places (#2831) Gives us just one place to update the API when we get there --- .../kotlin/com/lagradost/cloudstream3/extractors/Cda.kt | 4 ++-- .../kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt index e37c498f9..4b7f8a1cd 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt @@ -6,8 +6,8 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.StringUtils.decodeUri import com.lagradost.cloudstream3.utils.newExtractorLink -import java.net.URLDecoder open class Cda : ExtractorApi() { override var mainUrl = "https://ebd.cda.pl" @@ -64,7 +64,7 @@ open class Cda : ExtractorApi() { .replace("_QWE", "") .replace("_Q5", "") .replace("_IKSDE", "") - a = URLDecoder.decode(a, "UTF-8") + a = a.decodeUri() a = a.map { char -> if (char.code in 33..126) { return@map (33 + (char.code + 14) % 94).toChar().toString() diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt index 206b0f29f..6d9862d3a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.base64Decode +import com.lagradost.cloudstream3.utils.StringUtils.decodeUri import com.lagradost.nicehttp.NiceResponse import java.net.URI -import java.net.URLDecoder // Code heavily based on unshortenit.py form kodiondemand /addon @@ -189,7 +189,7 @@ object ShortLink { } fun unshortenDavisonbarker(uri: String): String { - return URLDecoder.decode(uri.substringAfter("dest="), "UTF-8") + return uri.substringAfter("dest=").decodeUri() } suspend fun unshortenIsecure(uri: String): String { From 0afb23eb2e351016ca381c11e76c8a81b67576cc Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 24 May 2026 16:16:53 -0600 Subject: [PATCH 22/64] Bump material to 1.14.0 stable (#2752) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97145c3f..f0dedf709 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ kotlinGradlePlugin = "2.3.20" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" -material = "1.14.0-beta01" +material = "1.14.0" media3 = "1.9.3" navigationKtx = "2.9.7" newpipeextractor = "v0.26.0" From 00e943ebc485329aeb00df8908f797dc01fe69c3 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 24 May 2026 16:31:27 -0600 Subject: [PATCH 23/64] Replace synchronized usage in library with kotlinx-atomicfu (#2808) --- .../lagradost/cloudstream3/MainActivity.kt | 20 +++---- .../cloudstream3/actions/VideoClickAction.kt | 4 +- .../lagradost/cloudstream3/plugins/Plugin.kt | 7 +-- .../cloudstream3/plugins/PluginManager.kt | 33 ++++++----- .../cloudstream3/syncproviders/AuthAPI.kt | 1 - .../syncproviders/SubtitleRepo.kt | 30 ++++++---- .../cloudstream3/ui/APIRepository.kt | 20 ++++--- .../cloudstream3/ui/home/HomeViewModel.kt | 2 +- .../ui/library/LibraryFragment.kt | 15 +++-- .../ui/result/ResultViewModel2.kt | 13 ++-- .../cloudstream3/ui/search/SearchViewModel.kt | 4 +- .../ui/settings/SettingsGeneral.kt | 2 +- .../ui/settings/SettingsProviders.kt | 8 +-- .../ui/settings/testing/TestViewModel.kt | 14 ++--- .../ui/setup/SetupFragmentExtensions.kt | 2 +- .../ui/setup/SetupFragmentProviderLanguage.kt | 6 +- .../cloudstream3/utils/AppContextUtils.kt | 24 +------- .../lagradost/cloudstream3/utils/SyncUtil.kt | 6 +- gradle/libs.versions.toml | 2 + library/build.gradle.kts | 1 + .../network/WebViewResolver.android.kt | 4 +- .../com/lagradost/cloudstream3/MainAPI.kt | 30 ++++------ .../metaproviders/CrossTmdbProvider.kt | 6 +- .../cloudstream3/plugins/BasePlugin.kt | 9 +-- .../cloudstream3/utils/AtomicList.kt | 59 +++++++++++++++++++ .../cloudstream3/utils/Coroutines.kt | 17 ++++-- .../cloudstream3/utils/ExtractorApi.kt | 3 +- 27 files changed, 191 insertions(+), 151 deletions(-) create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AtomicList.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 8a98bd297..90583011d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -408,13 +408,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa return true } - synchronized(apis) { - for (api in apis) { - if (str.startsWith(api.mainUrl)) { - loadResult(str, api.name, "") - return true - } - } + val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() + if (matchedApi != null) { + loadResult(str, matchedApi.name, "") + return true } } } @@ -809,12 +806,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - private val pluginsLock = Mutex() private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { - synchronized(allProviders) { + allProviders.withLock { // Load cloned sites after plugins have been loaded since clones depend on plugins. try { getKey>(USER_PROVIDER_API)?.let { list -> @@ -1657,9 +1653,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) - apis = synchronized(allProviders) { - allProviders.distinctBy { it } - } + apis = allProviders.distinctBy { it } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -1967,7 +1961,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - synchronized(allProviders) { + allProviders.withLock { for (api in allProviders) { providersAndroidManifestString += " Unit)? = null -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index eae14a6c0..aff1a2f33 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -651,9 +651,15 @@ object PluginManager { context.resources.configuration ) } - plugins[filePath] = pluginInstance - classLoaders[loader] = pluginInstance - urlPlugins[data.url ?: filePath] = pluginInstance + synchronized(plugins) { + plugins[filePath] = pluginInstance + } + synchronized(classLoaders) { + classLoaders[loader] = pluginInstance + } + synchronized(urlPlugins) { + urlPlugins[data.url ?: filePath] = pluginInstance + } if (pluginInstance is Plugin) { pluginInstance.load(context) } else { @@ -689,21 +695,20 @@ object PluginManager { } // remove all registered apis - synchronized(APIHolder.apis) { - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { - removePluginMapping(it) - } - } - synchronized(APIHolder.allProviders) { - APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { + removePluginMapping(it) } - synchronized(extractorApis) { - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + APIHolder.allProviders.withLock { + APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename } } - synchronized(VideoClickActionHolder.allVideoClickActions) { - VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } + extractorApis.withLock { + extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename } + } + + VideoClickActionHolder.allVideoClickActions.withLock { + VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename } } synchronized(classLoaders) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 83a7a0984..9a1a441f1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -36,7 +36,6 @@ import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt index 7a93f96f6..0b8c3e5ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.SubtitleResource -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf /** Stateless safe abstraction of SubtitleAPI */ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { @@ -24,26 +24,30 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { ) // maybe make this a generic struct? right now there is a lot of boilerplate - private val searchCache = threadSafeListOf() + private val searchCache = atomicListOf() private var searchCacheIndex: Int = 0 - private val resourceCache = threadSafeListOf() + private val resourceCache = atomicListOf() private var resourceCacheIndex: Int = 0 const val CACHE_SIZE = 20 } @WorkerThread suspend fun resource(data: SubtitleEntity): Result = runCatching { - synchronized(resourceCache) { + val cached = resourceCache.withLock { + var found: SubtitleResource? = null for (item in resourceCache) { // 20 min save if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { - return@runCatching item.response + found = item.response + break } } + found } + if (cached != null) return@runCatching cached val returnValue = api.resource(freshAuth(), data) - synchronized(resourceCache) { + resourceCache.withLock { val add = SavedResourceResponse(unixTime, returnValue, data) if (resourceCache.size > CACHE_SIZE) { resourceCache[resourceCacheIndex] = add // rolling cache @@ -58,22 +62,25 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { @WorkerThread suspend fun search(query: SubtitleSearch): Result> { return runCatching { - synchronized(searchCache) { + val cached = searchCache.withLock { + var found: List? = null for (item in searchCache) { // 120 min save if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { - return@runCatching item.response + found = item.response + break } } + found } - val returnValue = - api.search(freshAuth(), query) ?: emptyList() + if (cached != null) return@runCatching cached + val returnValue = api.search(freshAuth(), query) ?: emptyList() // only cache valid return values if (returnValue.isNotEmpty()) { val add = SavedSearchResponse(unixTime, returnValue, query) - synchronized(searchCache) { + searchCache.withLock { if (searchCache.size > CACHE_SIZE) { searchCache[searchCacheIndex] = add // rolling cache searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE @@ -86,4 +93,3 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { } } } - diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 93a79689e..8ec082520 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -17,7 +17,7 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.newSearchResponseList -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async @@ -55,7 +55,7 @@ class APIRepository(val api: MainAPI) { val hash: Pair ) - private val cache = threadSafeListOf() + private val cache = atomicListOf() private var cacheIndex: Int = 0 const val CACHE_SIZE = 20 @@ -66,9 +66,7 @@ class APIRepository(val api: MainAPI) { private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { - synchronized(cache) { - cache.clear() - } + cache.clear() } } @@ -91,21 +89,25 @@ class APIRepository(val api: MainAPI) { val fixedUrl = api.fixUrl(url) val lookingForHash = Pair(api.name, fixedUrl) - synchronized(cache) { + val cached = cache.withLock { + var found: LoadResponse? = null for (item in cache) { // 10 min save if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { - return@withTimeout item.response + found = item.response + break } } + found } + if (cached != null) return@withTimeout cached api.load(fixedUrl)?.also { response -> // Remove all blank tags as early as possible response.tags = response.tags?.filter { it.isNotBlank() } val add = SavedLoadResponse(unixTime, response, lookingForHash) - synchronized(cache) { + cache.withLock { if (cache.size > CACHE_SIZE) { cache[cacheIndex] = add // rolling cache cacheIndex = (cacheIndex + 1) % CACHE_SIZE @@ -215,4 +217,4 @@ class APIRepository(val api: MainAPI) { return false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index e0609c0e5..8d48f5a68 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -133,7 +133,7 @@ class HomeViewModel : ViewModel() { private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { - return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) + return APIRepository(apis.withLock { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 6e28c128d..c5f8fa3d9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -210,14 +210,13 @@ class LibraryFragment : BaseFragment( syncId: SyncIdName, apiName: String? = null, ) { - val availableProviders = synchronized(allProviders) { - allProviders.filter { - it.supportedSyncNames.contains(syncId) - }.map { it.name } + - // Add the api if it exists - (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } - ?: emptyList()) - } + val availableProviders = allProviders.filter { + it.supportedSyncNames.contains(syncId) + }.map { it.name } + + // Add the api if it exists + (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } + ?: emptyList()) + val baseOptions = listOf( LibraryOpenerType.Default, LibraryOpenerType.None, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 7dfe3cf59..b48adbf4d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1685,14 +1685,13 @@ class ResultViewModel2 : ViewModel() { } val realRecommendations = ArrayList() - val apiNames = synchronized(apis) { - apis.filter { - it.name.contains("gogoanime", true) || - it.name.contains("9anime", true) - }.map { - it.name - } + val apiNames = apis.filter { + it.name.contains("gogoanime", true) || + it.name.contains("9anime", true) + }.map { + it.name } + meta.recommendations?.forEach { rec -> apiNames.forEach { name -> realRecommendations.add(rec.copy(apiName = name)) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index 27db8d1ae..f60588e35 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -49,7 +49,7 @@ class SearchViewModel : ViewModel() { private var suggestionJob: Job? = null - private var repos = synchronized(apis) { apis.map { APIRepository(it) } } + private var repos = apis.withLock { apis.map { APIRepository(it) } } fun clearSearch() { _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) @@ -68,7 +68,7 @@ class SearchViewModel : ViewModel() { private var onGoingSearch: Job? = null fun reloadRepos() { - repos = synchronized(apis) { apis.map { APIRepository(it) } } + repos = apis.withLock { apis.map { APIRepository(it) } } } fun searchAndCancel( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index dbf2ff1dc..57f5aa870 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -219,7 +219,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } fun showAdd() { - val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } + val providers = allProviders.distinctBy { it::class }.sortedBy { it.name } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index 076f17a0a..c8478a840 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -111,10 +111,10 @@ class SettingsProviders : BasePreferenceFragmentCompat() { getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { activity?.getApiProviderLangSettings()?.let { currentLangTags -> - val languagesTagName = synchronized(APIHolder.apis) { - listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + - APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } - .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji + val languagesTagName = APIHolder.apis.withLock { + listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } } val currentIndexList = currentLangTags.map { langTag -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt index 818f1fd79..22500d931 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,7 +40,7 @@ class TestViewModel : ViewModel() { get() = scope != null private var filter = ProviderFilter.All - private val providers = threadSafeListOf>() + private val providers = atomicListOf>() private var passed = 0 private var failed = 0 private var total = 0 @@ -51,9 +51,9 @@ class TestViewModel : ViewModel() { } private fun postProviders() { - synchronized(providers) { + providers.withLock { val filtered = when (filter) { - ProviderFilter.All -> providers + ProviderFilter.All -> providers.toList() ProviderFilter.Passed -> providers.filter { it.second.success } ProviderFilter.Failed -> providers.filter { !it.second.success } } @@ -68,7 +68,7 @@ class TestViewModel : ViewModel() { } private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { - synchronized(providers) { + providers.withLock { val index = providers.indexOfFirst { it.first == api } if (index == -1) { providers.add(api to results) @@ -81,14 +81,14 @@ class TestViewModel : ViewModel() { } fun init() { - total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } + total = APIHolder.allProviders.withLock { APIHolder.allProviders.size } updateProgress() } fun startTest() { scope = CoroutineScope(Dispatchers.Default) - val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } + val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() } total = apis.size failed = 0 passed = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt index 501ee0eef..8c2e8e344 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt @@ -84,7 +84,7 @@ class SetupFragmentExtensions : BaseFragment( if (isSetup) if ( // If any available languages - synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } + apis.distinctBy { it.lang }.size > 1 ) { findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index 3c4a09ade..c18be8a2f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -36,10 +36,10 @@ class SetupFragmentProviderLanguage : BaseFragment diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 1377ccd08..7278fcdd7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -369,28 +369,10 @@ object AppContextUtils { } fun Context.getApiSettings(): HashSet { - //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val hashSet = HashSet() val activeLangs = getApiProviderLangSettings() val hasUniversal = activeLangs.contains(AllLanguagesName) - hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } } - .map { it.name }) - - /*val set = settingsManager.getStringSet( - this.getString(R.string.search_providers_list_key), - hashSet - )?.toHashSet() ?: hashSet - - val list = HashSet() - for (name in set) { - val api = getApiFromNameNull(name) ?: continue - if (activeLangs.contains(api.lang)) { - list.add(name) - } - }*/ - //if (list.isEmpty()) return hashSet - //return list + hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }.map { it.name }) return hashSet } @@ -481,9 +463,7 @@ object AppContextUtils { } ?: default val langs = this.getApiProviderLangSettings() val hasUniversal = langs.contains(AllLanguagesName) - val allApis = synchronized(apis) { - apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } - } + val allApis = apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } return if (currentPrefMedia.isEmpty()) { allApis } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index 351e77c8d..539d5e1a4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -96,10 +96,8 @@ object SyncUtil { .mapNotNull { it.url }.toMutableList() if (type == "anilist") { // TODO MAKE BETTER - synchronized(apis) { - apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { - current.add("${it.mainUrl}/anime/$id") - } + apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { + current.add("${it.mainUrl}/anime/$id") } } return current diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0dedf709..2a8775761 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ junitKtx = "1.3.0" junitVersion = "1.3.0" juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.20" +kotlinxAtomicfu = "0.32.1" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" @@ -81,6 +82,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" } +kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 073e49e64..b5f525e83 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -56,6 +56,7 @@ kotlin { implementation(libs.annotation) // Annotations implementation(libs.nicehttp) // HTTP Lib implementation(libs.jackson.module.kotlin) // JSON Parser + implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.coroutines.core) implementation(libs.fuzzywuzzy) // Match Extractors implementation(libs.jsoup) // HTML Parser diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt index 975572d05..bc443b3f8 100644 --- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt +++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt @@ -10,10 +10,10 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.nicehttp.requestCreator import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -119,7 +119,7 @@ actual class WebViewResolver actual constructor( } var fixedRequest: Request? = null - val extraRequestList = threadSafeListOf() + val extraRequestList = atomicListOf() main { try { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index fae2bf5e7..f0ddd54c9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -17,8 +17,8 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.Coroutines.mainWork -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.nicehttp.RequestBodyTypes @@ -85,11 +85,10 @@ object APIHolder { val unixTimeMS: Long get() = System.currentTimeMillis() - // ConcurrentModificationException is possible!!! - val allProviders = threadSafeListOf() + val allProviders = atomicListOf() fun initAll() { - synchronized(allProviders) { + allProviders.withLock { for (api in allProviders) { api.init() } @@ -102,25 +101,25 @@ object APIHolder { return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } - var apis: List = threadSafeListOf() + var apis: AtomicList = atomicListOf() var apiMap: Map? = null fun addPluginMapping(plugin: MainAPI) { - synchronized(apis) { + apis.withLock { apis = apis + plugin } initMap(true) } fun removePluginMapping(plugin: MainAPI) { - synchronized(apis) { + apis.withLock { apis = apis.filter { it != plugin } } initMap(true) } private fun initMap(forcedUpdate: Boolean = false) { - synchronized(apis) { + apis.withLock { if (apiMap == null || forcedUpdate) apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap() } @@ -128,24 +127,21 @@ object APIHolder { fun getApiFromNameNull(apiName: String?): MainAPI? { if (apiName == null) return null - synchronized(allProviders) { + return allProviders.withLock { initMap() - synchronized(apis) { - return apiMap?.get(apiName)?.let { apis.getOrNull(it) } + apis.withLock { + apiMap?.get(apiName)?.let { apis.getOrNull(it) } // Leave the ?. null check, it can crash regardless - ?: allProviders.firstOrNull { it.name == apiName } + ?: allProviders.firstOrNull { it.name == apiName } } } } fun getApiFromUrlNull(url: String?): MainAPI? { if (url == null) return null - synchronized(allProviders) { - allProviders.forEach { api -> - if (url.startsWith(api.mainUrl)) return api - } + return allProviders.withLock { + allProviders.firstOrNull { url.startsWith(it.mainUrl) } } - return null } /** diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt index 6fde6efe3..7076e407f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt @@ -30,11 +30,9 @@ class CrossTmdbProvider : TmdbProvider() { } private val validApis - get() = - synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } } + get() = apis.filter { it.lang == this.lang && it::class != this::class } //.distinctBy { it.uniqueId } - data class CrossMetaData( @JsonProperty("isSuccess") val isSuccess: Boolean, @JsonProperty("movies") val movies: List>? = null, @@ -121,4 +119,4 @@ class CrossTmdbProvider : TmdbProvider() { return base } -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt index 61f87b8ba..f4fce2ef3 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt @@ -17,10 +17,7 @@ abstract class BasePlugin { fun registerMainAPI(element: MainAPI) { Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") element.sourcePlugin = this.filename - // Race condition causing which would case duplicates if not for distinctBy - synchronized(APIHolder.allProviders) { - APIHolder.allProviders.add(element) - } + APIHolder.allProviders.add(element) APIHolder.addPluginMapping(element) } @@ -31,9 +28,7 @@ abstract class BasePlugin { fun registerExtractorAPI(element: ExtractorApi) { Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") element.sourcePlugin = this.filename - synchronized(extractorApis) { - extractorApis.add(element) - } + extractorApis.add(element) } /** diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AtomicList.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AtomicList.kt new file mode 100644 index 000000000..0be02ac6f --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AtomicList.kt @@ -0,0 +1,59 @@ +package com.lagradost.cloudstream3.utils + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +/** + * A thread-safe list backed by [SynchronizedObject]. + * + * For iteration, wrap block in [withLock] to hold the lock for the duration: + * list.withLock { list.forEach { ... } } + */ +open class AtomicList( + private val delegate: List = emptyList(), +) : List, SynchronizedObject() { + + fun withLock(block: () -> R): R = synchronized(this) { block() } + + fun filter(predicate: (T) -> Boolean): AtomicList = synchronized(this) { AtomicList(delegate.filter(predicate)) } + fun distinctBy(selector: (T) -> Any?): AtomicList = synchronized(this) { AtomicList(delegate.distinctBy(selector)) } + + override val size: Int get() = synchronized(this) { delegate.size } + override fun isEmpty(): Boolean = synchronized(this) { delegate.isEmpty() } + override fun contains(element: T): Boolean = synchronized(this) { delegate.contains(element) } + override fun containsAll(elements: Collection): Boolean = synchronized(this) { delegate.containsAll(elements) } + override fun get(index: Int): T = synchronized(this) { delegate[index] } + override fun indexOf(element: T): Int = synchronized(this) { delegate.indexOf(element) } + override fun lastIndexOf(element: T): Int = synchronized(this) { delegate.lastIndexOf(element) } + + // Iterators intentionally NOT synchronized, callers must use withLock { } for safe iteration. + override fun iterator(): Iterator = delegate.iterator() + override fun listIterator(): ListIterator = delegate.listIterator() + override fun listIterator(index: Int): ListIterator = delegate.listIterator(index) + override fun subList(fromIndex: Int, toIndex: Int): List = delegate.subList(fromIndex, toIndex) + + operator fun plus(element: T): AtomicList = synchronized(this) { AtomicList(delegate + element) } + operator fun plus(elements: Collection): AtomicList = synchronized(this) { AtomicList(delegate + elements) } +} + +class AtomicMutableList( + private val mutableDelegate: MutableList = mutableListOf(), +) : AtomicList(mutableDelegate), MutableList { + + override fun add(element: T): Boolean = synchronized(this) { mutableDelegate.add(element) } + override fun add(index: Int, element: T) = synchronized(this) { mutableDelegate.add(index, element) } + override fun addAll(elements: Collection): Boolean = synchronized(this) { mutableDelegate.addAll(elements) } + override fun addAll(index: Int, elements: Collection): Boolean = synchronized(this) { mutableDelegate.addAll(index, elements) } + override fun remove(element: T): Boolean = synchronized(this) { mutableDelegate.remove(element) } + override fun removeAt(index: Int): T = synchronized(this) { mutableDelegate.removeAt(index) } + override fun removeAll(elements: Collection): Boolean = synchronized(this) { mutableDelegate.removeAll(elements) } + override fun retainAll(elements: Collection): Boolean = synchronized(this) { mutableDelegate.retainAll(elements) } + override fun set(index: Int, element: T): T = synchronized(this) { mutableDelegate.set(index, element) } + override fun clear() = synchronized(this) { mutableDelegate.clear() } + + // Iterators intentionally NOT synchronized, callers must use withLock { } for safe iteration. + override fun iterator(): MutableIterator = mutableDelegate.iterator() + override fun listIterator(): MutableListIterator = mutableDelegate.listIterator() + override fun listIterator(index: Int): MutableListIterator = mutableDelegate.listIterator(index) + override fun subList(fromIndex: Int, toIndex: Int): MutableList = mutableDelegate.subList(fromIndex, toIndex) +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt index c525a1f36..d15ea129c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt @@ -3,10 +3,10 @@ package com.lagradost.cloudstream3.utils import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import kotlinx.coroutines.* -import java.util.Collections.synchronizedList @AnyThread expect fun runOnMainThreadNative(@MainThread work: (() -> Unit)) @@ -64,9 +64,18 @@ object Coroutines { /** * Safe to add and remove how you want * If you want to iterate over the list then you need to do: - * synchronized(allProviders) { code here } + * list.withLock { code here } */ - fun threadSafeListOf(vararg items: T): MutableList { - return synchronizedList(items.toMutableList()) + @Prerelease + fun atomicListOf(vararg items: T): AtomicMutableList { + return AtomicMutableList(items.toMutableList()) } + + // Deprecate after next stable + /*@Deprecated( + message = "Use atomicListOf() instead.", + replaceWith = ReplaceWith("atomicListOf(*items)"), + level = DeprecationLevel.WARNING, + )*/ + fun threadSafeListOf(vararg items: T): MutableList = atomicListOf(*items) } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 45e2ba71e..b40dd90cf 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -308,6 +308,7 @@ import com.lagradost.cloudstream3.extractors.Zplayer import com.lagradost.cloudstream3.extractors.ZplayerV2 import com.lagradost.cloudstream3.extractors.Ztreamhub import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -904,7 +905,7 @@ suspend fun loadExtractor( return false } -val extractorApis: MutableList = arrayListOf( +val extractorApis: AtomicMutableList = atomicListOf( //AllProvider(), Mp4Upload(), StreamTape(), From 70ed1c753d8be67cd6a4c252b8ec09963aed07f8 Mon Sep 17 00:00:00 2001 From: IndusAryan <125901294+IndusAryan@users.noreply.github.com> Date: Mon, 25 May 2026 04:29:43 +0530 Subject: [PATCH 24/64] (fix): implement hardware check for image loading (#2765) --- .../cloudstream3/utils/ImageModuleCoil.kt | 74 ++++++++++++------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt index 62cf1b996..96193fe45 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.utils import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log import android.widget.ImageView @@ -11,6 +12,7 @@ import coil3.EventListener import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader +import coil3.decode.BitmapFactoryDecoder import coil3.disk.DiskCache import coil3.dispose import coil3.load @@ -22,6 +24,7 @@ import coil3.request.CachePolicy import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.allowHardware +import coil3.request.bitmapConfig import coil3.request.crossfade import coil3.util.DebugLogger import com.lagradost.cloudstream3.BuildConfig @@ -32,71 +35,75 @@ import java.io.File import java.nio.ByteBuffer object ImageLoader { - private const val TAG = "CoilImgLoader" - - internal fun buildImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + internal fun buildImageLoader(context: PlatformContext): ImageLoader { + val isBrokenHardware = hasPotentialBrokenHardware() + return ImageLoader.Builder(context) .crossfade(200) - .allowHardware(SDK_INT >= 28) // SDK_INT >= 28, cant use hardware bitmaps for Palette Builder + .allowHardware(SDK_INT >= 28 && !isBrokenHardware) .diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .memoryCache { - MemoryCache.Builder().maxSizePercent(context, 0.1) // Use 10 % of the app's available memory for caching + MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache + .strongReferencesEnabled(false) .build() } .diskCache { DiskCache.Builder() .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) .maxSizeBytes(512L * 1024 * 1024) // 512 MB - .maxSizePercent(0.04) // Use 4 % of the device's storage space for disk caching + .maxSizePercent(0.04) // max 4% of storage for disk caching .build() } /** Pass interceptors with care, unnecessary passing tokens to servers or image hosting services causes unauthorized exceptions **/ - .components { add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) } - .also { - it.setupCoilLogger() - Log.d(TAG, "buildImageLoader: Setting COIL Image Loader.") + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) + if (isBrokenHardware) { + add(BitmapFactoryDecoder.Factory()) + } // sw decoder + } + .apply { + if (isBrokenHardware) { // coil will auto choose optimal config on modern device + bitmapConfig(Bitmap.Config.ARGB_8888) + } + setupCoilLogger() } .build() + } - /** Use DebugLogger on debug builds which won't slow down release builds & use EventListener for + /** DebugLogger on debug builds which won't slow down release builds & use EventListener for Errors on release builds. **/ internal fun ImageLoader.Builder.setupCoilLogger() { if (BuildConfig.DEBUG) { logger(DebugLogger()) - Log.d(TAG, "setupCoilLogger: Activated DEBUG_LOGGER FOR COIL") } else { eventListener(object : EventListener() { override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) - Log.e(TAG, "Error loading image: ${result.throwable}") + Log.e(TAG, "Image load error: ${result.throwable.message ?: result.throwable}") + Log.e(TAG, " URL: ${request.data}") + Log.e(TAG, " allowHardware: ${request.allowHardware}") + Log.e(TAG, " hardware: ${Build.HARDWARE}, board: ${Build.BOARD}") } }) - Log.d(TAG, "setupCoilLogger: Activated EVENT_LISTENER FOR COIL") } } - /** we use coil's built in loader with our global synchronized instance, this way we achieve - latest and complete functionality as well as stability **/ + /** coil's built in loader attached w/ global synchronized instance **/ private fun ImageView.loadImageInternal( imageData: Any?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations ) { - // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) + // clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column) this.dispose() - - if(imageData == null) return // Just in case - + if (imageData == null) return // setImageResource is better than coil3 on resources due to attr - if(imageData is Int) { - this.setImageResource(imageData) - return + if (imageData is Int) { + this.setImageResource(imageData); return } - - // Use Coil's built-in load method but with our custom module & a decent USER-AGENT always - // which can be overridden by extensions. + // headers can be overridden by extensions. this.load(imageData, SingletonImageLoader.get(context)) { this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> headerBuilder["User-Agent"] = USER_AGENT @@ -104,11 +111,22 @@ object ImageLoader { headerBuilder[key] = value } }.build()) - builder() // if passed } } + private fun hasPotentialBrokenHardware(): Boolean { + val hardware = Build.HARDWARE?.lowercase() ?: "" + val board = Build.BOARD?.lowercase() ?: "" + val model = Build.MODEL?.lowercase() ?: "" + val manufacturer = Build.MANUFACTURER?.lowercase() ?: "" + val allwinnerPatterns = listOf("sun50iw9", "h713", "allwinner", "sunxi") + val problematicModels = + listOf("hy320", "hy300", "a10plus", "magcubic", "sinoy", "android tv box") + return allwinnerPatterns.any { it in hardware || it in board || it in manufacturer } || + problematicModels.any { it in model } + } + /** TYPE_SAFE_LOADERS **/ fun ImageView.loadImage( imageData: UiImage?, @@ -166,4 +184,4 @@ object ImageLoader { imageData: ByteBuffer?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) -} \ No newline at end of file +} From b353cf2017dc323cffa1003c7f907f16261c9159 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 26 May 2026 11:00:36 -0600 Subject: [PATCH 25/64] Remove back-compat constructor for AnimeSearchResponse (#2815) This is in preparation to remove the use of EnumSet for `dubStatus`. Those that use the builder, use `addDubStatus` which means we can easily do this without breaking bytecode compatibility, but first we need to remove the back-compat constructor, as once we do that it wouldn't work anymore anyway. --- .../com/lagradost/cloudstream3/MainAPI.kt | 38 +------------------ 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index f0ddd54c9..faff17077 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -1502,43 +1502,7 @@ constructor( override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override var score: Score? = null, -) : SearchResponse { - @Suppress("DEPRECATION_ERROR") - @Deprecated( - "Use newAnimeSearchResponse", - level = DeprecationLevel.ERROR - ) - constructor( - name: String, - url: String, - apiName: String, - type: TvType? = null, - - posterUrl: String? = null, - year: Int? = null, - dubStatus: EnumSet? = null, - - otherName: String? = null, - episodes: MutableMap = mutableMapOf(), - - id: Int? = null, - quality: SearchQuality? = null, - posterHeaders: Map? = null, - ) : this( - name, - url, - apiName, - type, - posterUrl, - year, - dubStatus, - otherName, - episodes, - id, - quality, - posterHeaders, null - ) -} +) : SearchResponse fun AnimeSearchResponse.addDubStatus(status: DubStatus, episodes: Int? = null) { this.dubStatus = dubStatus?.also { it.add(status) } ?: EnumSet.of(status) From c8bc999d22024e5644f036ed498c49d4d33feebc Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Tue, 26 May 2026 19:00:51 +0200 Subject: [PATCH 26/64] Translated using Weblate (Norwegian Nynorsk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 26.4% (193 of 729 strings) Translated using Weblate (Malay) Currently translated at 65.8% (480 of 729 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Russian) Currently translated at 100.0% (729 of 729 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Dutch) Currently translated at 90.8% (662 of 729 strings) Translated using Weblate (Arabic (Saudi Arabia)) Currently translated at 100.0% (4 of 4 strings) Translated using Weblate (Arabic) Currently translated at 100.0% (729 of 729 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (729 of 729 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (729 of 729 strings) Translated using Weblate (Portuguese) Currently translated at 100.0% (729 of 729 strings) Translated using Weblate (Portuguese) Currently translated at 100.0% (4 of 4 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (729 of 729 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Vietnamese) Currently translated at 100.0% (729 of 729 strings) Co-authored-by: Andrei Stepanov Co-authored-by: Dark Co-authored-by: Douglas de Santana Ramos Co-authored-by: Gabriel Co-authored-by: Hosted Weblate Co-authored-by: Johannes Bø Co-authored-by: Maarten De Jong Co-authored-by: Man Co-authored-by: Saleh ALHarbi Co-authored-by: ssantos Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ms/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nn/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt_BR/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ar_SA/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/pt/ Translation: Cloudstream/App Translation: Cloudstream/Fastlane --- app/src/main/res/values-b+ar/strings.xml | 3 +++ app/src/main/res/values-b+ms/strings.xml | 6 +++--- app/src/main/res/values-b+nl/strings.xml | 16 +++++++++++++++ app/src/main/res/values-b+nn/strings.xml | 10 ++++++++++ app/src/main/res/values-b+pt+BR/strings.xml | 6 +++--- app/src/main/res/values-b+pt/strings.xml | 20 +++++++++++-------- app/src/main/res/values-b+ru/strings.xml | 4 ++++ app/src/main/res/values-b+vi/strings.xml | 18 ++++++++--------- .../android/ar-SA/short_description.txt | 2 +- .../metadata/android/pt/short_description.txt | 2 +- 10 files changed, 62 insertions(+), 25 deletions(-) diff --git a/app/src/main/res/values-b+ar/strings.xml b/app/src/main/res/values-b+ar/strings.xml index 57b2bb628..f543136dc 100644 --- a/app/src/main/res/values-b+ar/strings.xml +++ b/app/src/main/res/values-b+ar/strings.xml @@ -753,4 +753,7 @@ %d تنزيل قيد الانتظار عرض واجهة منبثقة للبيانات الوصفية للمشغِّل + مقطع + استعراض + البث قائم diff --git a/app/src/main/res/values-b+ms/strings.xml b/app/src/main/res/values-b+ms/strings.xml index ab0d0a54f..a15759939 100644 --- a/app/src/main/res/values-b+ms/strings.xml +++ b/app/src/main/res/values-b+ms/strings.xml @@ -25,8 +25,8 @@ Episod %d akan disiarkan dalam Pelakon:%s Mod Selamat Hidup - %1$dd %2$dh %3$dm - %1$dh %2$dm + %1$dh %2$dj %3$dm + %1$dj %2$dm %dm Poster Episod Poster Utama @@ -485,7 +485,7 @@ Kemaskini dan sandaran Ketik dua kali untuk mencari Gunakan kecerahan sistem dalam pemain apl dan bukannya tindanan gelap - %1$dh %2$dm %3$ds + %1$dj %2$dm %3$ds %1$dm %2$ds %1$ds Pengecaman pertuturan tidak tersedia diff --git a/app/src/main/res/values-b+nl/strings.xml b/app/src/main/res/values-b+nl/strings.xml index ace3aa0d4..3164e6b4e 100644 --- a/app/src/main/res/values-b+nl/strings.xml +++ b/app/src/main/res/values-b+nl/strings.xml @@ -661,4 +661,20 @@ Fout bij toegang tot het Klembord, Probeer het opnieuw. Fout bij het kopiëren. Kopieer alsjeblieft de logcat en neem contact op met de app-ondersteuning. Afwijzen + Video + Voorbeeld + Bron Prioriteit + Bepaal hoe de videobronnen worden gesorteerd in de speler + Ontgrendel CloudStream + Versleutel met Biometrie + Reset + verschijningsdatum (Nieuw naar Oud) + verschijningsdatum (Oud naar Nieuw) + Verberg de namen van de besturingselementen van de speler + Ondertiteling nog niet geladen + Back-up folder locatie + Aangepast + Bevestig voor afsluiten + Toon dialoogvenster voordat de app wordt afgesloten + Randgrote diff --git a/app/src/main/res/values-b+nn/strings.xml b/app/src/main/res/values-b+nn/strings.xml index 014e2b0a9..b29e142b0 100644 --- a/app/src/main/res/values-b+nn/strings.xml +++ b/app/src/main/res/values-b+nn/strings.xml @@ -188,4 +188,14 @@ Bilde i bilde Fortsett å sjå Prøv tilkopling på nytt… + Sesong %1$d Episode %2$d blir sleppt om + Spel av frå start + Nedlastingskø + Semmegjenkjenning er ikkje tilgjengeleg + Snakk no… + Nettlesar + Fjerna + Strøm Torrent + Spel heile serien + Denne filmen er ein Torrent, som betyr at bruken din kan bli spora\nSett deg inn i bruk av Torrent-resursar før du fortsetter. diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index 00e3a6229..455478b5a 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -616,7 +616,7 @@ Reproduzir do começo Reprovou alguns testes Excluir plugin - Você não baixou nada :/ + Atualmente não há downloads disponíveis. Ocultar os nomes dos controles do player Abrir arquivo de vídeo Data de lançamento (do novo ao antigo) @@ -736,7 +736,7 @@ Nome da fonte Baixar tudo Cancelar tudo - Você deseja baixar o episódio%s + Você deseja baixar o episódio%s? Você gostaria de cancelar todos os downloads da fila? %ddownload ativo @@ -748,7 +748,7 @@ %d downloads na sequência %d downloads na sequência - Exibir sobreposição de metadados do player + Mostrar sobreposição de metadados do reprodutor Vídeo Visualização Ao vivo diff --git a/app/src/main/res/values-b+pt/strings.xml b/app/src/main/res/values-b+pt/strings.xml index 6dad27011..960a91d0d 100644 --- a/app/src/main/res/values-b+pt/strings.xml +++ b/app/src/main/res/values-b+pt/strings.xml @@ -61,7 +61,7 @@ Transmitir Erro a Carregar Links Armazenamento Interno - Dob + Dub Leg Eliminar Ficheiro Reproduzir Ficheiro @@ -100,7 +100,7 @@ Importar fontes colocando em %s Continuar a Assistir Remover - Mais info + Mais informações Uma VPN pode ser necessária para que este fornecedor funcione corretamente Este fornecedor é um torrent, uma VPN é recomendada Metadados não são oferecidos pelo site, o carregamento do vídeo irá falhar se ele não existir no site. @@ -142,7 +142,7 @@ Procurar Contas e segurança Atualizações e cópias de segurança - Info + Informações Procura Avançada Mostra resultados separados por fornecedor Mostrar episódios de enchimento para anime @@ -318,9 +318,9 @@ Carregar de arquivo Carregar da Internet Arquivo baixado - Protagonista - Coadjuvante - Figurante + Principal + Suporte + Plano de fundo Aleatório Em breve… Imagem de Poster @@ -523,7 +523,7 @@ Repositório não encontrado, verifique o URL e tente a VPN Você já votou Cancelar Inscrição - Subscrever + Inscrever-se Favoritos A recarregar links Frequência de Backup @@ -686,7 +686,7 @@ Imagem Atualizada com Sucesso Marcar como assistido o episódio Removar marcação de assistido até esse episódio - Recarregado + Recarregar Provedor de Recarregamento Reproduzir do servidor alternativo" Nome @@ -733,4 +733,8 @@ %d downloads na fila %d downloads na fila + Mostrar sobreposição de metadados do player + Vídeo + Pré-visualização + Ao Vivo diff --git a/app/src/main/res/values-b+ru/strings.xml b/app/src/main/res/values-b+ru/strings.xml index 3f6a5d89d..38e576baa 100644 --- a/app/src/main/res/values-b+ru/strings.xml +++ b/app/src/main/res/values-b+ru/strings.xml @@ -735,4 +735,8 @@ Отменить всё Вы хотите загрузить эпизод %s? Вы хотите отменить всё запланированные загрузки? + Показывать наложения метаданных проигрывателя + Видео + Предпросмотр + Прямой эфир diff --git a/app/src/main/res/values-b+vi/strings.xml b/app/src/main/res/values-b+vi/strings.xml index 842b97080..952bafee7 100644 --- a/app/src/main/res/values-b+vi/strings.xml +++ b/app/src/main/res/values-b+vi/strings.xml @@ -166,7 +166,7 @@ Ngôn ngữ ứng dụng Nguồn phim này chưa hỗ trợ Chromecast Không tìm thấy liên kết - Đã sao chép liên kết vào bộ nhớ tạm + Đã sao chép liên kết vào bảng nhớ tạm Phát Tập phim Đặt lại giá trị mặc định Mùa @@ -254,7 +254,7 @@ Cập nhật Chất lượng xem ưu tiên (WiFi) Số ký tự tối đa tiêu đề trình phát - Hiện thông tin trình phát + Hiển thị thông tin trình phát Kích thước bộ nhớ đệm video Thời lượng bộ nhớ đệm Bộ nhớ đệm video trên thiết bị @@ -417,7 +417,7 @@ Âm thanh & video Âm thanh Video - Khởi động lại ứng dụng để thấy câc thay đổi. + Khởi động lại ứng dụng để thấy các thay đổi. Chế độ an toàn được bật Tất cả tiện ích mở rộng đã được tắt do ứng dụng bị ngừng bất thường để giúp bạn tìm ra vấn đề gây lỗi. Xem thông tin sự cố @@ -469,8 +469,8 @@ Danh đề Giới thiệu Xoá lịch sử - Hiện các popup bỏ qua cho mở đầu/kết thúc - Văn bản quá dài. Không thể lưu vào bộ nhớ tạm. + Hiển thị cửa sổ bật lên của bỏ qua giới thiệu cho mở đầu/kết thúc + Văn bản quá dài. Không thể lưu vào bảng nhớ tạm. Xoá khỏi đã xem Bạn có chắc muốn thoát? @@ -575,7 +575,7 @@ Bật tự động xoay màn hình theo hướng của video Tự động xoay đã sao chép! - Lỗi truy cập Bộ nhớ tạm, Vui lòng thử lại. + Lỗi truy cập Bảng nhớ tạm, Vui lòng thử lại. Lỗi sao chép, Vui lòng sao chép logcat và liên hệ hỗ trợ ứng dụng. Yêu thích OK @@ -635,7 +635,7 @@ Xem trước trên thanh tua Chưa tải phụ đề nào Xác nhận trước khi thoát - Hiện hộp thoại xác nhận trước khi thoát ứng dụng + Hiển thị hộp thoại xác nhận trước khi thoát ứng dụng Không hiển thị Hiển thị Vị trí thư mục sao lưu @@ -644,7 +644,7 @@ Video này là Torrent, điều này có nghĩa là hoạt động video của bạn có thể được theo dõi.\nHãy đảm bảo rằng bạn hiểu về Torrent trước khi tiếp tục. Lỗi mã hóa Giải mã phần mềm cho phép phát các tệp video không được thiết bị của bạn hỗ trợ, nhưng có thể gây ra phản hồi chậm hoặc phát lại không ổn định ở độ phân giải cao. - Bộ giải mã ứng dụng + Giải mã phần mềm Khởi động lại ứng dụng và chấp nhận cửa sổ bật lên của Stream Torrent để tiếp tục. Kích hoạt torrent trong Cài đặt/Nguồn phim/Thể loại ưu tiên Tải phụ đề đầu tiên có sẵn @@ -731,7 +731,7 @@ Tên nguồn Hàng đợi tải xuống Không có tải xuống đang chờ nào. - Quyết định cách sắp xếp các nguồn video trong trình phát. + Quyết định cách sắp xếp các nguồn video trong trình phát Ưu tiên nguồn Tải xuống tất cả Hủy tất cả diff --git a/fastlane/metadata/android/ar-SA/short_description.txt b/fastlane/metadata/android/ar-SA/short_description.txt index 7ccd9743b..e56a2e8be 100644 --- a/fastlane/metadata/android/ar-SA/short_description.txt +++ b/fastlane/metadata/android/ar-SA/short_description.txt @@ -1 +1 @@ -بث وتحميل الأفلام, الأنمي, والمسلسلات التلفزيونية. +بث وتحميل الأفلام, المسلسلات التلفزيونية والأنمي. diff --git a/fastlane/metadata/android/pt/short_description.txt b/fastlane/metadata/android/pt/short_description.txt index d0392f34b..08ad5a778 100644 --- a/fastlane/metadata/android/pt/short_description.txt +++ b/fastlane/metadata/android/pt/short_description.txt @@ -1 +1 @@ -Transmita e transfira filmes, séries de TV e anime. +Transmita e descarga filmes, séries de TV e anime. From 007c0ff9bc7cb158f9e4b2b710024fd744067069 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 26 May 2026 11:22:06 -0600 Subject: [PATCH 27/64] Fix some bugs in DownloadedPlayerActivity (#2758) * Fix some bugs in DownloadedPlayerActivity * Remove savedInstanceState check and use better long comment format --- .../ui/player/DownloadedPlayerActivity.kt | 22 +++++++++++++++---- .../ui/player/OfflinePlaybackHelper.kt | 19 +++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index 7a42cea93..a086cc16f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -58,9 +58,23 @@ class DownloadedPlayerActivity : AppCompatActivity() { enableEdgeToEdgeCompat() setContentView(R.layout.empty_layout) Log.i(TAG, "onCreate") - handleIntent(intent) - attachBackPressedCallback("DownloadedPlayerActivity") { finish() } + + /** + * Use moveTaskToBack instead of finish() so there is always exactly one task + * entry in recents, always reflecting the current file. + * + * finish() destroys the Activity but may leave the task in recents. Each new file + * open can create a new task entry, so recents accumulates stale entries for old + * files. The user then taps a stale entry and gets the wrong file. + * + * moveTaskToBack keeps the Activity alive in the background. There is only ever + * one task entry in recents. New files opened from the file manager arrive via + * onNewIntent on the live instance, updating the player immediately. The single + * recents entry always reflects the current state, ensuring we load the + * correct file. + */ + attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) } } private fun handleIntent(intent: Intent) { @@ -83,11 +97,11 @@ class DownloadedPlayerActivity : AppCompatActivity() { url != null -> playLink(this, url) data != null -> playUri(this, data) extraText != null -> playLink(this, extraText) - else -> { finish(); return } + else -> finishAndRemoveTask() } } else if (data?.scheme == "content") { playUri(this, data) - } else finish() + } else finishAndRemoveTask() } override fun onResume() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt index ac25347b6..dcf976612 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Intent import android.net.Uri import androidx.core.content.ContextCompat.getString +import androidx.navigation.NavOptions import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson @@ -12,6 +13,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.safefile.SafeFile object OfflinePlaybackHelper { + /** + * Pop any existing player off the nav back stack before pushing the new one, + * keeping the stack flat (at most one player at a time). This prevents an + * OOM when many files are opened in sequence via DownloadedPlayerActivity. + */ + private val replacePlayerNavOptions = NavOptions.Builder() + .setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false) + .build() + fun playLink(activity: Activity, url: String) { activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( @@ -20,7 +30,8 @@ object OfflinePlaybackHelper { BasicLink(url) ), id = url.hashCode() ), 0 - ) + ), + replacePlayerNavOptions ) } @@ -52,7 +63,8 @@ object OfflinePlaybackHelper { subs, if (id != -1) id else null, ), 0 - ) + ), + replacePlayerNavOptions ) return true } @@ -76,7 +88,8 @@ object OfflinePlaybackHelper { ) ) ), 0 - ) + ), + replacePlayerNavOptions ) } } \ No newline at end of file From d24f8bca0fd6c3796dfcd16e561d54205430ca6b Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 27 May 2026 14:57:09 -0600 Subject: [PATCH 28/64] Don't use any external library for Levenshtein ratio matching (#2802) --- app/build.gradle.kts | 4 +- .../cloudstream3/syncproviders/AuthAPI.kt | 1 - .../cloudstream3/syncproviders/SyncAPI.kt | 6 +- .../settings/extensions/PluginsViewModel.kt | 4 +- gradle/libs.versions.toml | 2 - library/build.gradle.kts | 4 +- .../cloudstream3/utils/ExtractorApi.kt | 3 +- .../cloudstream3/utils/Levenshtein.kt | 515 ++++++++++++++++++ .../cloudstream3/utils/SubtitleHelper.kt | 5 +- 9 files changed, 529 insertions(+), 15 deletions(-) create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b1aefab2..e6c970157 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -255,13 +255,15 @@ dependencies { // Extensions & Other Libs implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript - implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance implementation(libs.safefile) // To Prevent the URI File Fu*kery coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.zipline) + // Deprecated; will be removed once extensions have time to migrate from using it + implementation("me.xdrop:fuzzywuzzy:1.4.0") + // Torrent Support implementation(libs.torrentserver) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 9a1a441f1..184a9fbcc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -39,7 +39,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt -import me.xdrop.fuzzywuzzy.FuzzySearch import java.net.URL import java.security.SecureRandom import java.util.Date diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index e5f9aca84..f30a64748 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.Levenshtein import com.lagradost.cloudstream3.utils.UiText -import me.xdrop.fuzzywuzzy.FuzzySearch import java.util.Date /** @@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() { ListSorting.Query -> if (query != null) { items.sortedBy { - -FuzzySearch.partialRatio( + -Levenshtein.partialRatio( query.lowercase(), it.name.lowercase() ) } @@ -191,4 +191,4 @@ abstract class SyncAPI : AuthAPI() { override var score: Score? = null, val tags: List? = null ) : SearchResponse -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index dfc61eba5..0cbef9cf2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -23,7 +23,7 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import me.xdrop.fuzzywuzzy.FuzzySearch +import com.lagradost.cloudstream3.utils.Levenshtein import java.io.File // String => repository url @@ -246,7 +246,7 @@ class PluginsViewModel : ViewModel() { this.sortedBy { it.plugin.second.name } } else { this.sortedBy { - -FuzzySearch.partialRatio( + -Levenshtein.partialRatio( it.plugin.second.name.lowercase(), query.lowercase() ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a8775761..96fbee050 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,6 @@ desugar_jdk_libs_nio = "2.1.5" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" -fuzzywuzzy = "1.4.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" jsoup = "1.22.1" @@ -75,7 +74,6 @@ desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", vers espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } -fuzzywuzzy = { module = "me.xdrop:fuzzywuzzy", version.ref = "fuzzywuzzy" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } json = { module = "org.json:json", version.ref = "json" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index b5f525e83..228bf4994 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -58,11 +58,13 @@ kotlin { implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.coroutines.core) - 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 + + // Deprecated; will be removed once extensions have time to migrate from using it + implementation("me.xdrop:fuzzywuzzy:1.4.0") } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index b40dd90cf..66055d7de 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -312,7 +312,6 @@ import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive -import me.xdrop.fuzzywuzzy.FuzzySearch import org.jsoup.Jsoup import java.net.URI import java.util.UUID @@ -884,7 +883,7 @@ suspend fun loadExtractor( // this is to match mirror domains - like example.com, example.net for (index in extractorApis.lastIndex downTo 0) { val extractor = extractorApis[index] - if (FuzzySearch.partialRatio( + if (Levenshtein.partialRatio( extractor.mainUrl, currentUrl ) > 80 diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt new file mode 100644 index 000000000..2f3957630 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt @@ -0,0 +1,515 @@ +/** + * MIT License + * + * Copyright (c) 2026 Konstantin Tskhovrebov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.lagradost.cloudstream3.utils + +import com.lagradost.cloudstream3.Prerelease +import kotlin.math.round + +// Taken from https://github.com/terrakok/FuzzyKot/blob/f794d43/fuzzykot/src/commonMain/kotlin/com/github/terrakok/fuzzykot/Levenshtein.kt +@Prerelease +object Levenshtein { + fun ratio(s1: String, s2: String, processor: (String) -> String = { it }): Int { + val p1 = processor(s1) + val p2 = processor(s2) + return round(100 * basicRatio(p1, p2)).toInt() + } + + fun partialRatio(s1: String, s2: String, processor: (String) -> String = { it }): Int { + val p1 = processor(s1) + val p2 = processor(s2) + + val shorter: String + val longer: String + + if (p1.length < p2.length) { + shorter = p1 + longer = p2 + } else { + shorter = p2 + longer = p1 + } + + if (shorter.isEmpty()) return if (longer.isEmpty()) 100 else 0 + + val matchingBlocks = getMatchingBlocks(shorter.length, longer.length, getEditOps(shorter, longer)) + val scores = mutableListOf() + + for (mb in matchingBlocks) { + val dist = mb.dpos - mb.spos + val longStart = if (dist > 0) dist else 0 + var longEnd = longStart + shorter.length + if (longEnd > longer.length) longEnd = longer.length + + val longSubstr = longer.substring(longStart, longEnd) + val ratio = basicRatio(shorter, longSubstr) + + if (ratio > .995) return 100 + scores.add(ratio) + } + + return round(100 * (scores.maxOrNull() ?: 0.0)).toInt() + } + + private fun basicRatio(s1: String, s2: String): Double { + val lensum = s1.length + s2.length + if (lensum == 0) return 1.0 + val editDistance = levEditDistance(s1, s2, 1) + return (lensum - editDistance) / lensum.toDouble() + } +} + +private enum class EditType { + DELETE, + EQUAL, + INSERT, + REPLACE, + KEEP +} + +private data class EditOp( + var type: EditType? = null, + var spos: Int = 0, + var dpos: Int = 0 +) { + override fun toString(): String = "${type?.name ?: "null"}($spos,$dpos)" +} + +private data class MatchingBlock( + val spos: Int = 0, + val dpos: Int = 0, + val length: Int = 0 +) { + override fun toString(): String = "($spos,$dpos,$length)" +} + +private fun getEditOps(s1: String, s2: String): Array { + var len1Copy = s1.length + var len2Copy = s2.length + + var len1o = 0 + var i = 0 + + val matrix: IntArray + + val c1 = s1 + val c2 = s2 + + var p1 = 0 + var p2 = 0 + + while (len1Copy > 0 && len2Copy > 0 && c1[p1] == c2[p2]) { + len1Copy-- + len2Copy-- + p1++ + p2++ + len1o++ + } + + val len2o = len1o + + while (len1Copy > 0 && len2Copy > 0 && c1[p1 + len1Copy - 1] == c2[p2 + len2Copy - 1]) { + len1Copy-- + len2Copy-- + } + + len1Copy++ + len2Copy++ + + matrix = IntArray(len2Copy * len1Copy) + + while (i < len2Copy) { + matrix[i] = i + i++ + } + i = 1 + while (i < len1Copy) { + matrix[len2Copy * i] = i + i++ + } + + i = 1 + while (i < len1Copy) { + var ptrPrev = (i - 1) * len2Copy + var ptrC = i * len2Copy + val ptrEnd = ptrC + len2Copy - 1 + + val char1 = c1[p1 + i - 1] + var ptrChar2 = p2 + + var x = i + ptrC++ + + while (ptrC <= ptrEnd) { + var c3 = matrix[ptrPrev++] + if (char1 != c2[ptrChar2++]) 1 else 0 + x++ + if (x > c3) x = c3 + c3 = matrix[ptrPrev] + 1 + if (x > c3) x = c3 + matrix[ptrC++] = x + } + i++ + } + + return editOpsFromCostMatrix(len1Copy, c1, p1, len1o, len2Copy, c2, p2, len2o, matrix) +} + +private fun editOpsFromCostMatrix( + len1: Int, c1: String, p1: Int, o1: Int, + len2: Int, c2: String, p2: Int, o2: Int, + matrix: IntArray +): Array { + var i: Int = len1 - 1 + var j: Int = len2 - 1 + var pos: Int = matrix[len1 * len2 - 1] + var ptr: Int = len1 * len2 - 1 + val ops: Array = arrayOfNulls(pos) + var dir = 0 + + while (i > 0 || j > 0) { + if (dir < 0 && j != 0 && matrix[ptr] == matrix[ptr - 1] + 1) { + val eop = EditOp() + pos-- + ops[pos] = eop + eop.type = EditType.INSERT + eop.spos = i + o1 + eop.dpos = --j + o2 + ptr-- + continue + } + + if (dir > 0 && i != 0 && matrix[ptr] == matrix[ptr - len2] + 1) { + val eop = EditOp() + pos-- + ops[pos] = eop + eop.type = EditType.DELETE + eop.spos = --i + o1 + eop.dpos = j + o2 + ptr -= len2 + continue + } + + if (i != 0 && j != 0 && matrix[ptr] == matrix[ptr - len2 - 1] && c1[p1 + i - 1] == c2[p2 + j - 1]) { + i-- + j-- + ptr -= len2 + 1 + dir = 0 + continue + } + + if (i != 0 && j != 0 && matrix[ptr] == matrix[ptr - len2 - 1] + 1) { + pos-- + val eop = EditOp() + ops[pos] = eop + eop.type = EditType.REPLACE + eop.spos = --i + o1 + eop.dpos = --j + o2 + ptr -= len2 + 1 + dir = 0 + continue + } + + if (dir == 0 && j != 0 && matrix[ptr] == matrix[ptr - 1] + 1) { + pos-- + val eop = EditOp() + ops[pos] = eop + eop.type = EditType.INSERT + eop.spos = i + o1 + eop.dpos = --j + o2 + ptr-- + dir = -1 + continue + } + + if (dir == 0 && i != 0 && matrix[ptr] == matrix[ptr - len2] + 1) { + pos-- + val eop = EditOp() + ops[pos] = eop + eop.type = EditType.DELETE + eop.spos = --i + o1 + eop.dpos = j + o2 + ptr -= len2 + dir = 1 + continue + } + } + + return ops.requireNoNulls() +} + +private fun getMatchingBlocks(len1: Int, len2: Int, ops: Array): Array { + val n = ops.size + var numberOfMatchingBlocks = 0 + var i: Int + var spos: Int + var dpos: Int + var o = 0 + + dpos = 0 + spos = dpos + + i = n + while (i != 0) { + while (ops[o].type === EditType.KEEP && --i != 0) { + o++ + } + if (i == 0) break + if (spos < ops[o].spos || dpos < ops[o].dpos) { + numberOfMatchingBlocks++ + spos = ops[o].spos + dpos = ops[o].dpos + } + val type = ops[o].type!! + when (type) { + EditType.REPLACE -> do { + spos++ + dpos++ + i-- + o++ + } while (i != 0 && ops[o].type === type && spos == ops[o].spos && dpos == ops[o].dpos) + + EditType.DELETE -> do { + spos++ + i-- + o++ + } while (i != 0 && ops[o].type === type && spos == ops[o].spos && dpos == ops[o].dpos) + + EditType.INSERT -> do { + dpos++ + i-- + o++ + } while (i != 0 && ops[o].type === type && spos == ops[o].spos && dpos == ops[o].dpos) + + else -> {} + } + } + + if (spos < len1 || dpos < len2) numberOfMatchingBlocks++ + + val matchingBlocks = arrayOfNulls(numberOfMatchingBlocks + 1) + o = 0 + dpos = 0 + spos = dpos + var mbIndex = 0 + + i = n + while (i != 0) { + while (ops[o].type === EditType.KEEP && --i != 0) o++ + if (i == 0) break + if (spos < ops[o].spos || dpos < ops[o].dpos) { + val mb = MatchingBlock( + spos = spos, + dpos = dpos, + length = ops[o].spos - spos + ) + spos = ops[o].spos + dpos = ops[o].dpos + matchingBlocks[mbIndex++] = mb + } + val type = ops[o].type!! + when (type) { + EditType.REPLACE -> do { + spos++ + dpos++ + i-- + o++ + } while (i != 0 && ops[o].type === type && spos == ops[o].spos && dpos == ops[o].dpos) + + EditType.DELETE -> do { + spos++ + i-- + o++ + } while (i != 0 && ops[o].type === type && spos == ops[o].spos && dpos == ops[o].dpos) + + EditType.INSERT -> do { + dpos++ + i-- + o++ + } while (i != 0 && ops[o].type === type && spos == ops[o].spos && dpos == ops[o].dpos) + + else -> {} + } + } + + if (spos < len1 || dpos < len2) { + val mb = MatchingBlock( + spos = spos, + dpos = dpos, + length = len1 - spos + ) + matchingBlocks[mbIndex++] = mb + } + + val finalBlock = MatchingBlock( + spos = len1, + dpos = len2, + length = 0 + ) + matchingBlocks[mbIndex] = finalBlock + + return matchingBlocks.filterNotNull().toTypedArray() +} + +private fun levEditDistance(s1: String, s2: String, xcost: Int): Int { + var i: Int + val half: Int + + var c1 = s1 + var c2 = s2 + + var str1 = 0 + var str2 = 0 + + var len1 = s1.length + var len2 = s2.length + + while (len1 > 0 && len2 > 0 && c1[str1] == c2[str2]) { + len1-- + len2-- + str1++ + str2++ + } + + while (len1 > 0 && len2 > 0 && c1[str1 + len1 - 1] == c2[str2 + len2 - 1]) { + len1-- + len2-- + } + + if (len1 == 0) return len2 + if (len2 == 0) return len1 + + if (len1 > len2) { + val nx = len1 + val temp = str1 + len1 = len2 + len2 = nx + str1 = str2 + str2 = temp + val t = c2 + c2 = c1 + c1 = t + } + + if (len1 == 1) { + return if (xcost != 0) { + len2 + 1 - 2 * memchr(c2, str2, c1[str1], len2) + } else { + len2 - memchr(c2, str2, c1[str1], len2) + } + } + + len1++ + len2++ + half = len1 shr 1 + + val row = IntArray(len2) + var end = len2 - 1 + + i = 0 + while (i < len2 - if (xcost != 0) 0 else half) { + row[i] = i + i++ + } + + if (xcost != 0) { + i = 1 + while (i < len1) { + var p = 1 + val ch1 = c1[str1 + i - 1] + var c2p = str2 + var D = i + var x = i + while (p <= end) { + if (ch1 == c2[c2p++]) { + x = --D + } else { + x++ + } + D = row[p] + D++ + if (x > D) x = D + row[p++] = x + } + i++ + } + } else { + row[0] = len1 - half - 1 + i = 1 + while (i < len1) { + var p: Int + val ch1 = c1[str1 + i - 1] + var c2p: Int + var D: Int + var x: Int + + if (i >= len1 - half) { + val offset = i - (len1 - half) + c2p = str2 + offset + p = offset + val c3 = row[p++] + if (ch1 != c2[c2p++]) 1 else 0 + x = row[p] + x++ + D = x + if (x > c3) x = c3 + row[p++] = x + } else { + p = 1 + c2p = str2 + x = i + D = x + } + if (i <= half + 1) end = len2 + i - half - 2 + while (p <= end) { + val c3 = --D + if (ch1 != c2[c2p++]) 1 else 0 + x++ + if (x > c3) x = c3 + D = row[p] + D++ + if (x > D) x = D + row[p++] = x + } + if (i <= half) { + val c3 = --D + if (ch1 != c2[c2p]) 1 else 0 + x++ + if (x > c3) x = c3 + row[p] = x + } + i++ + } + } + + return row[end] +} + +private fun memchr(haystack: String, offset: Int, needle: Char, num: Int): Int { + var numCopy = num + if (numCopy != 0) { + var p = 0 + do { + if (haystack[offset + p] == needle) return 1 + p++ + } while (--numCopy != 0) + } + return 0 +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt index 7becf4d19..a6362efd0 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.utils -import me.xdrop.fuzzywuzzy.FuzzySearch import java.util.Locale // If you find a way to use SettingsGeneral getCurrentLocale() @@ -112,8 +111,8 @@ object SubtitleHelper { for (lang in languages) { val score = maxOf( - FuzzySearch.ratio(lowLangName, lang.languageName.lowercase()), - FuzzySearch.ratio( + Levenshtein.ratio(lowLangName, lang.languageName.lowercase()), + Levenshtein.ratio( lowLangName, lang.nativeName.lowercase() ) ) From 647c27494497364a548aaea3465433ac040c86c8 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 27 May 2026 15:05:18 -0600 Subject: [PATCH 29/64] [skip ci] Move versionCode and versionName to version catalog --- app/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6c970157..a21e520a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,8 +103,8 @@ android { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 68 - versionName = "4.7.0" + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.versionName.get() manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96fbee050..c806df9a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,9 @@ minSdk = "23" compileSdk = "36" targetSdk = "36" +versionCode = "68" +versionName = "4.7.0" + [libraries] activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeDb" } From c1b6fc2eeb3c5e53ad1c9b06068d98ffd90c1ec8 Mon Sep 17 00:00:00 2001 From: PiterDev <71133634+PiterWeb@users.noreply.github.com> Date: Thu, 28 May 2026 12:46:49 +0200 Subject: [PATCH 30/64] Fix Updated/ReleaseDate/Rating sorting for Kitsu SyncProvider (#2780) --- .../syncproviders/providers/KitsuApi.kt | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt index 29c3c0c17..e15a77c64 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -27,9 +27,8 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import java.text.SimpleDateFormat -import java.time.Instant import java.time.LocalDate -import java.time.format.DateTimeFormatter +import java.time.ZoneId import java.util.Date import java.util.Locale @@ -202,7 +201,7 @@ class KitsuApi: SyncAPI() { id = id, totalEpisodes = anime.episodeCount, title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), - publicScore = Score.from(anime.ratingTwenty.toString(), 20), + publicScore = Score.from(anime.ratingTwenty, 20), duration = anime.episodeLength, synopsis = anime.synopsis, airStatus = when(anime.status) { @@ -250,7 +249,7 @@ class KitsuApi: SyncAPI() { } return SyncStatus( - score = Score.from(anime.ratingTwenty.toString(), 20), + score = Score.from(anime.ratingTwenty, 20), status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), isFavorite = null, watchedEpisodes = anime.progress, @@ -454,8 +453,8 @@ class KitsuApi: SyncAPI() { private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { - val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","episodeCount") - val libraryEntriesSelectedFields = arrayOf("progress","rating","updatedAt", "status") + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount") + val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status") val limit = 500 var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" @@ -526,7 +525,7 @@ class KitsuApi: SyncAPI() { this.id, this.attributes.progress, numEpisodes, - Score.from(this.attributes.ratingTwenty.toString(), 20), + Score.from(this.attributes.ratingTwenty, 20), parseDateLong(this.attributes.updatedAt), "Kitsu", TvType.Anime, @@ -535,12 +534,9 @@ class KitsuApi: SyncAPI() { null, plot = synopsis, releaseDate = if (startDate == null) null else try { - Date.from( - Instant.from( - DateTimeFormatter.ofPattern(if (startDate.length == 4) "yyyy" else if (startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") - .parse(startDate) - ) - ) + Date.from(LocalDate.parse(startDate).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant()) } catch (_: RuntimeException) { null } @@ -583,7 +579,7 @@ class KitsuApi: SyncAPI() { @JsonProperty("avatar") val avatar: KitsuUserAvatar?, /* User list anime attributes */ @JsonProperty("progress") val progress: Int?, - @JsonProperty("ratingTwenty") val ratingTwenty: Float?, + @JsonProperty("ratingTwenty") val ratingTwenty: Int?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("status") val status: String?, ) @@ -632,7 +628,7 @@ class KitsuApi: SyncAPI() { const val KITSU_CACHED_LIST: String = "kitsu_cached_list" private fun parseDateLong(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { From 028a794ea5708786e81025781f2cf51c336a81f9 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 28 May 2026 15:16:31 -0600 Subject: [PATCH 31/64] Add support for using kotlinx-serialization rather than Jackson (#2791) --- app/build.gradle.kts | 5 + .../cloudstream3/SerializationClassTester.kt | 127 ++++++++++++++ .../utils/serializers/SerializerTest.kt | 157 ++++++++++++++++++ .../cloudstream3/plugins/PluginManager.kt | 2 +- .../syncproviders/providers/SimklApi.kt | 9 +- .../cloudstream3/ui/ControllerActivity.kt | 8 +- .../ui/result/ResultViewModel2.kt | 5 +- .../cloudstream3/utils/BackupUtils.kt | 14 +- .../lagradost/cloudstream3/utils/DataStore.kt | 36 ++-- .../lagradost/cloudstream3/utils/SyncUtil.kt | 6 +- .../utils/serializers/UriSerializer.kt | 40 +++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 8 + library/build.gradle.kts | 2 + .../com/lagradost/cloudstream3/MainAPI.kt | 8 + .../lagradost/cloudstream3/MainActivity.kt | 26 ++- .../extractors/HDMomPlayerExtractor.kt | 11 +- .../extractors/VideoSeyredExtractor.kt | 9 +- .../lagradost/cloudstream3/utils/AppUtils.kt | 66 +++++++- .../utils/serializers/NonEmptySerializer.kt | 47 ++++++ .../utils/serializers/WriteOnlySerializer.kt | 38 +++++ 21 files changed, 549 insertions(+), 76 deletions(-) create mode 100644 app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt create mode 100644 app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a21e520a7..05b25237a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.serialization) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -206,9 +207,12 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) + androidTestImplementation(libs.classgraph) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.instancio.core) androidTestImplementation(libs.junit.ktx) + androidTestImplementation(libs.kotlin.test) // Android Core & Lifecycle implementation(libs.core.ktx) @@ -219,6 +223,7 @@ dependencies { implementation(libs.bundles.lifecycle) implementation(libs.bundles.navigation) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) // JSON Parser // Design & UI implementation(libs.preference.ktx) diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt new file mode 100644 index 000000000..0b19535cb --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt @@ -0,0 +1,127 @@ +package com.lagradost.cloudstream3 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import io.github.classgraph.ClassGraph +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import kotlinx.serialization.serializerOrNull +import org.instancio.Instancio +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmName +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class SerializationClassTester { + // Same as app, or using app reference + val jacksonMapper = mapper + val kotlinxMapper = json + + @Test + fun isIdenticalSerialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + + val jacksonJson = jacksonMapper.writeValueAsString(instance) + val kotlinxJson = serializeWithKotlinx(kClass, instance) + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical serialization for: ${kClass.jvmName}") + } + } + + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + @Test + fun isIdenticalDeserialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + // Convert to JSON to get example JSON object + // We prefer jackson here because the app may have many jackson JSON strings in local storage + val originalJson = jacksonMapper.writeValueAsString(instance) + + // Create an object from the JSON using kotlinx + val serializer = + kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass) + assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!") + val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson) + + // Create an object from the JSON using jackson + val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java) + + + // Deep inspect both object using the mapper toJson function. + // This deep equality check can be performed using other methods, but this just works. + val jacksonJson = mapperDecoded.toJson() + val kotlinxJson = kotlinxDecoded.toJson() + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical deserialization for: ${kClass.jvmName}") + } + } + + private fun findSerializableClasses(packageName: String): List> { + val context = InstrumentationRegistry + .getInstrumentation() + .targetContext + + return ClassGraph() + .enableClassInfo() + .enableAnnotationInfo() + .overrideClassLoaders(context.classLoader) + .acceptPackages(packageName) + .scan() + .getClassesWithAnnotation(Serializable::class.java.name) + .mapNotNull { runCatching { Class.forName(it.name, false, context.classLoader).kotlin }.getOrNull() } + } + + @OptIn(InternalSerializationApi::class) + @Suppress("UNCHECKED_CAST") + private fun serializeWithKotlinx( + kClass: KClass<*>, + value: Any + ): String { + val serializer = kClass.serializer() as KSerializer + return kotlinxMapper.encodeToString(serializer, value) + } +} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt new file mode 100644 index 000000000..15ad532f8 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt @@ -0,0 +1,157 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = NonEmptyData.Serializer::class) +data class NonEmptyData( + val title: String = "", + val tags: List = emptyList(), + val meta: Map = emptyMap(), + val name: String = "hello", +) { + object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = WriteOnlyData.Serializer::class) +data class WriteOnlyData( + val fieldA: String = "", + val fieldB: String = "", +) { + object Serializer : WriteOnlySerializer( + WriteOnlyData.generatedSerializer(), + setOf("fieldB"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = MultiWriteOnly.Serializer::class) +data class MultiWriteOnly( + val fieldA: String = "", + val fieldB: String = "", + val fieldC: String = "", +) { + object Serializer : WriteOnlySerializer( + MultiWriteOnly.generatedSerializer(), + setOf("fieldB", "fieldC"), + ) +} + +@Serializable +data class UriData( + @Serializable(with = UriSerializer::class) + val uri: Uri = Uri.EMPTY, +) + +class SerializerTest { + + @Test + fun nonEmptySerializerOmitsEmptyStrings() { + val data = NonEmptyData(title = "", name = "hello") + val result = data.toJson() + assertFalse(result.contains("title")) + assertTrue(result.contains("name")) + } + + @Test + fun nonEmptySerializerOmitsEmptyLists() { + val data = NonEmptyData(tags = emptyList(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("tags")) + } + + @Test + fun nonEmptySerializerOmitsEmptyMaps() { + val data = NonEmptyData(meta = emptyMap(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("meta")) + } + + @Test + fun nonEmptySerializerKeepsNonEmptyFields() { + val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) + val result = data.toJson() + assertTrue(result.contains("title")) + assertTrue(result.contains("tags")) + assertTrue(result.contains("meta")) + } + + @Test + fun nonEmptySerializerDoesNotAffectDeserialization() { + val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertEquals(listOf("a"), result.tags) + assertEquals(mapOf("k" to "v"), result.meta) + assertEquals("world", result.name) + } + + @Test + fun writeOnlySerializerOmitsFieldOnSerialize() { + val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + } + + @Test + fun writeOnlySerializerDeserializesNormally() { + val input = """{"fieldA":"hello","fieldB":"secret"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("secret", result.fieldB) + } + + @Test + fun writeOnlySerializerDeserializesMissingAsDefault() { + val input = """{"fieldA":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("", result.fieldB) + } + + @Test + fun writeOnlySerializerHandlesMultipleKeys() { + val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + assertFalse(result.contains("fieldC")) + } + + @Test + fun uriSerializerSerializesUriToString() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val result = data.toJson() + assertTrue(result.contains("https://example.com/path?query=1")) + } + + @Test + fun uriSerializerDeserializesStringToUri() { + val input = """{"uri":"https://example.com/path?query=1"}""" + val result = parseJson(input) + assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) + } + + @Test + fun uriSerializerRoundtripsCorrectly() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data.uri, decoded.uri) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index aff1a2f33..debd3f0eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -610,7 +610,7 @@ object PluginManager { return false } InputStreamReader(stream).use { reader -> - manifest = parseJson(reader, BasePlugin.Manifest::class.java) + manifest = parseJson(reader.readText()) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index c4095e2d8..84a498bb0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -16,7 +16,6 @@ import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mapper import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING @@ -30,6 +29,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.txt import java.math.BigInteger @@ -117,13 +117,8 @@ class SimklApi : SyncAPI() { * Gets cached object, if object is not fresh returns null and removes it from cache */ inline fun getKey(path: String): T? { - // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" - val type = mapper.typeFactory.constructParametricType( - SimklCacheWrapper::class.java, - T::class.java - ) val cache = getKey(SIMKL_CACHE_KEY, path)?.let { - mapper.readValue>(it, type) + tryParseJson>(it) } return if (cache?.isFresh() == true) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index f91d40f28..2aadfb13c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -12,9 +12,6 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import androidx.appcompat.app.AlertDialog -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions @@ -105,9 +102,6 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { - private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() - init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { @@ -449,4 +443,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index b48adbf4d..c519e0de2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -83,6 +83,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork @@ -1324,7 +1325,7 @@ class ResultViewModel2 : ViewModel() { episodeIds: Array, watchState: VideoWatchState ) { - val watchStateString = DataStore.mapper.writeValueAsString(watchState) + val watchStateString = watchState.toJson() episodeIds.forEach { if (getVideoWatchState(it.toInt()) != watchState) { editor.setKeyRaw( @@ -2705,4 +2706,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 88cb7481c..62426197e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -10,7 +10,6 @@ import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R @@ -21,11 +20,12 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream @@ -133,9 +133,7 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context?): BackupFile? { - if (context == null) return null - + private fun getBackup(context: Context): BackupFile { val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } @@ -214,7 +212,7 @@ object BackupUtils { fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(mapper.writeValueAsString(backupFile)) + printStream.print(backupFile.toJson()) showToast( R.string.backup_success, @@ -259,8 +257,8 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val restoredValue = - mapper.readValue(input) + val text = input.bufferedReader().readText() + val restoredValue = parseJson(text) restore( activity, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 0a1db85fa..02ee69791 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,17 +2,16 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.kotlinModule import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral import kotlin.reflect.KClass import kotlin.reflect.KProperty -import androidx.core.content.edit /** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -88,8 +87,18 @@ data class Editor( } object DataStore { - val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + // Extensions shouldn't have really been using this version of it, but it seems + // some have. Since there has always been a very easy alternative, we won't + // need to deprecate it that long, and should be able to fully remove it + // once extensions at least use the other version. + @Deprecated( + "Please do not use the mapper version from DataStore. Preferably use methods from AppUtils " + + "to parse JSON. However, you can use the stable-API version of the mapper at " + + "com.lagradost.cloudstream3.mapper to access the mapper directly if necessary.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith("com.lagradost.cloudstream3.mapper"), + ) + val mapper = com.lagradost.cloudstream3.mapper private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -99,7 +108,6 @@ object DataStore { return getPreferences(this) } - fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } @@ -165,17 +173,17 @@ object DataStore { fun Context.setKey(path: String, value: T) { try { getSharedPrefs().edit { - putString(path, mapper.writeValueAsString(value)) + putString(path, value?.toJsonLiteral()) } } catch (e: Exception) { logError(e) } } - fun Context.getKey(path: String, valueType: Class): T? { + fun Context.getKey(path: String, valueType: Class): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return null - return json.toKotlinObject(valueType) + return parseJson(json, valueType.kotlin) } catch (e: Exception) { return null } @@ -186,11 +194,11 @@ object DataStore { } inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) + return parseJson(this) } - fun String.toKotlinObject(valueType: Class): T { - return mapper.readValue(this, valueType) + fun String.toKotlinObject(valueType: Class): T { + return parseJson(this, valueType.kotlin) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -214,4 +222,4 @@ object DataStore { inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { return getKey(getFolderName(folder, path), defVal) ?: defVal } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index 539d5e1a4..6e74fa00a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import java.util.concurrent.TimeUnit object SyncUtil { @@ -71,7 +71,7 @@ object SyncUtil { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text - val mapped = parseJson(response) + val mapped = tryParseJson(response) val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id @@ -167,4 +167,4 @@ object SyncUtil { @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt new file mode 100644 index 000000000..7c73a6889 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt @@ -0,0 +1,40 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Custom KSerializer for Android's [Uri] type. + * + * Uri is an Android platform type and cannot be annotated with @Serializable directly. + * Registering it in a SerializersModule globally would require a custom module passed to + * every Json instance, which adds hidden coupling. This serializer is also used sparingly + * across the codebase, so the overhead of a global registration isn't justified. + * Instead, we keep it explicit so that each usage site opts in intentionally and the + * serialization behavior remains visible. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = UriSerializer::class) + * val uri: Uri, + * ) + */ +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri { + return Uri.parse(decoder.decodeString()) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e35c1f611..609a94b3a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.dokka) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c806df9a9..32e417e62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ annotation = "1.10.0" appcompat = "1.7.1" biometric = "1.4.0-alpha06" buildkonfigGradlePlugin = "0.18.0" +classgraph = "4.8.184" coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later colorpicker = "6b46b49" conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything @@ -17,6 +18,7 @@ desugar_jdk_libs_nio = "2.1.5" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" +instancioCore = "5.5.1" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" jsoup = "1.22.1" @@ -28,6 +30,7 @@ kotlinGradlePlugin = "2.3.20" kotlinxAtomicfu = "0.32.1" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" +kotlinxSerializationJson = "1.11.0" lifecycleKtx = "2.10.0" material = "1.14.0" media3 = "1.9.3" @@ -65,6 +68,7 @@ anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeD annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +classgraph = { group = "io.github.classgraph", name = "classgraph", version.ref = "classgraph" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } colorpicker = { module = "com.github.recloudstream:color-picker-android", version.ref = "colorpicker" } @@ -77,15 +81,18 @@ desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", vers espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +instancio-core = { group = "org.instancio", name = "instancio-core", version.ref = "instancioCore" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } json = { module = "org.json:json", version.ref = "json" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinGradlePlugin" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } @@ -128,6 +135,7 @@ buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfigG dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaGradlePlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinGradlePlugin" } [bundles] coil = ["coil", "coil-network-okhttp"] diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 228bf4994..0abd6f3de 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.android.multiplatform.library) alias(libs.plugins.buildkonfig) alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.serialization) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -58,6 +59,7 @@ kotlin { implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) // JSON Parser implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript implementation(libs.newpipeextractor) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index faff17077..5de3d3e38 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -30,6 +30,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.EnumSet import java.util.Locale +import kotlinx.serialization.json.Json import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue @@ -76,6 +77,13 @@ const val USER_AGENT = class ErrorLoadingException(message: String? = null) : Exception(message) //val baseHeader = mapOf("User-Agent" to USER_AGENT) + +@Prerelease +val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true +} + val mapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt index 4b163867d..127b075da 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -1,39 +1,33 @@ package com.lagradost.cloudstream3 -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ResponseParser import kotlin.reflect.KClass // Short name for requests client to make it nicer to use -private val jacksonResponseParser = object : ResponseParser { - val mapper: ObjectMapper = jacksonObjectMapper().configure( - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, - false - ) - +private val jsonResponseParser = object : ResponseParser { override fun parse(text: String, kClass: KClass): T { - return mapper.readValue(text, kClass.java) + return parseJson(text, kClass) } override fun parseSafe(text: String, kClass: KClass): T? { return try { - mapper.readValue(text, kClass.java) - } catch (e: Exception) { + parse(text, kClass) + } catch (_: Exception) { null } } override fun writeValueAsString(obj: Any): String { - return mapper.writeValueAsString(obj) + return obj.toJson() } } /** The default networking helper. This helper performs SSL checks. * If you need to make requests to websites with invalid SSL certificates use insecureApp instead. */ -var app = Requests(responseParser = jacksonResponseParser).apply { +var app = Requests(responseParser = jsonResponseParser).apply { defaultHeaders = mapOf("user-agent" to USER_AGENT) } @@ -41,6 +35,6 @@ var app = Requests(responseParser = jacksonResponseParser).apply { * This should NEVER be used for sensitive networking operations such as logins. Only use this when required. */ @Prerelease @UnsafeSSL -var insecureApp = Requests(responseParser = jacksonResponseParser).apply { +var insecureApp = Requests(responseParser = jsonResponseParser).apply { defaultHeaders = mapOf("user-agent" to USER_AGENT) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt index ea6fba73b..b80534db2 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt @@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.extractors +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.extractors.helper.AesHelper -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.parseJson open class HDMomPlayer : ExtractorApi() { override val name = "HDMomPlayer" @@ -32,7 +31,7 @@ open class HDMomPlayer : ExtractorApi() { val trackStr = Regex("""tracks:\[([^\]]+)""").find(iSource)?.groupValues?.get(1) if (trackStr != null) { - val tracks:List = jacksonObjectMapper().readValue("[${trackStr}]") + val tracks:List = parseJson>("[${trackStr}]") for (track in tracks) { if (track.file == null || track.label == null) continue @@ -68,4 +67,4 @@ open class HDMomPlayer : ExtractorApi() { @JsonProperty("language") val language: String?, @JsonProperty("default") val default: String? ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt index 583d92322..bc94ae0cc 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt @@ -2,12 +2,11 @@ package com.lagradost.cloudstream3.extractors +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.utils.* -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson open class VideoSeyred : ExtractorApi() { override val name = "VideoSeyred" @@ -20,7 +19,7 @@ open class VideoSeyred : ExtractorApi() { val videoUrl = "${mainUrl}/playlist/${videoId}.json" val responseRaw = app.get(videoUrl) - val responseList:List = jacksonObjectMapper().readValue(responseRaw.text) ?: throw ErrorLoadingException("VideoSeyred") + val responseList: List = tryParseJson>(responseRaw.text) ?: throw ErrorLoadingException("VideoSeyred") val response = responseList[0] for (track in response.tracks) { @@ -68,4 +67,4 @@ open class VideoSeyred : ExtractorApi() { @JsonProperty("label") val label: String? = null, @JsonProperty("default") val default: String? = null ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 6832ab8d2..96dda5b25 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -1,21 +1,37 @@ package com.lagradost.cloudstream3.utils import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.InternalAPI +import com.lagradost.cloudstream3.json import com.lagradost.cloudstream3.mapper -import java.io.Reader +import com.lagradost.cloudstream3.mvvm.logError +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.serializerOrNull +import kotlin.reflect.KClass +@OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) object AppUtils { - /** Any object as json string */ + /** Any object as a JSON string */ fun Any.toJson(): String { if (this is String) return this - return mapper.writeValueAsString(this) + return toJsonLiteral() } - inline fun parseJson(value: String): T { - return mapper.readValue(value) + inline fun parseJson(value: String): T { + return parseJson(value, T::class) } - inline fun parseJson(reader: Reader, valueType: Class): T { + @Deprecated( + "This overload was only ever used for BasePlugin.Manifest which has since been migrated. " + + "No other code should be using this. Use reader.readText() and call parseJson(String) instead.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith("parseJson(reader.readText())") + ) + inline fun parseJson(reader: java.io.Reader, valueType: Class): T { + // Reader-based parsing has no kotlinx equivalent, fall back to Jackson return mapper.readValue(reader, valueType) } @@ -26,4 +42,40 @@ object AppUtils { null } } -} \ No newline at end of file + + /** Sometimes we want to encode as JSON even if it is already a String. */ + @InternalAPI + fun Any.toJsonLiteral(): String { + // @Serializable generates a serializer at compile time; contextual serializers are + // registered manually in serializersModule, we need both to support all cases + val serializer = this::class.serializerOrNull() ?: json.serializersModule.getContextual(this::class) + return if (serializer != null) { + try { + @Suppress("UNCHECKED_CAST") + json.encodeToString(serializer as KSerializer, this) + } catch (e: SerializationException) { + logError(e) + mapper.writeValueAsString(this) + } + } else { + mapper.writeValueAsString(this) + } + } + + @InternalAPI + fun parseJson(value: String, kClass: KClass): T { + // @Serializable generates a serializer at compile time; contextual serializers are + // registered manually in serializersModule, we need both to support all cases + val serializer = kClass.serializerOrNull() ?: json.serializersModule.getContextual(kClass) + return if (serializer != null) { + try { + json.decodeFromString(serializer, value) + } catch (e: SerializationException) { + logError(e) + mapper.readValue(value, kClass.java) + } + } else { + mapper.readValue(value, kClass.java) + } + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt new file mode 100644 index 000000000..82de9f7f7 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt @@ -0,0 +1,47 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer + +/** + * Replicates Jackson's @JsonInclude(JsonInclude.Include.NON_EMPTY) behaviour. + * Strips null, empty strings, empty arrays, and empty objects from the serialized + * output. Requires the enclosing Json instance to have encodeDefaults = true, + * which is already in our default global Json instance. + * + * Usage: + * + * @OptIn(ExperimentalSerializationApi::class) + * @KeepGeneratedSerializer + * @Serializable(with = MyData.Serializer::class) + * data class MyData( + * val tags: List = emptyList(), + * val title: String = "", + * val meta: Map = emptyMap(), + * ) { + * object Serializer : NonEmptySerializer(MyData.generatedSerializer()) + * } + */ +@Prerelease +abstract class NonEmptySerializer(tSerializer: KSerializer) : + JsonTransformingSerializer(tSerializer) { + + override fun transformSerialize(element: JsonElement): JsonElement { + if (element !is JsonObject) return element + + return JsonObject(element.filterValues { value -> + when (value) { + is JsonPrimitive -> value.content.isNotEmpty() + is JsonArray -> value.isNotEmpty() + is JsonObject -> value.isNotEmpty() + JsonNull -> false + } + }) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt new file mode 100644 index 000000000..c7f412eaa --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer + +/** + * Replicates Jackson's @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) behaviour. + * Properties in [keysToIgnore] are deserialized normally but omitted from serialized output. + * + * Usage: + * + * @OptIn(ExperimentalSerializationApi::class) + * @KeepGeneratedSerializer + * @Serializable(with = MyData.Serializer::class) + * data class MyData( + * val fieldA: String = "", + * val fieldB: String = "", + * ) { + * object Serializer : WriteOnlySerializer( + * MyData.generatedSerializer(), + * setOf("fieldB"), + * ) + * } + */ +@Prerelease +abstract class WriteOnlySerializer( + tSerializer: KSerializer, + private val keysToIgnore: Set, +) : JsonTransformingSerializer(tSerializer) { + + override fun transformSerialize(element: JsonElement): JsonElement { + if (element !is JsonObject) return element + return JsonObject(element.filterKeys { it !in keysToIgnore }) + } +} From a124450ddcb496a20292d3e76cea42eec567c36e Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 29 May 2026 04:58:08 -0600 Subject: [PATCH 32/64] Migrate Java date utils in library to kotlinx-datetime (#2798) --- .github/workflows/pull_request.yml | 2 +- gradle/libs.versions.toml | 2 + library/build.gradle.kts | 5 + .../com/lagradost/cloudstream3/MainAPI.kt | 84 ++++++-- .../cloudstream3/extractors/Vicloud.kt | 3 +- .../cloudstream3/metaproviders/MyDramaList.kt | 16 +- .../metaproviders/TraktProvider.kt | 14 +- .../lagradost/cloudstream3/EpisodeDateTest.kt | 196 ++++++++++++++++++ 8 files changed, 281 insertions(+), 41 deletions(-) create mode 100644 library/src/commonTest/kotlin/com/lagradost/cloudstream3/EpisodeDateTest.kt diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 675ce3b2f..8f5c62866 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -27,7 +27,7 @@ jobs: cache-read-only: false - name: Run Gradle - run: ./gradlew assemblePrereleaseDebug lint + run: ./gradlew assemblePrereleaseDebug lint check - name: Upload Artifact uses: actions/upload-artifact@v7 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32e417e62..7b63a5ddc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ kotlinGradlePlugin = "2.3.20" kotlinxAtomicfu = "0.32.1" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" +kotlinxDatetime = "0.8.0" kotlinxSerializationJson = "1.11.0" lifecycleKtx = "2.10.0" material = "1.14.0" @@ -92,6 +93,7 @@ kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.re kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 0abd6f3de..ead73eec2 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -59,6 +59,7 @@ kotlin { implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) // JSON Parser implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript @@ -68,6 +69,10 @@ kotlin { // Deprecated; will be removed once extensions have time to migrate from using it implementation("me.xdrop:fuzzywuzzy:1.4.0") } + + commonTest.dependencies { + implementation(libs.kotlin.test) + } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 5de3d3e38..5d4deba24 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -25,16 +25,25 @@ import com.lagradost.nicehttp.RequestBodyTypes import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.format.DateTimeComponents +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +import kotlinx.datetime.format.char +import kotlinx.datetime.format.parse +import kotlinx.datetime.toInstant import java.net.URI -import java.text.SimpleDateFormat -import java.util.Date import java.util.EnumSet -import java.util.Locale import kotlinx.serialization.json.Json import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue import kotlin.math.roundToInt +import kotlin.time.Clock +import kotlin.time.Instant /** * API available only on prerelease builds. @@ -88,10 +97,10 @@ val mapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! object APIHolder { - val unixTime: Long - get() = System.currentTimeMillis() / 1000L val unixTimeMS: Long - get() = System.currentTimeMillis() + get() = Clock.System.now().toEpochMilliseconds() + val unixTime: Long + get() = unixTimeMS / 1000L val allProviders = atomicListOf() @@ -2512,15 +2521,45 @@ constructor( get() = score?.toInt(100) } +@OptIn(FormatStringsInDatetimeFormats::class) fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") { - try { - this.date = SimpleDateFormat(format, Locale.getDefault()).parse(date ?: return)?.time - } catch (e: Exception) { - logError(e) - } + if (date == null) return + this.date = runCatching { + // First try standard ISO 8601 (e.g. "2026-01-01T12:30:00.000Z", "2026-05-17T14:35+02:00") + runCatching { Instant.parse(date).toEpochMilliseconds() } + .getOrElse { + val fmt = DateTimeComponents.Format { byUnicodePattern(format) } + val components = DateTimeComponents.parse(date, fmt) + /** + * Try multiple conversions in order of precision for non-ISO-8601 formats, + * since the date string may or may not include time and/or timezone offset: + * 1. If the custom format produced a UTC offset (e.g. "2026-05-17 14:35+02:00"), use it directly + * 2. If it has time but no offset (e.g. "2026-05-17 14:35"), fall back to device timezone + * 3. If it's date-only (e.g. "2026-05-17"), use start of day in device timezone + */ + runCatching { components.toInstantUsingOffset().toEpochMilliseconds() } + .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() } + .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() } + } + }.onFailure { logError(it) }.getOrNull() } -fun Episode.addDate(date: Date?) { +@Prerelease +fun Episode.addDate(date: LocalDate?) { + this.date = date?.atStartOfDayIn(TimeZone.currentSystemDefault())?.toEpochMilliseconds() +} + +@Prerelease +fun Episode.addDate(date: Instant?) { + this.date = date?.toEpochMilliseconds() +} + +// Deprecate after next stable +/* @Deprecated( + message = "Use addDate with LocalDate, Instant, or String instead.", + level = DeprecationLevel.WARNING, +) */ +fun Episode.addDate(date: java.util.Date?) { this.date = date?.time } @@ -2657,6 +2696,27 @@ fun fetchUrls(text: String?): List { return linkRegex.findAll(text).map { it.value.trim().removeSurrounding("\"") }.toList() } +@Prerelease +fun isUpcoming(dateString: String?): Boolean { + return runCatching { + val fmt = DateTimeComponents.Format { + year(); char('-'); monthNumber(); char('-'); day() + } + val components = DateTimeComponents.parse(dateString ?: return false, fmt) + /** + * Try multiple conversions in order of precision, since the date string format + * may or may not include time and/or timezone offset information: + * 1. If the string has a UTC offset (e.g. "2026-05-17T14:35+02:00"), use it directly + * 2. If it has time but no offset (e.g. "2026-05-17T14:35"), fall back to device timezone + * 3. If it's date-only (e.g. "2026-05-17"), use start of day in device timezone + */ + val instant = runCatching { components.toInstantUsingOffset() } + .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()) } + .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()) } + Clock.System.now() < instant + }.onFailure { logError(it) }.getOrElse { false } +} + @Deprecated( "toRatingInt() is deprecated. Use new score API instead.", level = DeprecationLevel.ERROR diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt index 974549fcb..6b82ee454 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi @@ -21,7 +22,7 @@ open class Vicloud : ExtractorApi() { ) { val id = Regex("\"apiQuery\":\"(.*?)\"").find(app.get(url).text)?.groupValues?.getOrNull(1) app.get( - "$mainUrl/api/?$id=&_=${System.currentTimeMillis()}", + "$mainUrl/api/?$id=&_=$unixTimeMS", headers = mapOf( "X-Requested-With" to "XMLHttpRequest" ), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt index cf3e28a8d..e7e1175f0 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt @@ -18,6 +18,7 @@ import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.addDate import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.isUpcoming import com.lagradost.cloudstream3.mainPageOf import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.newEpisode @@ -30,8 +31,6 @@ import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import okhttp3.Interceptor import okhttp3.Response -import java.text.SimpleDateFormat -import java.util.Locale //Reference: https://mydramalist.github.io/MDL-API/ abstract class MyDramaListAPI : MainAPI() { @@ -192,17 +191,6 @@ abstract class MyDramaListAPI : MainAPI() { return this } - private fun isUpcoming(dateString: String?): Boolean { - return try { - val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - val dateTime = dateString?.let { format.parse(it)?.time } ?: return false - unixTimeMS < dateTime - } catch (t: Throwable) { - logError(t) - false - } - } - private fun getStatus(status: String?): ShowStatus? { return when (status) { "Airing" -> ShowStatus.Ongoing @@ -451,4 +439,4 @@ abstract class MyDramaListAPI : MainAPI() { @JsonProperty("date") val date: String? = null, @JsonProperty("airedDate") val airedDate: String? = null, ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index 8f921d75c..0e5aeb339 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -22,6 +22,7 @@ import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.addDate import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.isUpcoming import com.lagradost.cloudstream3.mainPageOf import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.newEpisode @@ -33,8 +34,6 @@ import com.lagradost.cloudstream3.newTvSeriesLoadResponse import com.lagradost.cloudstream3.newTvSeriesSearchResponse import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson -import java.text.SimpleDateFormat -import java.util.Locale open class TraktProvider : MainAPI() { override var name = "Trakt" @@ -292,17 +291,6 @@ open class TraktProvider : MainAPI() { ).text } - private fun isUpcoming(dateString: String?): Boolean { - return try { - val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - val dateTime = dateString?.let { format.parse(it)?.time } ?: return false - unixTimeMS < dateTime - } catch (t: Throwable) { - logError(t) - false - } - } - private fun getStatus(t: String?): ShowStatus { return when (t) { "returning series" -> ShowStatus.Ongoing diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/EpisodeDateTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/EpisodeDateTest.kt new file mode 100644 index 000000000..4dc56978e --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/EpisodeDateTest.kt @@ -0,0 +1,196 @@ +package com.lagradost.cloudstream3 + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EpisodeDateTest { + + private val api = object : MainAPI() { + override var name = "Test" + override var mainUrl = "https://test.com" + } + + private fun episode() = api.newEpisode("") + + @Test + fun addDateDefaultFormatParsesIsoDate() { + val ep = episode() + ep.addDate("2026-05-17") + val expected = LocalDate(2026, 5, 17) + .atStartOfDayIn(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateNullDoesNotSetDate() { + val ep = episode() + ep.addDate(null as String?) + assertNull(ep.date) + } + + @Test + fun addDateInvalidStringLeavesDateNull() { + val ep = episode() + ep.addDate("not-a-date") + assertNull(ep.date) + } + + @Test + fun addDateCustomFormatParsesSlashDate() { + val ep = episode() + ep.addDate("17/05/2026", "dd/MM/yyyy") + val expected = LocalDate(2026, 5, 17) + .atStartOfDayIn(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateIsoDateTimeWithOffsetUsesExactInstant() { + val ep = episode() + ep.addDate("2026-05-17T10:30:00.000+05:00", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + val expected = Instant.parse("2026-05-17T10:30:00.000+05:00").toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateUtcDateTimeUsesExactInstant() { + val ep = episode() + ep.addDate("2026-05-17T10:30:00.000Z", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + val expected = Instant.parse("2026-05-17T10:30:00.000Z").toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateDateTimeNoOffsetUsesSystemTimezone() { + val ep = episode() + ep.addDate("2026-05-17T10:30:00", "yyyy-MM-dd'T'HH:mm:ss") + val expected = LocalDateTime(2026, 5, 17, 10, 30, 0) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateLocalDateSetsCorrectEpochMillis() { + val ep = episode() + ep.addDate(LocalDate(2026, 5, 17)) + val expected = LocalDate(2026, 5, 17) + .atStartOfDayIn(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateNullLocalDateLeavesDateNull() { + val ep = episode() + ep.addDate(null as LocalDate?) + assertNull(ep.date) + } + + @Test + fun addDateInstantSetsCorrectEpochMillis() { + val ep = episode() + val instant = Instant.parse("2026-05-17T10:30:00Z") + ep.addDate(instant) + assertEquals(instant.toEpochMilliseconds(), ep.date) + } + + @Test + fun addDateNullInstantLeavesDateNull() { + val ep = episode() + ep.addDate(null as Instant?) + assertNull(ep.date) + } + + @Test + fun addDateIsoWithMillisAndZUsesExactInstant() { + val ep = episode() + ep.addDate("2026-01-01T12:30:00.000Z") + assertEquals(1767270600000L, ep.date) + } + + @Test + fun addDateIsoWithZNoMillisUsesExactInstant() { + val ep = episode() + ep.addDate("2026-01-01T12:30:00Z") + assertEquals(1767270600000L, ep.date) + } + + @Test + fun addDateIsoWithPositiveOffsetUsesExactInstant() { + val ep = episode() + ep.addDate("2026-05-17T14:35:00+02:00") + // 14:35 +02:00 = 12:35 UTC = 2026-05-17T12:35:00Z + assertEquals(1779021300000L, ep.date) + } + + @Test + fun addDateIsoWithNegativeOffsetUsesExactInstant() { + val ep = episode() + ep.addDate("2026-05-17T09:35:00-05:00") + // 09:35 -05:00 = 14:35 UTC = 2026-05-17T14:35:00Z + assertEquals(1779028500000L, ep.date) + } + + @Test + fun addDateCustomFormatWithOffsetUsesExactInstant() { + val ep = episode() + ep.addDate("17/05/2026 14:35+02:00", "dd/MM/yyyy HH:mmXXX") + // 14:35 +02:00 = 12:35 UTC = 2026-05-17T12:35:00Z + assertEquals(1779021300000L, ep.date) + } + + @Test + fun addDateCustomFormatDateTimeNoOffsetUsesSystemTimezone() { + val ep = episode() + ep.addDate("17/05/2026 14:35", "dd/MM/yyyy HH:mm") + val expected = LocalDateTime(2026, 5, 17, 14, 35, 0) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateCustomFormatDateOnlyUsesStartOfDay() { + val ep = episode() + ep.addDate("17/05/2026", "dd/MM/yyyy") + val expected = LocalDate(2026, 5, 17) + .atStartOfDayIn(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } +} + +class IsUpcomingTest { + + @Test + fun isUpcomingFutureDate() { + assertTrue(isUpcoming("2099-01-01")) + } + + @Test + fun isUpcomingPastDate() { + assertFalse(isUpcoming("2000-01-01")) + } + + @Test + fun isUpcomingNullReturnsFalse() { + assertFalse(isUpcoming(null)) + } + + @Test + fun isUpcomingInvalidStringReturnsFalse() { + assertFalse(isUpcoming("not-a-date")) + } +} From 041d21a486685eb3fda44461932d6615512eb22e Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 29 May 2026 11:30:55 -0600 Subject: [PATCH 33/64] Emergency patch (#2851) --- .../java/com/lagradost/cloudstream3/utils/InAppUpdater.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 8bcd1b88e..1973d4d6c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -93,9 +93,9 @@ object InAppUpdater { private suspend fun Activity.getReleaseUpdate(): Update { val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( + val response = parseJson>( app.get(url, headers = headers).text - ) + ).toList() val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") @@ -150,9 +150,9 @@ object InAppUpdater { "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( + val response = parseJson>( app.get(releaseUrl, headers = headers).text - ) + ).toList() val found = response.lastOrNull { rel -> rel.prerelease || rel.tagName == "pre-release" From 0728dd06a1d0f1629c8e6ebab739c0e364d0b25c Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 30 May 2026 19:09:43 -0600 Subject: [PATCH 34/64] [skip ci] Replace using Char constructor in a couple extractors (#2856) --- .../com/lagradost/cloudstream3/extractors/Gdriveplayer.kt | 4 ++-- .../kotlin/com/lagradost/cloudstream3/extractors/Userload.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt index 61c22e929..5fc55aac6 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt @@ -82,7 +82,7 @@ open class Gdriveplayer : ExtractorApi() { val password = Regex("null,['|\"](\\w+)['|\"]").first(eval) ?.split(Regex("\\D+")) ?.joinToString("") { - Char(it.toInt()).toString() + it.toInt().toChar().toString() }.let { Regex("var pass = \"(\\S+?)\"").first(it ?: return)?.toByteArray() } ?: throw ErrorLoadingException("can't find password") val decryptedData = cryptoAESHandler(data, password, false, "AES/CBC/NoPadding")?.let { getAndUnpack(it) }?.replace("\\", "") @@ -125,4 +125,4 @@ open class Gdriveplayer : ExtractorApi() { @JsonProperty("label") val label: String ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt index fad6e8571..08dcb634e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt @@ -69,7 +69,7 @@ open class Userload : ExtractorApi() { } var txtresult = "" subchar.forEach{ - txtresult = txtresult.plus(Char(it.toInt(8))) + txtresult = txtresult.plus(it.toInt(8).toChar()) } val val1 = Regex(""""morocco="((.|\\n)*?)"&mycountry="""").find(txtresult)?.groups?.get(1)?.value.toString().drop(1).dropLast(1) val val2 = txtresult.substringAfter("""&mycountry="+""").substringBefore(")") From 8e7569df5351a4cb4e4cf3ddfc3551d42af997ab Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 30 May 2026 19:47:46 -0600 Subject: [PATCH 35/64] Fix T::class in parseJson causing type erasure in some cases (#2852) --- .../cloudstream3/utils/InAppUpdater.kt | 4 +-- .../lagradost/cloudstream3/utils/AppUtils.kt | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 1973d4d6c..b01f6e07e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -103,9 +103,7 @@ object InAppUpdater { !rel.prerelease }.sortedWith(compareBy { release -> release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> - versionRegex.find( - it1 - )?.groupValues?.let { + versionRegex.find(it1)?.groupValues?.let { it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 96dda5b25..5cc91b6d6 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.json @@ -9,6 +10,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException +import kotlinx.serialization.serializer import kotlinx.serialization.serializerOrNull import kotlin.reflect.KClass @@ -21,7 +23,10 @@ object AppUtils { } inline fun parseJson(value: String): T { - return parseJson(value, T::class) + // serializer() preserves full generic type info (e.g. List) + // and must be resolved here while T is still reified, same for TypeReference + val serializer = try { serializer() } catch (_: Exception) { null } + return parseJson(value, T::class, serializer, object : TypeReference() {}) } @Deprecated( @@ -63,17 +68,28 @@ object AppUtils { } @InternalAPI - fun parseJson(value: String, kClass: KClass): T { + fun parseJson( + value: String, + kClass: KClass, + serializer: KSerializer? = null, + typeReference: TypeReference? = null, + ): T { // @Serializable generates a serializer at compile time; contextual serializers are // registered manually in serializersModule, we need both to support all cases - val serializer = kClass.serializerOrNull() ?: json.serializersModule.getContextual(kClass) - return if (serializer != null) { + val s = + serializer ?: kClass.serializerOrNull() ?: json.serializersModule.getContextual(kClass) + + // Prefer Kotlin Serialization over Jackson + if (s != null) { try { - json.decodeFromString(serializer, value) + return json.decodeFromString(s, value) } catch (e: SerializationException) { logError(e) - mapper.readValue(value, kClass.java) } + } + + return if (typeReference != null) { + mapper.readValue(value, typeReference) } else { mapper.readValue(value, kClass.java) } From b7f5826a19e20ec9709880bcc8893b03118ab215 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 30 May 2026 19:48:06 -0600 Subject: [PATCH 36/64] Add expect/actual for YoutubeExtractor (#2844) NewPipeExtractor won't work in non-JVM platforms. --- 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 ead73eec2..92a9330e8 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -63,7 +63,6 @@ kotlin { implementation(libs.kotlinx.serialization.json) // JSON Parser implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript - implementation(libs.newpipeextractor) implementation(libs.tmdb.java) // TMDB API v3 Wrapper Made with RetroFit // Deprecated; will be removed once extensions have time to migrate from using it @@ -73,6 +72,16 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) } + + // 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 000000000..345aa7185 --- /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 dd8511eae..fb310401a 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 000000000..345aa7185 --- /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() + } + } +} From 8e1b41ea61fef8417a455fe06126ad2ab9d4c81a Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 31 May 2026 16:49:23 -0600 Subject: [PATCH 37/64] Fix one instance of String(byteArray) I previously missed (#2859) --- .../com/lagradost/cloudstream3/extractors/Mvidoo.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt index 76f14d33b..84b818723 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt @@ -14,11 +14,10 @@ open class Mvidoo : ExtractorApi() { private fun String.decodeHex(): String { require(length % 2 == 0) { "Must have an even length" } - return String( - chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() - ) + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .decodeToString() } override suspend fun getUrl( From e1aacce93d942b12d726a423f3a1f1ea7d89f6c3 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 31 May 2026 16:53:37 -0600 Subject: [PATCH 38/64] ExtractorAPI: support Kotlin Uuid (#2855) --- app/build.gradle.kts | 1 + .../cloudstream3/ui/player/CS3IPlayer.kt | 15 ++-- .../cloudstream3/utils/ExtractorApi.kt | 79 ++++++++++++++++--- .../cloudstream3/utils/HlsPlaylistParser.kt | 4 +- 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 05b25237a..6c784f3ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -317,6 +317,7 @@ tasks.withType { optIn.addAll( "com.lagradost.cloudstream3.InternalAPI", "com.lagradost.cloudstream3.Prerelease", + "kotlin.uuid.ExperimentalUuidApi", ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index aa44b9235..d7e10c814 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -96,7 +96,7 @@ import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.CLEARKEY_UUID +import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount @@ -104,9 +104,9 @@ import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.PLAYREADY_UUID +import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName -import com.lagradost.cloudstream3.utils.WIDEVINE_UUID +import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import kotlinx.coroutines.delay import okhttp3.Interceptor @@ -118,6 +118,7 @@ import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession +import kotlin.uuid.toJavaUuid const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" @@ -1278,7 +1279,7 @@ class CS3IPlayer : IPlayer { item.drm?.let { drm -> when (drm.uuid) { - CLEARKEY_UUID -> { + CLEARKEY_DRM_UUID.toJavaUuid() -> { // Use headers from DrmMetadata for media requests val client = dataSourceFactory ?: throw IllegalArgumentException("Must supply onlineSource") @@ -1299,8 +1300,8 @@ class CS3IPlayer : IPlayer { .createMediaSource(item.mediaItem) } - WIDEVINE_UUID, - PLAYREADY_UUID -> { + WIDEVINE_DRM_UUID.toJavaUuid(), + PLAYREADY_DRM_UUID.toJavaUuid() -> { // Use headers from DrmMetadata for media requests val client = dataSourceFactory ?: throw IllegalArgumentException("Must supply onlineSource") @@ -1914,7 +1915,7 @@ class CS3IPlayer : IPlayer { drm = DrmMetadata( kid = link.kid, key = link.key, - uuid = link.uuid, + uuid = link.uuid.toJavaUuid(), kty = link.kty, licenseUrl = link.licenseUrl, keyRequestParameters = link.keyRequestParameters, diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 66055d7de..f42128b10 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -1,8 +1,11 @@ +@file:OptIn(ExperimentalUuidApi::class) + package com.lagradost.cloudstream3.utils import com.fasterxml.jackson.annotation.JsonIgnore import com.lagradost.cloudstream3.AudioFile import com.lagradost.cloudstream3.IDownloadableMinimum +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app @@ -314,8 +317,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import org.jsoup.Jsoup import java.net.URI -import java.util.UUID import kotlin.coroutines.cancellation.CancellationException +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid /** * For use in the ConcatenatingMediaSource. @@ -431,29 +437,43 @@ private fun inferTypeFromUrl(url: String): ExtractorLinkType { val INFER_TYPE: ExtractorLinkType? = null /** - * UUID for the ClearKey DRM scheme. + * [Uuid] for the ClearKey DRM scheme. * * * ClearKey is supported on Android devices running Android 5.0 (API Level 21) and up. */ -val CLEARKEY_UUID = UUID(-0x1d8e62a7567a4c37L, 0x781AB030AF78D30EL) +@Prerelease +val CLEARKEY_DRM_UUID = Uuid.fromLongs(-0x1d8e62a7567a4c37L, 0x781AB030AF78D30EL) /** - * UUID for the Widevine DRM scheme. + * [Uuid] for the Widevine DRM scheme. * * * Widevine is supported on Android devices running Android 4.3 (API Level 18) and up. */ -val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L) +@Prerelease +val WIDEVINE_DRM_UUID = Uuid.fromLongs(-0x121074568629b532L, -0x5c37d8232ae2de13L) /** - * UUID for the PlayReady DRM scheme. + * [Uuid] for the PlayReady DRM scheme. * * * PlayReady is supported on all AndroidTV devices. Note that most other Android devices do not * provide PlayReady support. */ -val PLAYREADY_UUID = UUID(-0x65fb0f8667bfbd7aL, -0x546d19a41f77a06bL) +@Prerelease +val PLAYREADY_DRM_UUID = Uuid.fromLongs(-0x65fb0f8667bfbd7aL, -0x546d19a41f77a06bL) + +// Deprecate after next stable + +// @Deprecated("Use CLEARKEY_DRM_UUID", ReplaceWith("CLEARKEY_DRM_UUID"), level = DeprecationLevel.WARNING) +val CLEARKEY_UUID = CLEARKEY_DRM_UUID.toJavaUuid() + +// @Deprecated("Use WIDEVINE_DRM_UUID", ReplaceWith("WIDEVINE_DRM_UUID"), level = DeprecationLevel.WARNING) +val WIDEVINE_UUID = WIDEVINE_DRM_UUID.toJavaUuid() + +// @Deprecated("Use PLAYREADY_DRM_UUID", ReplaceWith("PLAYREADY_DRM_UUID"), level = DeprecationLevel.WARNING) +val PLAYREADY_UUID = PLAYREADY_DRM_UUID.toJavaUuid() suspend fun newExtractorLink( source: String, @@ -476,15 +496,42 @@ suspend fun newExtractorLink( return builder } +// Deprecate after next stable +/* @Deprecated( + message = "Use Kotlin Uuid (kotlin.uuid.Uuid) instead of Java UUID.", + level = DeprecationLevel.WARNING, +) */ suspend fun newDrmExtractorLink( source: String, name: String, url: String, type: ExtractorLinkType? = null, - uuid: UUID, + uuid: java.util.UUID, initializer: suspend DrmExtractorLink.() -> Unit = { } ): DrmExtractorLink { + @Suppress("DEPRECATION_ERROR") + val builder = + DrmExtractorLink( + source = source, + name = name, + url = url, + uuid = uuid.toKotlinUuid(), + type = type ?: INFER_TYPE + ) + builder.initializer() + return builder +} + +@Prerelease +suspend fun newDrmExtractorLink( + source: String, + name: String, + url: String, + type: ExtractorLinkType? = null, + uuid: Uuid, + initializer: suspend DrmExtractorLink.() -> Unit = {}, +): DrmExtractorLink { @Suppress("DEPRECATION_ERROR") val builder = DrmExtractorLink( @@ -510,7 +557,7 @@ suspend fun newDrmExtractorLink( * @property type the type of the media, use [INFER_TYPE] if you want to auto infer the type from the url * @property kid Base64 value of The KID element (Key Id) contains the identifier of the key associated with a license. * @property key Base64 value of Key to be used to decrypt the media file. - * @property uuid Drm UUID [WIDEVINE_UUID], [PLAYREADY_UUID], [CLEARKEY_UUID] (by default) .. etc + * @property uuid Drm [Uuid] [WIDEVINE_DRM_UUID], [PLAYREADY_DRM_UUID], [CLEARKEY_DRM_UUID] (by default) .. etc * @property kty Key type "oct" (octet sequence) by default * @property keyRequestParameters Parameters that will used to request the key. * @see newDrmExtractorLink @@ -528,7 +575,7 @@ open class DrmExtractorLink private constructor( override var type: ExtractorLinkType, open var kid: String? = null, open var key: String? = null, - open var uuid: UUID, + open var uuid: Uuid, open var kty: String? = null, open var keyRequestParameters: HashMap, open var licenseUrl: String? = null, @@ -550,7 +597,7 @@ open class DrmExtractorLink private constructor( extractorData: String? = null, kid: String? = null, key: String? = null, - uuid: UUID = CLEARKEY_UUID, + uuid: Uuid = CLEARKEY_DRM_UUID, kty: String? = "oct", keyRequestParameters: HashMap = hashMapOf(), licenseUrl: String? = null, @@ -585,7 +632,7 @@ open class DrmExtractorLink private constructor( extractorData: String? = null, kid: String? = null, key: String? = null, - uuid: UUID = CLEARKEY_UUID, + uuid: Uuid = CLEARKEY_DRM_UUID, kty: String? = "oct", keyRequestParameters: HashMap = hashMapOf(), licenseUrl: String? = null, @@ -605,6 +652,14 @@ open class DrmExtractorLink private constructor( kty = kty, licenseUrl = licenseUrl, ) + + @Deprecated(message = "Use Kotlin Uuid", level = DeprecationLevel.HIDDEN) + fun setUuid(uuid: java.util.UUID) { + this.uuid = uuid.toKotlinUuid() + } + + @Deprecated(message = "Use Kotlin Uuid", level = DeprecationLevel.HIDDEN) + fun getUuid(): java.util.UUID = this.uuid.toJavaUuid() } /** Class holds extracted media info to be passed to the player. diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt index 898550b24..f6da18390 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt @@ -1179,7 +1179,7 @@ object HlsPlaylistParser { if (KEYFORMAT_WIDEVINE_PSSH_BINARY == keyFormat) { val uriString = parseStringAttr(line, REGEX_URI, variableDefinitions) return SchemeData( - uuid = WIDEVINE_UUID, + uuid = C.WIDEVINE_UUID, mimeType = MimeTypes.VIDEO_MP4, data = Base64.Default.decode(uriString.substring(uriString.indexOf(','))) ) @@ -2078,4 +2078,4 @@ object HlsPlaylistParser { sessionKeyDrmInitData = sessionKeyDrmInitData ) } -} \ No newline at end of file +} From 5de7f207f2807eb7b44532eed843a9bb43684f1a Mon Sep 17 00:00:00 2001 From: KingLucius Date: Sun, 31 May 2026 18:53:47 -0400 Subject: [PATCH 39/64] feat(MetaProviders): Fix Trakt (#2858) --- library/build.gradle.kts | 5 +++++ .../lagradost/cloudstream3/metaproviders/TraktProvider.kt | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 92a9330e8..1652970a6 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -103,6 +103,11 @@ buildkonfig { "MDL_API_KEY", (System.getenv("MDL_API_KEY") ?: localProperties["mdl.key"]).toString() ) + + buildConfigField( + FieldSpec.Type.STRING, + "TRAKT_CLIENT_ID", (System.getenv("TRAKT_CLIENT_ID") ?: localProperties["trakt.id"]).toString() + ) } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index 0e5aeb339..af037e589 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.metaproviders import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.api.BuildConfig import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.Actor import com.lagradost.cloudstream3.ActorData @@ -45,10 +46,10 @@ open class TraktProvider : MainAPI() { TvType.Anime, ) - private val traktClientId = - "d9f434f48b55683a279ffe88ddc68351cc04c9dc9372bd95af5de780b794e770" private val traktApiUrl = "https://api.trakt.tv" + val traktClientId: String = BuildConfig.TRAKT_CLIENT_ID + override val mainPage = mainPageOf( "$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now "$traktApiUrl/movies/popular" to "Popular Movies", //The most popular movies for all time From 0e16f429af8b831d07475f853c56e50b227de90c Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:04:36 +0200 Subject: [PATCH 40/64] Update TRAKT_CLIENT_ID (#2860) --- .github/workflows/prerelease.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index d9a20a04b..b5b17ba6a 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -62,6 +62,7 @@ jobs: SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - name: Create pre-release From 4836e2b3713d40714f50a8fd4f3258bee30511ed Mon Sep 17 00:00:00 2001 From: fgmitesh Date: Mon, 1 Jun 2026 04:37:22 +0530 Subject: [PATCH 41/64] fix: treat KEYCODE_ENTER as select for TV remotes (LG Magic Remote) (#2853) * Improve key event handling in FullScreenPlayer Refactor key event handling for play/pause and chapter skipping. * Handle KEYCODE_ENTER for SearchView focus --- .../com/lagradost/cloudstream3/CommonActivity.kt | 6 ++++-- .../cloudstream3/ui/player/FullScreenPlayer.kt | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index ed0aaf9b7..4ce09bd44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -579,8 +579,10 @@ object CommonActivity { // TODO: Figure out why removing the check for SearchAutoComplete seems // to break focus on TV as it shouldn't need to be used. + // Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote) + // send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button. @SuppressLint("RestrictedApi") - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && + if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) && (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) ) { showInputMethod(act.currentFocus?.findFocus()) @@ -601,4 +603,4 @@ object CommonActivity { } return null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 26706699b..4ba933e13 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -945,12 +945,18 @@ open class FullScreenPlayer : AbstractPlayerFragment( player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER -> { // space is not captured due to navigation player.handleEvent(CSPlayerEvent.PlayPauseToggle) } - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (isShowing) { + // KEYCODE_DPAD_CENTER and KEYCODE_ENTER both act as a "select/confirm" button. + // Some remotes (e.g. LG Magic Remote) send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER. + // When the player UI or a dialog is visible, we let the event pass through (return null) + // so the focused button/item can handle the click normally, rather than always toggling + // play/pause. Only when the UI is hidden do we treat it as a play/pause toggle. + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER -> { + if (isShowing || isDialogOpen()) { return null } // If UI is not shown make click instantly skip to next chapter even if locked From 8e0c664b1ebe5dd4996ba9d68a26466dda31a761 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 31 May 2026 17:10:03 -0600 Subject: [PATCH 42/64] Add TRAKT_CLIENT_ID to Archive build as well (#2861) --- .github/workflows/build_to_archive.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index b5960d5d9..30bedcc1b 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -71,6 +71,7 @@ jobs: SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - uses: actions/checkout@v6 From 62662cb06460ae521f0bd51a587cab24943d6f0b Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 31 May 2026 17:24:31 -0600 Subject: [PATCH 43/64] Bump a few libs (#2840) --- gradle/libs.versions.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b63a5ddc..b93c4474c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,8 @@ androidGradlePlugin = "9.1.1" animeDb = "1.0.2" annotation = "1.10.0" appcompat = "1.7.1" -biometric = "1.4.0-alpha06" -buildkonfigGradlePlugin = "0.18.0" +biometric = "1.4.0-alpha07" +buildkonfigGradlePlugin = "0.21.2" classgraph = "4.8.184" coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later colorpicker = "6b46b49" @@ -18,25 +18,25 @@ desugar_jdk_libs_nio = "2.1.5" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" -instancioCore = "5.5.1" +instancioCore = "5.6.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) -json = "20251224" +json = "20260522" jsoup = "1.22.1" junit = "4.13.2" junitKtx = "1.3.0" junitVersion = "1.3.0" juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.20" -kotlinxAtomicfu = "0.32.1" +kotlinxAtomicfu = "0.33.0" kotlinxCollectionsImmutable = "0.4.0" -kotlinxCoroutinesCore = "1.10.2" +kotlinxCoroutinesCore = "1.11.0" kotlinxDatetime = "0.8.0" kotlinxSerializationJson = "1.11.0" lifecycleKtx = "2.10.0" material = "1.14.0" media3 = "1.9.3" -navigationKtx = "2.9.7" -newpipeextractor = "v0.26.0" +navigationKtx = "2.9.8" +newpipeextractor = "v0.26.2" nextlibMedia3 = "1.9.3-0.12.0" nicehttp = "0.4.18" overlappingpanels = "0.1.5" From 11f77fbe114aedef5259765afc60de3eb7a5f002 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:11:54 +0200 Subject: [PATCH 44/64] Fixed parseJson inline problem --- .../kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 5cc91b6d6..7551f7652 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -67,6 +67,12 @@ object AppUtils { } } + @InternalAPI + fun parseJson( + value: String, + kClass: KClass + ): T = parseJson(value, kClass, null, null) + @InternalAPI fun parseJson( value: String, From 3844c896f16fcfbf3bd691d16c513bca8c5ed19a Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:14:38 +0200 Subject: [PATCH 45/64] Fixed Json again --- .../lagradost/cloudstream3/utils/AppUtils.kt | 114 +++++++++--------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 7551f7652..3d9cc8944 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.utils -import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.json @@ -22,16 +21,64 @@ object AppUtils { return toJsonLiteral() } + /** Sometimes we want to encode as JSON even if it is already a String. */ + @InternalAPI + fun Any.toJsonLiteral(): String { + // @Serializable generates a serializer at compile time; contextual serializers are + // registered manually in serializersModule, we need both to support all cases + val serializer = + this::class.serializerOrNull() ?: json.serializersModule.getContextual(this::class) + return if (serializer != null) { + try { + @Suppress("UNCHECKED_CAST") + json.encodeToString(serializer as KSerializer, this) + } catch (e: SerializationException) { + logError(e) + mapper.writeValueAsString(this) + } + } else { + mapper.writeValueAsString(this) + } + } + + @InternalAPI + fun parseJson(value: String, kClass: KClass): T { + val serializer = kClass.serializerOrNull() ?: json.serializersModule.getContextual(kClass) + if (serializer != null) { + try { + return json.decodeFromString(serializer, value) + } catch (e: SerializationException) { + logError(e) + } + } + + return mapper.readValue(value, kClass.java) + } + + // This is inlined code and can easily cause breakage in extensions! + // Watch out when editing this to make sure stable also supports all inlined code! inline fun parseJson(value: String): T { - // serializer() preserves full generic type info (e.g. List) - // and must be resolved here while T is still reified, same for TypeReference - val serializer = try { serializer() } catch (_: Exception) { null } - return parseJson(value, T::class, serializer, object : TypeReference() {}) + // @Serializable generates a serializer at compile time; contextual serializers are + // registered manually in serializersModule, we need both to support all cases + val serializer = runCatching { serializer() }.runCatching { + json.serializersModule.getContextual(T::class) + }.getOrNull() + + // Prefer Kotlin Serialization over Jackson + if (serializer != null) { + try { + return json.decodeFromString(serializer, value) + } catch (e: SerializationException) { + logError(e) + } + } + + return mapper.readValue(value) } @Deprecated( "This overload was only ever used for BasePlugin.Manifest which has since been migrated. " + - "No other code should be using this. Use reader.readText() and call parseJson(String) instead.", + "No other code should be using this. Use reader.readText() and call parseJson(String) instead.", level = DeprecationLevel.ERROR, replaceWith = ReplaceWith("parseJson(reader.readText())") ) @@ -47,57 +94,4 @@ object AppUtils { null } } - - /** Sometimes we want to encode as JSON even if it is already a String. */ - @InternalAPI - fun Any.toJsonLiteral(): String { - // @Serializable generates a serializer at compile time; contextual serializers are - // registered manually in serializersModule, we need both to support all cases - val serializer = this::class.serializerOrNull() ?: json.serializersModule.getContextual(this::class) - return if (serializer != null) { - try { - @Suppress("UNCHECKED_CAST") - json.encodeToString(serializer as KSerializer, this) - } catch (e: SerializationException) { - logError(e) - mapper.writeValueAsString(this) - } - } else { - mapper.writeValueAsString(this) - } - } - - @InternalAPI - fun parseJson( - value: String, - kClass: KClass - ): T = parseJson(value, kClass, null, null) - - @InternalAPI - fun parseJson( - value: String, - kClass: KClass, - serializer: KSerializer? = null, - typeReference: TypeReference? = null, - ): T { - // @Serializable generates a serializer at compile time; contextual serializers are - // registered manually in serializersModule, we need both to support all cases - val s = - serializer ?: kClass.serializerOrNull() ?: json.serializersModule.getContextual(kClass) - - // Prefer Kotlin Serialization over Jackson - if (s != null) { - try { - return json.decodeFromString(s, value) - } catch (e: SerializationException) { - logError(e) - } - } - - return if (typeReference != null) { - mapper.readValue(value, typeReference) - } else { - mapper.readValue(value, kClass.java) - } - } -} +} \ No newline at end of file From a4a4c31f8d4ccf41505d318023bbb462004436bb Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:09:30 -0600 Subject: [PATCH 46/64] Replace toByteArray() in some places in library (#2866) --- .../lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt | 2 +- .../lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt | 2 +- .../kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt index 3c79baf3a..7f6d98049 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt @@ -27,7 +27,7 @@ open class CloudMailRu : ExtractorApi() { "Origin" to mainUrl, "User-Agent" to USER_AGENT, ) - val vidId = url.substringAfter("public/").toByteArray() + val vidId = url.substringAfter("public/").encodeToByteArray() val vidIdEnc = base64Encode(vidId) val videoReq = app.get(url, headers=headers).text val regex = Regex(pattern = "videowl_view\":\\{\"count\":\"1\",\"url\":\"([^\"]*)\"\\}", options = setOf(RegexOption.IGNORE_CASE)) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt index b80534db2..1ccd3e4d5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt @@ -23,7 +23,7 @@ open class HDMomPlayer : ExtractorApi() { if (bePlayer != null) { val bePlayerPass = bePlayer.get(1) val bePlayerData = bePlayer.get(2) - val encrypted = AesHelper.cryptoAESHandler(bePlayerData, bePlayerPass.toByteArray(), false)?.replace("\\", "") ?: throw ErrorLoadingException("failed to decrypt") + val encrypted = AesHelper.cryptoAESHandler(bePlayerData, bePlayerPass.encodeToByteArray(), false)?.replace("\\", "") ?: throw ErrorLoadingException("failed to decrypt") m3uLink = Regex("""video_location\":\"([^\"]+)""").find(encrypted)?.groupValues?.get(1) } else { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt index 23226418b..b203fd338 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -328,7 +328,7 @@ object M3u8Helper2 { encryptionUri = "${getParentLink(playlistStream.streamUrl)}/$encryptionUri" } - encryptionIv = match[3].toByteArray() + encryptionIv = match[3].encodeToByteArray() val encryptionKeyResponse = app.get(encryptionUri, headers = playlistStream.headers, verify = false) val body = encryptionKeyResponse.body From 70053ebbae1b0e5a6c7b79063209582d79fbc339 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:48:37 +0200 Subject: [PATCH 47/64] [skip ci] Fix serialization testing (#2896) --- .../cloudstream3/SerializationClassTester.kt | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt index 0b19535cb..d1a11e003 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt @@ -3,7 +3,7 @@ package com.lagradost.cloudstream3 import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.lagradost.cloudstream3.utils.AppUtils.toJson -import io.github.classgraph.ClassGraph +import dalvik.system.DexFile import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer @@ -100,19 +100,27 @@ class SerializationClassTester { } } + // DEX files are the best solution to read all our classes dynamically. + // ClassGraph() can be used instead, but it only gives results on the JVM, not Android. + @Suppress("DEPRECATION") private fun findSerializableClasses(packageName: String): List> { val context = InstrumentationRegistry .getInstrumentation() .targetContext - return ClassGraph() - .enableClassInfo() - .enableAnnotationInfo() - .overrideClassLoaders(context.classLoader) - .acceptPackages(packageName) - .scan() - .getClassesWithAnnotation(Serializable::class.java.name) - .mapNotNull { runCatching { Class.forName(it.name, false, context.classLoader).kotlin }.getOrNull() } + val dexFile = DexFile(context.packageCodePath) + + return dexFile.entries() + .toList() + .filter { it.startsWith(packageName) } + .mapNotNull { + runCatching { Class.forName(it).kotlin }.getOrNull() + }.filter { kClass -> + // Not possible to use .hasAnnotation() on newer Android versions. + kClass.java.annotations.any { + it is Serializable + } + } } @OptIn(InternalSerializationApi::class) From d78b991d663d75709ed8f52abdb64c8afa570ee8 Mon Sep 17 00:00:00 2001 From: KingLucius Date: Mon, 8 Jun 2026 00:59:42 +0300 Subject: [PATCH 48/64] Trakt meta provider logo support (#2894) --- .../com/lagradost/cloudstream3/metaproviders/TraktProvider.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index af037e589..59dcd2711 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -25,7 +25,6 @@ import com.lagradost.cloudstream3.addDate import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.isUpcoming import com.lagradost.cloudstream3.mainPageOf -import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.newEpisode import com.lagradost.cloudstream3.newHomePageResponse import com.lagradost.cloudstream3.newMovieLoadResponse @@ -114,6 +113,7 @@ open class TraktProvider : MainAPI() { val posterUrl = fixPath(mediaDetails?.images?.poster?.firstOrNull()) val backDropUrl = fixPath(mediaDetails?.images?.fanart?.firstOrNull()) + val logoUrl = fixPath(mediaDetails?.images?.logo?.firstOrNull()) val resActor = getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=full,images") @@ -183,6 +183,7 @@ open class TraktProvider : MainAPI() { this.comingSoon = isUpcoming(mediaDetails.released) //posterHeaders this.backgroundPosterUrl = backDropUrl + this.logoUrl = logoUrl this.contentRating = mediaDetails.certification addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) @@ -273,6 +274,7 @@ open class TraktProvider : MainAPI() { //posterHeaders this.nextAiring = nextAir this.backgroundPosterUrl = backDropUrl + this.logoUrl = logoUrl this.contentRating = mediaDetails.certification addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) From 79cc3fb501566804fa43abe85e691478c89dd167 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:22:34 -0600 Subject: [PATCH 49/64] Fix kotlinx serialization in parseJson (#2902) It doing runCatching on the Result itself, that results in the serializer always returning null. Just do what we do everywhere else here also, getContextual doesn't throw anything. --- .../kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 3d9cc8944..6233fd820 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -60,9 +60,8 @@ object AppUtils { inline fun parseJson(value: String): T { // @Serializable generates a serializer at compile time; contextual serializers are // registered manually in serializersModule, we need both to support all cases - val serializer = runCatching { serializer() }.runCatching { - json.serializersModule.getContextual(T::class) - }.getOrNull() + val serializer = runCatching { serializer() }.getOrNull() + ?: json.serializersModule.getContextual(T::class) // Prefer Kotlin Serialization over Jackson if (serializer != null) { @@ -94,4 +93,4 @@ object AppUtils { null } } -} \ No newline at end of file +} From fd579fcc18b053be93b7883d893af14cc2d40879 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Mon, 8 Jun 2026 22:22:50 +0200 Subject: [PATCH 50/64] Translated using Weblate (German) Currently translated at 100.0% (729 of 729 strings) Translated using Weblate (German) Currently translated at 100.0% (729 of 729 strings) Translated using Weblate (Macedonian) Currently translated at 100.0% (729 of 729 strings) Co-authored-by: Deleted User Co-authored-by: Hosted Weblate Co-authored-by: WertZuz <97708601+wertzuz@users.noreply.github.com> Co-authored-by: stojkovskistefan Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/mk/ Translation: Cloudstream/App --- app/src/main/res/values-b+de/strings.xml | 12 +++++--- app/src/main/res/values-b+mk/strings.xml | 37 ++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-b+de/strings.xml b/app/src/main/res/values-b+de/strings.xml index 6ccb9bc69..e674fafd6 100644 --- a/app/src/main/res/values-b+de/strings.xml +++ b/app/src/main/res/values-b+de/strings.xml @@ -252,7 +252,7 @@ Update Bevorzugte Videoqualität (WLAN) Videoplayertitel max. Zeichen - Playerinformationen anzeigen + Zeige Playerinformationen Videopuffergröße Videopufferlänge Video-Cache in Speicher @@ -587,7 +587,7 @@ Sicherheit Konten Repository öffnen - Besuche%s auf dem Smartphone oder Computer und gebe den obenstehenden Code ein + Besuche %s auf dem Smartphone oder Computer und gebe den obenstehenden Code ein PIN-Code vom Gerät nicht abrufbar, versuche lokale Authentifizierung Zur Zeit sind keine Downloads verfügbar. Lokales Video öffnen @@ -712,8 +712,8 @@ Zusätzliche Helligkeit Aktiviere Helligkeitsfilter, wenn 100% Bildschirmhelligkeit überschritten ist Erhöhte Helligkeit aktiviert - Cast-Panel zeigen - Medieninfo + Zeige Cast-Panel + Mediainfo Quellname Alle herunterladen Möchtest du Episode %s herunter laden? @@ -731,4 +731,8 @@ Es befinden sich keine Downloads in der Warteschlange. Quellpriorität Entscheide, wie Videoquellen im Player sortiert werden sollen + Zeige Player-Metadaten + Video + Vorschau + Live diff --git a/app/src/main/res/values-b+mk/strings.xml b/app/src/main/res/values-b+mk/strings.xml index ea467833e..4e37afdea 100644 --- a/app/src/main/res/values-b+mk/strings.xml +++ b/app/src/main/res/values-b+mk/strings.xml @@ -244,7 +244,7 @@ TC Претплатен на %s Преводи - Предупредување: CloudStream 3 не презема никаква одговорност за користење на екстензии од трети страни и не обезбедува никаква поддршка за нив! + Предупредување: CloudStream не презема никаква одговорност за користење на екстензии од трети страни и не обезбедува никаква поддршка за нив! Недостасуваат дозволи за складирање. Обиди се повторно. Зачувај Вчитај од датотека @@ -445,7 +445,7 @@ Грешка при правење резервна копија на %s Сокриј го избраниот квалитет на видеото во резултатите од пребарувањето Некои уреди не го поддржуваат новиот инсталатор на пакети. Испробај ја легаси(старата) опција, ако ажурирањата не се инсталираат. - Резолуција на видео плеер + Прикажи информации за плеерот Големина на видео баферот Распоред Стандардно @@ -705,4 +705,37 @@ Горе во центар Горе на десно Пушти ја целата серија + Редица за преземање + Моментално нема преземања во редицата. + Дополнителна осветленост + Овозможи филтер за осветленост кога ќе се надмине 100% осветленост на екранот + овозможена_дополнителна_осветленост + Предлози за пребарување + Прикажувај предлози за пребарување додека пишуваш + Исчисти предлози + Прикажи преклоп со метаподатоци на плеерот + Прикажи панел за емитување + Инсталирај предиздавачка верзија + Предиздавачката верзија е веќе инсталирана. + Неуспешна инсталација на предиздавачката верзија. + Видео + Текст на епизода + Информации за медиумот + Преглед + Приоритет на извор + Одреди како ќе се подредуваат видео изворите во плеерот + Име на изворот + Преземи сѐ + Откажи сѐ + Дали сакате да ја преземете епизодата %s? + Дали сакате да ги откажете сите преземања во редицата? + + %d активно преземање + %d активни преземања + + + %d преземање во редицата + %d преземања во редицата + + Во живо From f7cbf25b30b35d8d5a8b411cbe5de7a56fb8dbf0 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:03:48 -0600 Subject: [PATCH 51/64] Replace EnumSet for dubStatus (#2845) --- .../commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 5d4deba24..fc2fac005 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -36,7 +36,6 @@ import kotlinx.datetime.format.char import kotlinx.datetime.format.parse import kotlinx.datetime.toInstant import java.net.URI -import java.util.EnumSet import kotlinx.serialization.json.Json import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -1510,7 +1509,7 @@ constructor( override var posterUrl: String? = null, var year: Int? = null, - var dubStatus: EnumSet? = null, + var dubStatus: MutableSet? = null, var otherName: String? = null, var episodes: MutableMap = mutableMapOf(), @@ -1522,7 +1521,7 @@ constructor( ) : SearchResponse fun AnimeSearchResponse.addDubStatus(status: DubStatus, episodes: Int? = null) { - this.dubStatus = dubStatus?.also { it.add(status) } ?: EnumSet.of(status) + this.dubStatus = dubStatus?.also { it.add(status) } ?: mutableSetOf(status) if (this.type?.isMovieType() != true) if (episodes != null && episodes > 0) this.episodes[status] = episodes From 2181243dd14506a52921c5090c52ace690842a6e Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:08:58 -0600 Subject: [PATCH 52/64] Bump NewPipeExtractor to fix trailers and other YouTube videos (#2906) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b93c4474c..8456da66e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ lifecycleKtx = "2.10.0" material = "1.14.0" media3 = "1.9.3" navigationKtx = "2.9.8" -newpipeextractor = "v0.26.2" +newpipeextractor = "v0.26.3" nextlibMedia3 = "1.9.3-0.12.0" nicehttp = "0.4.18" overlappingpanels = "0.1.5" From 8012c580690fe9b2c67e7178ea29f780ccb02147 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:24:50 -0600 Subject: [PATCH 53/64] Set explicitNulls = false for kotlinx serialization (#2897) This is more inline with Jackson's behavior otherwise for nullable types with non default values, and they don't exist, gives a Missingfield exception whereas it worked on Jackson. We may need coerceInputValues = true also but I am unsure of that right now. --- .../src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index fc2fac005..fe3e03ef2 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -89,6 +89,7 @@ class ErrorLoadingException(message: String? = null) : Exception(message) @Prerelease val json = Json { encodeDefaults = true + explicitNulls = false ignoreUnknownKeys = true } From 18a857723b003891b70b8291f2f2e4f22ec7c9b4 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:26:49 -0600 Subject: [PATCH 54/64] Replace java.net.uri in library (#2839) --- gradle/libs.versions.toml | 2 + library/build.gradle.kts | 1 + .../network/WebViewResolver.android.kt | 5 +- .../com/lagradost/cloudstream3/MainAPI.kt | 37 ++-- .../cloudstream3/extractors/ByseSX.kt | 7 +- .../lagradost/cloudstream3/extractors/Cda.kt | 4 +- .../cloudstream3/extractors/CineMMRedirect.kt | 6 +- .../cloudstream3/extractors/Dailymotion.kt | 11 +- .../cloudstream3/extractors/DoodExtractor.kt | 6 +- .../cloudstream3/extractors/GDMirrorbot.kt | 4 +- .../cloudstream3/extractors/HubCloud.kt | 6 +- .../extractors/InternetArchive.kt | 4 +- .../cloudstream3/extractors/Streamplay.kt | 8 +- .../cloudstream3/extractors/VidStack.kt | 4 +- .../extractors/helper/GogoHelper.kt | 6 +- .../extractors/helper/NineAnimeHelper.kt | 10 +- .../cloudstream3/utils/ExtractorApi.kt | 7 +- .../cloudstream3/utils/HlsPlaylistParser.kt | 198 +++++++++--------- .../cloudstream3/utils/M3u8Helper.kt | 12 +- .../cloudstream3/utils/StringUtils.kt | 32 ++- .../cloudstream3/utils/UnshortenUrl.kt | 110 +++++----- 21 files changed, 247 insertions(+), 233 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8456da66e..f28bc41ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.11.0" kotlinxDatetime = "0.8.0" kotlinxSerializationJson = "1.11.0" +ktor = "3.5.0" lifecycleKtx = "2.10.0" material = "1.14.0" media3 = "1.9.3" @@ -95,6 +96,7 @@ kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collec kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +ktor-http = { module = "io.ktor:ktor-http", version.ref = "ktor" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 1652970a6..a1f30fede 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -61,6 +61,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) // JSON Parser + implementation(libs.ktor.http) implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript implementation(libs.tmdb.java) // TMDB API v3 Wrapper Made with RetroFit diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt index bc443b3f8..2f9c9b628 100644 --- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt +++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt @@ -15,12 +15,13 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.nicehttp.requestCreator +import io.ktor.http.Url +import io.ktor.http.decodeURLPart import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response -import java.net.URI /** * When used as Interceptor additionalUrls cannot be returned, use WebViewResolver(...).resolveUsingWebView(...) @@ -211,7 +212,7 @@ actual class WebViewResolver actual constructor( * */ return@runBlocking try { when { - blacklistedFiles.any { URI(webViewUrl).path.contains(it) } || webViewUrl.endsWith( + blacklistedFiles.any { Url(webViewUrl).encodedPath.decodeURLPart().contains(it) } || webViewUrl.endsWith( "/favicon.ico" ) -> WebResourceResponse( "image/png", diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index fe3e03ef2..4a9e0b10a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -22,6 +22,10 @@ import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.nicehttp.RequestBodyTypes +import io.ktor.http.Url +import io.ktor.http.URLBuilder +import io.ktor.http.encodedPath +import io.ktor.http.takeFrom import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody @@ -35,7 +39,6 @@ import kotlinx.datetime.format.byUnicodePattern import kotlinx.datetime.format.char import kotlinx.datetime.format.parse import kotlinx.datetime.toInstant -import java.net.URI import kotlinx.serialization.json.Json import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -176,9 +179,9 @@ object APIHolder { // To get the key suspend fun getCaptchaToken(url: String, key: String, referer: String? = null): String? { try { - val uri = URI.create(url) + val _url = Url(url) val domain = base64Encode( - (uri.scheme + "://" + uri.host + ":443").encodeToByteArray(), + (_url.protocol.name + "://" + _url.host + ":443").encodeToByteArray(), ).replace("\n", "").replace("=", ".") val vToken = @@ -1330,23 +1333,23 @@ fun getQualityFromString(string: String?): SearchQuality? { * ``` */ fun MainAPI.updateUrl(url: String): String { - try { - val original = URI(url) - val updated = URI(mainUrl) + return try { + val original = Url(url) + val updated = Url(mainUrl) - // URI(String scheme, String userInfo, String host, int port, String path, String query, String fragment) - return URI( - updated.scheme, - original.userInfo, - updated.host, - updated.port, - original.path, - original.query, - original.fragment - ).toString() + URLBuilder().apply { + takeFrom(updated) + user = original.user + password = original.password + encodedPath = original.encodedPath + fragment = original.fragment + + parameters.clear() + parameters.appendAll(original.parameters) + }.buildString() } catch (t: Throwable) { logError(t) - return url + url } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt index cc7293b80..b29d29f5d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt @@ -8,7 +8,8 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper -import java.net.URI +import io.ktor.http.Url +import io.ktor.http.decodeURLPart import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec @@ -45,11 +46,11 @@ open class ByseSX : ExtractorApi() { } private fun getBaseUrl(url: String): String { - return URI(url).let { "${it.scheme}://${it.host}" } + return Url(url).let { "${it.protocol.name}://${it.host}" } } private fun getCodeFromUrl(url: String): String { - val path = URI(url).path ?: "" + val path = Url(url).encodedPath.decodeURLPart() return path.trimEnd('/').substringAfterLast('/') } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt index 4b7f8a1cd..5c9f58efc 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt @@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.StringUtils.decodeUri +import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl import com.lagradost.cloudstream3.utils.newExtractorLink open class Cda : ExtractorApi() { @@ -64,7 +64,7 @@ open class Cda : ExtractorApi() { .replace("_QWE", "") .replace("_Q5", "") .replace("_IKSDE", "") - a = a.decodeUri() + a = a.decodeUrl() a = a.map { char -> if (char.code in 33..126) { return@map (33 + (char.code + 14) % 94).toChar().toString() diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt index 62c450073..a85aff8d4 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt @@ -4,7 +4,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.loadExtractor -import okhttp3.HttpUrl.Companion.toHttpUrl +import io.ktor.http.Url // deobfuscated from https://hglink.to/main.js?v=1.1.3 using https://deobfuscate.io/ private val mirrors = arrayOf( @@ -90,7 +90,7 @@ abstract class CineMMRedirect : ExtractorApi() { subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit ) { - val videoId = url.toHttpUrl().encodedPath + val videoId = Url(url).encodedPath val mirror = mirrors.random() // re-use existing extractors by calling the ExtractorApi @@ -98,4 +98,4 @@ abstract class CineMMRedirect : ExtractorApi() { val mirrorUrlWithVideoId = "https://$mirror$videoId" loadExtractor(mirrorUrlWithVideoId, referer, subtitleCallback, callback) } -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt index db6db39d5..4732cafcf 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt @@ -7,9 +7,8 @@ import com.lagradost.cloudstream3.newSubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import java.net.URI - - +import io.ktor.http.Url +import io.ktor.http.decodeURLPart class Geodailymotion : Dailymotion() { override val name = "GeoDailymotion" @@ -57,7 +56,6 @@ open class Dailymotion : ExtractorApi() { } } - private fun getEmbedUrl(url: String): String? { if (url.contains("/embed/") || url.contains("/video/")) return url if (url.contains("geo.dailymotion.com")) { @@ -67,9 +65,8 @@ open class Dailymotion : ExtractorApi() { return null } - private fun getVideoId(url: String): String? { - val path = URI(url).path + val path = Url(url).encodedPath.decodeURLPart() val id = path.substringAfter("/video/") return if (id.matches(videoIdRegex)) id else null } @@ -82,7 +79,6 @@ open class Dailymotion : ExtractorApi() { return generateM3u8(name, streamLink, "").forEach(callback) } - data class MetaData( val qualities: Map>?, val subtitles: SubtitlesWrapper? @@ -102,5 +98,4 @@ open class Dailymotion : ExtractorApi() { val label: String, val urls: List ) - } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt index 12bc5a0c5..bce017276 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.newExtractorLink -import java.net.URI +import io.ktor.http.Url class Doodspro : DoodLaExtractor() { override var mainUrl = "https://doods.pro" @@ -138,8 +138,6 @@ open class DoodLaExtractor : ExtractorApi() { } private fun getBaseUrl(url: String): String { - return URI(url).let { - "${it.scheme}://${it.host}" - } + return Url(url).let { "${it.protocol.name}://${it.host}" } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt index e0fefe8aa..ba297067e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.base64Decode import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.loadExtractor -import java.net.URI +import io.ktor.http.Url class Techinmind: GDMirrorbot() { override var name = "Techinmind Cloud AIO" @@ -103,7 +103,7 @@ open class GDMirrorbot : ExtractorApi() { } private fun getBaseUrl(url: String): String { - return URI(url).let { "${it.scheme}://${it.host}" } + return Url(url).let { "${it.protocol.name}://${it.host}" } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt index 4f83bad25..a974df15c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt @@ -9,7 +9,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.newExtractorLink -import java.net.URI +import io.ktor.http.Url class HubCloud : ExtractorApi() { override val name = "Hub-Cloud" @@ -24,7 +24,7 @@ class HubCloud : ExtractorApi() { ) { val tag = "HubCloud" val realUrl = url.takeIf { - try { URI(it).toURL(); true } catch (e: Exception) { Log.e(tag, "Invalid URL: ${e.message}"); false } + try { Url(it); true } catch (e: Exception) { Log.e(tag, "Invalid URL: ${e.message}"); false } } ?: return val baseUrl=getBaseUrl(realUrl) @@ -161,7 +161,7 @@ class HubCloud : ExtractorApi() { private fun getBaseUrl(url: String): String { return try { - URI(url).let { "${it.scheme}://${it.host}" } + Url(url).let { "${it.protocol.name}://${it.host}" } } catch (_: Exception) { "" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/InternetArchive.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/InternetArchive.kt index 40d817e99..1ac6c789c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/InternetArchive.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/InternetArchive.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.newSubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.StringUtils.decodeUri +import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl import com.lagradost.cloudstream3.utils.newExtractorLink import org.jsoup.nodes.Document @@ -96,7 +96,7 @@ open class InternetArchive : ExtractorApi() { if (mediaUrl.isNotEmpty()) { val name = if (mediaUrl.count() > 1) { val fileExtension = mediaUrl.substringAfterLast(".") - val fileNameCleaned = fileName.decodeUri().substringBeforeLast('.') + val fileNameCleaned = fileName.decodeUrl().substringBeforeLast('.') "$fileNameCleaned ($fileExtension)" } else this.name callback( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt index 98481970b..9886300aa 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import java.net.URI +import io.ktor.http.Url open class Streamplay : ExtractorApi() { override val name = "Streamplay" @@ -22,9 +22,7 @@ open class Streamplay : ExtractorApi() { ) { val request = app.get(url, referer = referer) val redirectUrl = request.url - val mainServer = URI(redirectUrl).let { - "${it.scheme}://${it.host}" - } + val mainServer = Url(redirectUrl).let { "${it.protocol.name}://${it.host}" } val key = redirectUrl.substringAfter("embed-").substringBefore(".html") val token = request.document.select("script").find { it.data().contains("sitekey:") }?.data() @@ -79,4 +77,4 @@ open class Streamplay : ExtractorApi() { @JsonProperty("label") val label: String? = null, ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt index 846fd851d..63ceb1f3d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt @@ -10,7 +10,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.fixUrl import com.lagradost.cloudstream3.utils.newExtractorLink -import java.net.URI +import io.ktor.http.Url import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -84,7 +84,7 @@ open class VidStack : ExtractorApi() { private fun getBaseUrl(url: String): String { return try { - URI(url).let { "${it.scheme}://${it.host}" } + Url(url).let { "${it.protocol.name}://${it.host}" } } catch (e: Exception) { Log.e("Vidstack", "getBaseUrl fallback: ${e.message}") mainUrl diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt index a16d41943..31618a32b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt @@ -12,8 +12,8 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.newExtractorLink +import io.ktor.http.Url import org.jsoup.nodes.Document -import java.net.URI import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -88,8 +88,8 @@ object GogoHelper { val foundKey = secretKey ?: getKey(base64Decode(id) + foundIv) ?: return@safeApiCall val foundDecryptKey = secretDecryptKey ?: foundKey - val uri = URI(iframeUrl) - val mainUrl = "https://" + uri.host + val url = Url(iframeUrl) + val mainUrl = "https://${url.host}" val encryptedId = cryptoHandler(id, foundIv, foundKey) val encryptRequestData = if (isUsingAdaptiveData) { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt index 2563d40e1..c3b50c7a5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.extractors.helper -import com.lagradost.cloudstream3.utils.StringUtils.decodeUri -import com.lagradost.cloudstream3.utils.StringUtils.encodeUri +import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl +import com.lagradost.cloudstream3.utils.StringUtils.encodeUrl // Taken from https://github.com/saikou-app/saikou/blob/b35364c8c2a00364178a472fccf1ab72f09815b4/app/src/main/java/ani/saikou/parsers/anime/NineAnime.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md @@ -108,8 +108,6 @@ object NineAnimeHelper { } } - fun encode(input: String): String = - input.encodeUri().replace("+", "%20") - - private fun decode(input: String): String = input.decodeUri() + fun encode(input: String): String = input.encodeUrl() + private fun decode(input: String): String = input.decodeUrl() } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index f42128b10..8a71714cf 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -312,11 +312,12 @@ import com.lagradost.cloudstream3.extractors.ZplayerV2 import com.lagradost.cloudstream3.extractors.Ztreamhub import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import io.ktor.http.Url +import io.ktor.http.decodeURLPart import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import org.jsoup.Jsoup -import java.net.URI import kotlin.coroutines.cancellation.CancellationException import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -420,7 +421,7 @@ enum class ExtractorLinkType { private fun inferTypeFromUrl(url: String): ExtractorLinkType { val path = try { - URI(url).path + Url(url).encodedPath.decodeURLPart() } catch (_: Throwable) { // don't log magnet links as errors null @@ -819,7 +820,7 @@ constructor( /** * Removes https:// and www. - * To match urls regardless of schema, perhaps Uri() can be used? + * To match urls regardless of schema, perhaps Url() can be used? */ val schemaStripRegex = Regex("""^(https:|)//(www\.|)""") diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt index f6da18390..37d5f6285 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt @@ -19,8 +19,8 @@ */ package com.lagradost.cloudstream3.utils +import io.ktor.http.Url import java.io.IOException -import java.net.URI import java.nio.ByteBuffer import java.util.UUID import kotlin.io.encoding.Base64 @@ -276,29 +276,29 @@ object HlsPlaylistParser { } } - object UriUtil { - fun resolveToUri(baseUri: String?, referenceUri: String?): URI { - return URI.create(resolve(baseUri, referenceUri)) + object UrlUtil { + fun resolveToUrl(baseUrl: String?, referenceUrl: String?): Url { + return Url(resolve(baseUrl, referenceUrl)) } - /** The length of arrays returned by [.getUriIndices]. */ + /** The length of arrays returned by [.getUrlIndices]. */ private const val INDEX_COUNT: Int = 4 /** - * An index into an array returned by [.getUriIndices]. + * An index into an array returned by [.getUrlIndices]. * * * The value at this position in the array is the index of the ':' after the scheme. Equals -1 - * if the URI is a relative reference (no scheme). The hier-part starts at (schemeColon + 1), - * including when the URI has no scheme. + * if the URL is a relative reference (no scheme). The hier-part starts at (schemeColon + 1), + * including when the URL has no scheme. */ private const val SCHEME_COLON: Int = 0 /** - * An index into an array returned by [.getUriIndices]. + * An index into an array returned by [.getUrlIndices]. * * * The value at this position in the array is the index of the path part. Equals (schemeColon + @@ -310,7 +310,7 @@ object HlsPlaylistParser { const val PATH: Int = 1 /** - * An index into an array returned by [.getUriIndices]. + * An index into an array returned by [.getUrlIndices]. * * * The value at this position in the array is the index of the query part, including the '?' @@ -321,87 +321,87 @@ object HlsPlaylistParser { const val QUERY: Int = 2 /** - * An index into an array returned by [.getUriIndices]. + * An index into an array returned by [.getUrlIndices]. * * * The value at this position in the array is the index of the fragment part, including the '#' - * before the fragment. Equal to the length of the URI if no fragment part, and (length - 1) if + * before the fragment. Equal to the length of the URL if no fragment part, and (length - 1) if * the fragment part is a single '#' with no data. */ private const val FRAGMENT: Int = 3 /** - * Performs relative resolution of a `referenceUri` with respect to a `baseUri`. + * Performs relative resolution of a `referenceUrl` with respect to a `baseUrl`. * * * The resolution is performed as specified by RFC-3986. * - * @param baseUri The base URI. - * @param referenceUri The reference URI to resolve. + * @param baseUrl The base URL. + * @param referenceUrl The reference URL to resolve. */ - private fun resolve(baseUri: String?, referenceUri: String?): String { - var baseUri = baseUri - var referenceUri = referenceUri - val uri = StringBuilder() + private fun resolve(baseUrl: String?, referenceUrl: String?): String { + var baseUrl = baseUrl + var referenceUrl = referenceUrl + val url = StringBuilder() // Map null onto empty string, to make the following logic simpler. - baseUri = baseUri ?: "" - referenceUri = referenceUri ?: "" + baseUrl = baseUrl ?: "" + referenceUrl = referenceUrl ?: "" - val refIndices: IntArray = getUriIndices(referenceUri) + val refIndices: IntArray = getUrlIndices(referenceUrl) if (refIndices[SCHEME_COLON] != -1) { - // The reference is absolute. The target Uri is the reference. - uri.append(referenceUri) - removeDotSegments(uri, refIndices[PATH], refIndices[QUERY]) - return uri.toString() + // The reference is absolute. The target Url is the reference. + url.append(referenceUrl) + removeDotSegments(url, refIndices[PATH], refIndices[QUERY]) + return url.toString() } - val baseIndices: IntArray = getUriIndices(baseUri) + val baseIndices: IntArray = getUrlIndices(baseUrl) if (refIndices[FRAGMENT] == 0) { - // The reference is empty or contains just the fragment part, then the target Uri is the - // concatenation of the base Uri without its fragment, and the reference. - return uri.append(baseUri, 0, baseIndices[FRAGMENT]).append(referenceUri).toString() + // The reference is empty or contains just the fragment part, then the target Url is the + // concatenation of the base Url without its fragment, and the reference. + return url.append(baseUrl, 0, baseIndices[FRAGMENT]).append(referenceUrl).toString() } if (refIndices[QUERY] == 0) { // The reference starts with the query part. The target is the base up to (but excluding) the // query, plus the reference. - return uri.append(baseUri, 0, baseIndices[QUERY]).append(referenceUri).toString() + return url.append(baseUrl, 0, baseIndices[QUERY]).append(referenceUrl).toString() } if (refIndices[PATH] != 0) { // The reference has authority. The target is the base scheme plus the reference. val baseLimit = baseIndices[SCHEME_COLON] + 1 - uri.append(baseUri, 0, baseLimit).append(referenceUri) + url.append(baseUrl, 0, baseLimit).append(referenceUrl) return removeDotSegments( - uri, + url, baseLimit + refIndices[PATH], baseLimit + refIndices[QUERY] ) } - if (referenceUri[refIndices[PATH]] == '/') { + if (referenceUrl[refIndices[PATH]] == '/') { // The reference path is rooted. The target is the base scheme and authority (if any), plus // the reference. - uri.append(baseUri, 0, baseIndices[PATH]).append(referenceUri) + url.append(baseUrl, 0, baseIndices[PATH]).append(referenceUrl) return removeDotSegments( - uri, + url, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY] ) } - // The target Uri is the concatenation of the base Uri up to (but excluding) the last segment, + // The target Url is the concatenation of the base Url up to (but excluding) the last segment, // and the reference. This can be split into 2 cases: if (baseIndices[SCHEME_COLON] + 2 < baseIndices[PATH] && baseIndices[PATH] == baseIndices[QUERY] ) { // Case 1: The base hier-part is just the authority, with an empty path. An additional '/' is // needed after the authority, before appending the reference. - uri.append(baseUri, 0, baseIndices[PATH]).append('/').append(referenceUri) + url.append(baseUrl, 0, baseIndices[PATH]).append('/').append(referenceUrl) return removeDotSegments( - uri, + url, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY] + 1 ) @@ -410,22 +410,22 @@ object HlsPlaylistParser { // it. If base hier-part has no '/', it could only mean that it is completely empty or // contains only one segment, in which case the whole hier-part is excluded and the reference // is appended right after the base scheme colon without an added '/'. - val lastSlashIndex = baseUri.lastIndexOf('/', baseIndices[QUERY] - 1) + val lastSlashIndex = baseUrl.lastIndexOf('/', baseIndices[QUERY] - 1) val baseLimit = if (lastSlashIndex == -1) baseIndices[PATH] else lastSlashIndex + 1 - uri.append(baseUri, 0, baseLimit).append(referenceUri) - return removeDotSegments(uri, baseIndices[PATH], baseLimit + refIndices[QUERY]) + url.append(baseUrl, 0, baseLimit).append(referenceUrl) + return removeDotSegments(url, baseIndices[PATH], baseLimit + refIndices[QUERY]) } } /** - * Removes dot segments from the path of a URI. + * Removes dot segments from the path of a URL. * - * @param uri A [StringBuilder] containing the URI. - * @param offset The index of the start of the path in `uri`. - * @param limit The limit (exclusive) of the path in `uri`. + * @param url A [StringBuilder] containing the URL. + * @param offset The index of the start of the path in `url`. + * @param limit The limit (exclusive) of the path in `url`. */ private fun removeDotSegments( - uri: StringBuilder, + url: StringBuilder, offset: Int, limit: Int ): String { @@ -433,9 +433,9 @@ object HlsPlaylistParser { var limit = limit if (offset >= limit) { // Nothing to do. - return uri.toString() + return url.toString() } - if (uri[offset] == '/') { + if (url[offset] == '/') { // If the path starts with a /, always retain it. offset++ } @@ -445,7 +445,7 @@ object HlsPlaylistParser { while (i <= limit) { val nextSegmentStart = if (i == limit) { i - } else if (uri[i] == '/') { + } else if (url[i] == '/') { i + 1 } else { i++ @@ -453,16 +453,16 @@ object HlsPlaylistParser { } // We've encountered the end of a segment or the end of the path. If the final segment was // "." or "..", remove the appropriate segments of the path. - if (i == segmentStart + 1 && uri[segmentStart] == '.') { + if (i == segmentStart + 1 && url[segmentStart] == '.') { // Given "abc/def/./ghi", remove "./" to get "abc/def/ghi". - uri.delete(segmentStart, nextSegmentStart) + url.delete(segmentStart, nextSegmentStart) limit -= nextSegmentStart - segmentStart i = segmentStart - } else if (i == segmentStart + 2 && uri[segmentStart] == '.' && uri[segmentStart + 1] == '.') { + } else if (i == segmentStart + 2 && url[segmentStart] == '.' && url[segmentStart + 1] == '.') { // Given "abc/def/../ghi", remove "def/../" to get "abc/ghi". - val prevSegmentStart = uri.lastIndexOf("/", segmentStart - 2) + 1 + val prevSegmentStart = url.lastIndexOf("/", segmentStart - 2) + 1 val removeFrom = if (prevSegmentStart > offset) prevSegmentStart else offset - uri.delete(removeFrom, nextSegmentStart) + url.delete(removeFrom, nextSegmentStart) limit -= nextSegmentStart - removeFrom segmentStart = prevSegmentStart i = prevSegmentStart @@ -471,41 +471,41 @@ object HlsPlaylistParser { segmentStart = i } } - return uri.toString() + return url.toString() } /** - * Calculates indices of the constituent components of a URI. + * Calculates indices of the constituent components of a URL. * - * @param uriString The URI as a string. + * @param urlString The URL as a string. * @return The corresponding indices. */ - private fun getUriIndices(uriString: String?): IntArray { + private fun getUrlIndices(urlString: String?): IntArray { val indices = IntArray(INDEX_COUNT) - if (uriString.isNullOrEmpty()) { + if (urlString.isNullOrEmpty()) { indices[SCHEME_COLON] = -1 return indices } // Determine outer structure from right to left. - // Uri = scheme ":" hier-part [ "?" query ] [ "#" fragment ] - val length = uriString.length - var fragmentIndex = uriString.indexOf('#') + // Url = scheme ":" hier-part [ "?" query ] [ "#" fragment ] + val length = urlString.length + var fragmentIndex = urlString.indexOf('#') if (fragmentIndex == -1) { fragmentIndex = length } - var queryIndex = uriString.indexOf('?') + var queryIndex = urlString.indexOf('?') if (queryIndex == -1 || queryIndex > fragmentIndex) { // '#' before '?': '?' is within the fragment. queryIndex = fragmentIndex } // Slashes are allowed only in hier-part so any colon after the first slash is part of the // hier-part, not the scheme colon separator. - var schemeIndexLimit = uriString.indexOf('/') + var schemeIndexLimit = urlString.indexOf('/') if (schemeIndexLimit == -1 || schemeIndexLimit > queryIndex) { schemeIndexLimit = queryIndex } - var schemeIndex = uriString.indexOf(':') + var schemeIndex = urlString.indexOf(':') if (schemeIndex > schemeIndexLimit) { // '/' before ':' schemeIndex = -1 @@ -514,10 +514,10 @@ object HlsPlaylistParser { // Determine hier-part structure: hier-part = "//" authority path / path // This block can also cope with schemeIndex == -1. val hasAuthority = - schemeIndex + 2 < queryIndex && uriString[schemeIndex + 1] == '/' && uriString[schemeIndex + 2] == '/' + schemeIndex + 2 < queryIndex && urlString[schemeIndex + 1] == '/' && urlString[schemeIndex + 2] == '/' var pathIndex: Int if (hasAuthority) { - pathIndex = uriString.indexOf('/', schemeIndex + 3) // find first '/' after "://" + pathIndex = urlString.indexOf('/', schemeIndex + 3) // find first '/' after "://" if (pathIndex == -1 || pathIndex > queryIndex) { pathIndex = queryIndex } @@ -806,7 +806,7 @@ object HlsPlaylistParser { const val APPLICATION_MEDIA3_CUES: String = "$BASE_TYPE_APPLICATION/x-media3-cues" - /** MIME type for an image URI loaded from an external image management framework. */ + /** MIME type for an image URL loaded from an external image management framework. */ const val APPLICATION_EXTERNALLY_LOADED_IMAGE: String = "$BASE_TYPE_APPLICATION/x-image-uri" @@ -1177,11 +1177,11 @@ object HlsPlaylistParser { val keyFormatVersions = parseOptionalStringAttr(line, REGEX_KEYFORMATVERSIONS, "1", variableDefinitions) if (KEYFORMAT_WIDEVINE_PSSH_BINARY == keyFormat) { - val uriString = parseStringAttr(line, REGEX_URI, variableDefinitions) + val urlString = parseStringAttr(line, REGEX_URI, variableDefinitions) return SchemeData( uuid = C.WIDEVINE_UUID, mimeType = MimeTypes.VIDEO_MP4, - data = Base64.Default.decode(uriString.substring(uriString.indexOf(','))) + data = Base64.Default.decode(urlString.substring(urlString.indexOf(','))) ) } else if (KEYFORMAT_WIDEVINE_PSSH_JSON == keyFormat) { return SchemeData( @@ -1190,9 +1190,9 @@ object HlsPlaylistParser { data = line.encodeToByteArray() ) } else if (KEYFORMAT_PLAYREADY == keyFormat && "1" == keyFormatVersions) { - val uriString = parseStringAttr(line, REGEX_URI, variableDefinitions) + val urlString = parseStringAttr(line, REGEX_URI, variableDefinitions) val data: ByteArray = - Base64.Default.decode(uriString.substring(uriString.indexOf(','))) + Base64.Default.decode(urlString.substring(urlString.indexOf(','))) val psshData: ByteArray = PsshAtomUtil.buildPsshAtom( systemId = C.PLAYREADY_UUID, @@ -1270,7 +1270,7 @@ object HlsPlaylistParser { } data class Variant( - val url: URI, + val url: Url, val format: Format, val videoGroupId: String?, val audioGroupId: String?, @@ -1323,7 +1323,7 @@ object HlsPlaylistParser { data class Rendition( /** The rendition's url, or null if the tag does not have a URI attribute. */ - val url: URI?, + val url: Url?, /** Format information associated with this rendition. */ val format: Format, @@ -1336,14 +1336,14 @@ object HlsPlaylistParser { ) data class HlsMultivariantPlaylist( - /** The base uri. Used to resolve relative paths. */ + /** The base url. Used to resolve relative paths. */ val baseUri: String, /** The list of tags in the playlist. */ val tags: List, /** All of the media playlist URLs referenced by the playlist. */ - //val mediaPlaylistUrls: List, + //val mediaPlaylistUrls: List, /** The variants declared by the playlist. */ val variants: List, @@ -1729,8 +1729,8 @@ object HlsPlaylistParser { private fun parseMultivariantPlaylist( iterator: Iterator, baseUri: String ): HlsMultivariantPlaylist { - val urlToVariantInfos: HashMap?> = - HashMap?>() + val urlToVariantInfos: HashMap?> = + HashMap?>() val variableDefinitions = HashMap() val variants: ArrayList = ArrayList() val videos: ArrayList = ArrayList() @@ -1853,10 +1853,10 @@ object HlsPlaylistParser { parseOptionalStringAttr(line, REGEX_SUBTITLES, variableDefinitions) val closedCaptionsGroupId: String? = parseOptionalStringAttr(line, REGEX_CLOSED_CAPTIONS, variableDefinitions) - val uri: URI + val url: Url if (isIFrameOnlyVariant) { - uri = - UriUtil.resolveToUri( + url = + UrlUtil.resolveToUrl( baseUri, parseStringAttr(line, REGEX_URI, variableDefinitions) ) @@ -1865,14 +1865,14 @@ object HlsPlaylistParser { "#EXT-X-STREAM-INF must be followed by another line", /* cause= */null ) } else { - // The following line contains #EXT-X-STREAM-INF's URI. + // The following line contains #EXT-X-STREAM-INF's URL. line = replaceVariableReferences(iterator.next(), variableDefinitions) - uri = UriUtil.resolveToUri(baseUri, line) + url = UrlUtil.resolveToUrl(baseUri, line) } val variant = Variant( - url = uri, + url = url, format = Format( id = variants.size.toString(), containerMimeType = MimeTypes.APPLICATION_M3U8, @@ -1890,10 +1890,10 @@ object HlsPlaylistParser { captionGroupId = closedCaptionsGroupId ) variants.add(variant) - var variantInfosForUrl: ArrayList? = urlToVariantInfos[uri] + var variantInfosForUrl: ArrayList? = urlToVariantInfos[url] if (variantInfosForUrl == null) { variantInfosForUrl = ArrayList() - urlToVariantInfos[uri] = variantInfosForUrl + urlToVariantInfos[url] = variantInfosForUrl } variantInfosForUrl.add( VariantInfo( @@ -1911,7 +1911,7 @@ object HlsPlaylistParser { // TODO: Don't deduplicate variants by URL. val deduplicatedVariants = variants.distinctBy { it.url } /*val deduplicatedVariants: ArrayList = ArrayList() - val urlsInDeduplicatedVariants = HashSet() + val urlsInDeduplicatedVariants = HashSet() for (i in variants.indices) { val variant: Variant = variants[i] if (urlsInDeduplicatedVariants.add(variant.url)) { @@ -1945,10 +1945,10 @@ object HlsPlaylistParser { containerMimeType = MimeTypes.APPLICATION_M3U8, ) - val referenceUri: String? = + val referenceUrl: String? = parseOptionalStringAttr(line, REGEX_URI, variableDefinitions) - val uri: URI? = - if (referenceUri == null) null else UriUtil.resolveToUri(baseUri, referenceUri) + val url: Url? = + if (referenceUrl == null) null else UrlUtil.resolveToUrl(baseUri, referenceUrl) //val metadata = // Metadata(HlsTrackMetadataEntry(groupId, name, emptyList())) when (parseStringAttr(line, REGEX_TYPE, variableDefinitions)) { @@ -1963,11 +1963,11 @@ object HlsPlaylistParser { codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO) ) } - if (uri == null) { - // TODO: Remove this case and add a Rendition with a null uri to videos. + if (url == null) { + // TODO: Remove this case and add a Rendition with a null url to videos. } else { //formatBuilder.setMetadata(metadata) - videos.add(Rendition(url = uri, format = formatBuilder, groupId, name)) + videos.add(Rendition(url = url, format = formatBuilder, groupId, name)) } } @@ -1995,11 +1995,11 @@ object HlsPlaylistParser { } } val format = formatBuilder.copy(sampleMimeType = sampleMimeType) - if (uri != null) { + if (url != null) { //formatBuilder.setMetadata(metadata) - audios.add(Rendition(uri, format, groupId, name)) + audios.add(Rendition(url, format, groupId, name)) } else if (variant != null) { - // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios. + // TODO: Remove muxedAudioFormat and add a Rendition with a null url to audios. muxedAudioFormat = format } } @@ -2018,10 +2018,10 @@ object HlsPlaylistParser { if (sampleMimeType == null) { sampleMimeType = MimeTypes.TEXT_VTT } - if (uri != null) { + if (url != null) { subtitles.add( Rendition( - uri, + url, formatBuilder.copy(sampleMimeType = sampleMimeType), groupId, name diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt index b203fd338..00d448d94 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -112,8 +112,8 @@ object M3u8Helper2 { return c.doFinal(data) } - private fun getParentLink(uri: String): String { - val split = uri.split("/").toMutableList() + private fun getParentLink(url: String): String { + val split = url.split("/").toMutableList() split.removeAt(split.lastIndex) return split.joinToString("/") } @@ -322,15 +322,15 @@ object M3u8Helper2 { if (!match.isNullOrEmpty()) { encryptionState = true - var encryptionUri = match[2] + var encryptionUrl = match[2] - if (isNotCompleteUrl(encryptionUri)) { - encryptionUri = "${getParentLink(playlistStream.streamUrl)}/$encryptionUri" + if (isNotCompleteUrl(encryptionUrl)) { + encryptionUrl = "${getParentLink(playlistStream.streamUrl)}/$encryptionUrl" } encryptionIv = match[3].encodeToByteArray() val encryptionKeyResponse = - app.get(encryptionUri, headers = playlistStream.headers, verify = false) + app.get(encryptionUrl, headers = playlistStream.headers, verify = false) val body = encryptionKeyResponse.body encryptionData = body.bytes() body.close() diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt index 1e3a2ffb7..b9233490a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt @@ -1,14 +1,32 @@ package com.lagradost.cloudstream3.utils -import java.net.URLDecoder -import java.net.URLEncoder +import com.lagradost.cloudstream3.Prerelease +import io.ktor.http.decodeURLQueryComponent +import io.ktor.http.encodeURLParameter object StringUtils { - fun String.encodeUri(): String { - return URLEncoder.encode(this, "UTF-8") + @Prerelease + fun String.decodeUrl(): String { + return this.decodeURLQueryComponent() } - fun String.decodeUri(): String { - return URLDecoder.decode(this, "UTF-8") + @Prerelease + fun String.encodeUrl(): String { + return this.encodeURLParameter() } -} \ No newline at end of file + + // Deprecate after next stable + + /* @Deprecated( + message = "Use Ktor 'Url' naming convention instead.", + replaceWith = ReplaceWith("this.encodeUrl()") + ) */ + fun String.encodeUri(): String = encodeUrl() + + /* @Deprecated( + message = "Use Ktor 'Url' naming convention instead.", + replaceWith = ReplaceWith("this.decodeUrl()") + ) */ + fun String.decodeUri(): String = decodeUrl() +} + diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt index 6d9862d3a..8bdbf3788 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.base64Decode -import com.lagradost.cloudstream3.utils.StringUtils.decodeUri +import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl import com.lagradost.nicehttp.NiceResponse -import java.net.URI +import io.ktor.http.Url // Code heavily based on unshortenit.py form kodiondemand /addon @@ -48,8 +48,8 @@ object ShortLink { } } - suspend fun unshorten(uri: String, type: String? = null): String { - var currentUrl = uri + suspend fun unshorten(url: String, type: String? = null): String { + var currentUrl = url val visitedUrls = mutableSetOf() var count = 10 @@ -57,9 +57,7 @@ object ShortLink { visitedUrls += currentUrl count -= 1 - val domain = - URI(currentUrl.trim()).host - ?: throw IllegalArgumentException("No domain found in URI!") + val domain = Url(currentUrl.trim()).host currentUrl = shortList.firstOrNull { it.regex.find(domain) != null || type == it.type }?.function?.let { it(currentUrl) } ?: break @@ -67,8 +65,8 @@ object ShortLink { return currentUrl.trim() } - suspend fun unshortenAdfly(uri: String): String { - val html = app.get(uri).text + suspend fun unshortenAdfly(url: String): String { + val html = app.get(url).text val ysmm = Regex("""var ysmm =.*;?""").find(html)!!.value if (ysmm.isNotEmpty()) { @@ -81,46 +79,46 @@ object ShortLink { left += c[0] right = c[1] + right } - val encodedUri = (left + right).toMutableList() + val encodedUrl = (left + right).toMutableList() val numbers = - encodedUri.mapIndexed { i, n -> Pair(i, n) }.filter { it.second.isDigit() } + encodedUrl.mapIndexed { i, n -> Pair(i, n) }.filter { it.second.isDigit() } for (el in numbers.chunked(2).dropLastWhile { it.size == 1 }) { val xor = (el[0].second).code.xor(el[1].second.code) if (xor < 10) { - encodedUri[el[0].first] = xor.digitToChar() + encodedUrl[el[0].first] = xor.digitToChar() } } - val encodedbytearray = encodedUri.map { it.code.toByte() }.toByteArray() - var decodedUri = + val encodedbytearray = encodedUrl.map { it.code.toByte() }.toByteArray() + var decodedUrl = base64Decode(encodedbytearray.toString()).dropLast(16) .drop(16) - if (Regex("""go\.php\?u=""").find(decodedUri) != null) { - decodedUri = - base64Decode(decodedUri.replace(Regex("""(.*?)u="""), "")) + if (Regex("""go\.php\?u=""").find(decodedUrl) != null) { + decodedUrl = + base64Decode(decodedUrl.replace(Regex("""(.*?)u="""), "")) } - return decodedUri + return decodedUrl } else { - return uri + return url } } - suspend fun unshortenLinkup(uri: String): String { + suspend fun unshortenLinkup(url: String): String { var r: NiceResponse? = null - var uri = uri + var url = url when { - uri.contains("/tv/") -> uri = uri.replace("/tv/", "/tva/") - uri.contains("delta") -> uri = uri.replace("/delta/", "/adelta/") - (uri.contains("/ga/") || uri.contains("/ga2/")) -> uri = - base64Decode(uri.split('/').last()).trim() + url.contains("/tv/") -> url = url.replace("/tv/", "/tva/") + url.contains("delta") -> url = url.replace("/delta/", "/adelta/") + (url.contains("/ga/") || url.contains("/ga2/")) -> url = + base64Decode(url.split('/').last()).trim() - uri.contains("/speedx/") -> uri = - uri.replace("http://linkup.pro/speedx", "http://speedvideo.net") + url.contains("/speedx/") -> url = + url.replace("http://linkup.pro/speedx", "http://speedvideo.net") else -> { - r = app.get(uri, allowRedirects = true) - uri = r.url + r = app.get(url, allowRedirects = true) + url = r.url val link = Regex("]*src=\\'([^'>]*)\\'[^<>]*>").find(r.text)?.value ?: Regex("""action="(?:[^/]+.*?/[^/]+/([a-zA-Z0-9_]+))">""").find(r.text)?.value @@ -128,40 +126,40 @@ object ShortLink { .elementAtOrNull(1)?.groupValues?.get(1) if (link != null) { - uri = link + url = link } } } - val short = Regex("""^https?://.*?(https?://.*)""").find(uri)?.value + val short = Regex("""^https?://.*?(https?://.*)""").find(url)?.value if (short != null) { - uri = short + url = short } if (r == null) { r = app.get( - uri, + url, allowRedirects = false ) if (r.headers["location"] != null) { - uri = r.headers["location"].toString() + url = r.headers["location"].toString() } } - if (uri.contains("snip.")) { - if (uri.contains("out_generator")) { - uri = Regex("url=(.*)\$").find(uri)!!.value - } else if (uri.contains("/decode/")) { - uri = app.get(uri, allowRedirects = true).url + if (url.contains("snip.")) { + if (url.contains("out_generator")) { + url = Regex("url=(.*)\$").find(url)!!.value + } else if (url.contains("/decode/")) { + url = app.get(url, allowRedirects = true).url } } - return uri + return url } - fun unshortenLinksafe(uri: String): String { - return base64Decode(uri.split("?url=").last()) + fun unshortenLinksafe(url: String): String { + return base64Decode(url.split("?url=").last()) } - suspend fun unshortenNuovoIndirizzo(uri: String): String { - val soup = app.get(uri, allowRedirects = true) + suspend fun unshortenNuovoIndirizzo(url: String): String { + val soup = app.get(url, allowRedirects = true) val header = soup.headers["refresh"] val link: String = if (header != null) { soup.headers["refresh"]!!.substringAfter("=") @@ -171,29 +169,29 @@ object ShortLink { return link } - suspend fun unshortenNuovoLink(uri: String): String { - return app.get(uri, allowRedirects = true).document.selectFirst("a")!!.attr("href") + suspend fun unshortenNuovoLink(url: String): String { + return app.get(url, allowRedirects = true).document.selectFirst("a")!!.attr("href") } - suspend fun unshortenUprot(uri: String): String { - val page = app.get(uri).text + suspend fun unshortenUprot(url: String): String { + val page = app.get(url).text Regex("""]+href="([^"]+)".*Continue""").findAll(page) .map { it.value.replace(""" - if (link.contains("https://maxstream.video") || link.contains("https://uprot.net") && link != uri) { + if (link.contains("https://maxstream.video") || link.contains("https://uprot.net") && link != url) { return link } } - return uri + return url } - fun unshortenDavisonbarker(uri: String): String { - return uri.substringAfter("dest=").decodeUri() + fun unshortenDavisonbarker(url: String): String { + return url.substringAfter("dest=").decodeUrl() } - suspend fun unshortenIsecure(uri: String): String { - val doc = app.get(uri).document - return doc.selectFirst("iframe")?.attr("src")?.trim() ?: uri + suspend fun unshortenIsecure(url: String): String { + val doc = app.get(url).document + return doc.selectFirst("iframe")?.attr("src")?.trim() ?: url } -} \ No newline at end of file +} From b222911e29dadbfdba62c2d44e92f7973c4a647b Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 11 Jun 2026 04:29:29 -0600 Subject: [PATCH 55/64] Fix parseJson inline on stable once again! (#2908) --- .../kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 6233fd820..6ab9d3e95 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -60,8 +60,9 @@ object AppUtils { inline fun parseJson(value: String): T { // @Serializable generates a serializer at compile time; contextual serializers are // registered manually in serializersModule, we need both to support all cases - val serializer = runCatching { serializer() }.getOrNull() - ?: json.serializersModule.getContextual(T::class) + val serializer = runCatching { serializer() } + .recoverCatching { json.serializersModule.getContextual(T::class) } + .getOrNull() // Prefer Kotlin Serialization over Jackson if (serializer != null) { From 6f9646e52f475f94005467f6256870357cb948bc Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:06:23 +0200 Subject: [PATCH 56/64] Fix one last issue in JSON parsing :I --- .../kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 6ab9d3e95..1c635013f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -70,6 +70,8 @@ object AppUtils { return json.decodeFromString(serializer, value) } catch (e: SerializationException) { logError(e) + } catch (_: Throwable) { + // Pass, the above code will trigger a NoSuchMethodError on stable due to our previously undefined json variable } } From 55450a02fa14d2364eca8ab5e551d2c0552b4870 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:56:45 +0200 Subject: [PATCH 57/64] Merge pull request #2904 from recloudstream/mpvrx Feat: MpvRx --- .../cloudstream3/actions/VideoClickAction.kt | 2 + .../cloudstream3/actions/temp/MpvRxPackage.kt | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt index 3ed858ef3..ef042e3a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -20,6 +20,7 @@ import com.lagradost.cloudstream3.actions.temp.MpvExPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage +import com.lagradost.cloudstream3.actions.temp.MpvRxPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction @@ -64,6 +65,7 @@ object VideoClickActionHolder { MpvYTDLPackage(), MpvKtPackage(), MpvKtPreviewPackage(), + MpvRxPackage(), // Always Ask option AlwaysAskAction(), // added by plugins diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt new file mode 100644 index 000000000..e8bb93a99 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.api.Log +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/Riteshp2001/mpvRx + * + * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132 + * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56 + * */ +class MpvRxPackage : OpenInAppAction( + appName = txt("mpvRx"), + packageName = "app.gyrolet.mpvrx", + intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity" +) { + override val oneSource = true + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.apply { + putExtra("title", video.name) + val link = result.links[index!!] + val headers = link.headers + + setData(link.url.toUri()) + if (headers.isNotEmpty()) { + // PlayerActivity expects a flat array: [key1, value1, key2, value2, ...] + val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray() + intent.putExtra("headers", flat) + } + /*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146 + intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray()) + intent.putExtra( + "subs.titles", + subs.map { it.name }.toTypedArray(), + ) + intent.putExtra( + "subs.langs", + subs.map { it.languageCode }.toTypedArray(), + ) + val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri() + intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf() )*/ + + if (video.tvType.isEpisodeBased()) { + video.season?.let { intent.putExtra("introdb_season", it) } + video.episode.let { intent.putExtra("introdb_episode", it) } + } + + val position = getViewPos(video.id)?.position + if (position != null) + putExtra("position", position.toInt()) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getIntExtra("position", -1) ?: -1 + val duration = intent?.getIntExtra("duration", -1) ?: -1 + Log.d("MPV", "Position: $position, Duration: $duration") + updateDurationAndPosition(position.toLong(), duration.toLong()) + } +} \ No newline at end of file From 3417fe016051d988361d32d093e0d4df55b34a8f Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:58:43 +0200 Subject: [PATCH 58/64] Feat: OnlyPlayer (#2905) --- .../cloudstream3/actions/VideoClickAction.kt | 2 + .../cloudstream3/actions/temp/OnlyPlayer.kt | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt index ef042e3a6..a864b5fb7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.actions.temp.MpvPackage import com.lagradost.cloudstream3.actions.temp.MpvRxPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage +import com.lagradost.cloudstream3.actions.temp.OnlyPlayer import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action @@ -65,6 +66,7 @@ object VideoClickActionHolder { MpvYTDLPackage(), MpvKtPackage(), MpvKtPreviewPackage(), + OnlyPlayer(), MpvRxPackage(), // Always Ask option AlwaysAskAction(), diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt new file mode 100644 index 000000000..348be440a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt @@ -0,0 +1,44 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/Kindness-Kismet/only_player/tree/main + * https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */ +class OnlyPlayer : OpenInAppAction( + txt("Only Player"), + "one.only.player", + intentClass = "one.only.player.feature.player.PlayerActivity" +) { + override val oneSource = true + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + /** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */ + intent.apply { + val link = result.links[index!!] + setData(link.url.toUri()) + + putExtra("headers", Bundle().apply { + for ((key, value) in link.headers) { + putExtra(key, value) + } + }) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + /* onResult does not get called */ + } +} \ No newline at end of file From 2c03a3d97664f6f82db2a773e41c20bb579b84dd Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:59:02 +0200 Subject: [PATCH 59/64] fix gradient (#2912) --- app/src/main/res/layout/player_custom_layout.xml | 1 + app/src/main/res/layout/player_custom_layout_tv.xml | 1 + app/src/main/res/layout/trailer_custom_layout.xml | 1 + 3 files changed, 3 insertions(+) diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 407de4a3f..04a2a1f1f 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -11,6 +11,7 @@ android:id="@+id/player_metadata_scrim" android:layout_width="640dp" android:layout_height="match_parent" + android:layout_marginTop="-10dp" android:background="@drawable/bg_player_metadata_scrim_netflix" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/app/src/main/res/layout/player_custom_layout_tv.xml b/app/src/main/res/layout/player_custom_layout_tv.xml index 3a3076943..077929d87 100644 --- a/app/src/main/res/layout/player_custom_layout_tv.xml +++ b/app/src/main/res/layout/player_custom_layout_tv.xml @@ -12,6 +12,7 @@ android:id="@+id/player_metadata_scrim" android:layout_width="680dp" android:layout_height="match_parent" + android:layout_marginTop="-10dp" android:background="@drawable/bg_player_metadata_scrim_netflix" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/trailer_custom_layout.xml b/app/src/main/res/layout/trailer_custom_layout.xml index 76231a2d3..88a318874 100644 --- a/app/src/main/res/layout/trailer_custom_layout.xml +++ b/app/src/main/res/layout/trailer_custom_layout.xml @@ -11,6 +11,7 @@ android:id="@+id/player_metadata_scrim" android:layout_width="640dp" android:layout_height="match_parent" + android:layout_marginTop="-10dp" android:background="@drawable/bg_player_metadata_scrim_netflix" android:visibility="gone" app:layout_constraintStart_toStartOf="parent" From c045bfdc0d9a691aa0405d9831a93fb875cc2196 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:03:28 -0600 Subject: [PATCH 60/64] [skip ci] MainAPI: remove `@OptIn(ExperimentalEncodingApi::class)` (#2930) It is stable since Kotlin 2.2.0 --- .../commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 4a9e0b10a..ffc0a938d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -41,7 +41,6 @@ import kotlinx.datetime.format.parse import kotlinx.datetime.toInstant import kotlinx.serialization.json.Json import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue import kotlin.math.roundToInt import kotlin.time.Clock @@ -716,12 +715,10 @@ fun base64Decode(string: String): String { } } -@OptIn(ExperimentalEncodingApi::class) fun base64DecodeArray(string: String): ByteArray { return Base64.decode(string) } -@OptIn(ExperimentalEncodingApi::class) fun base64Encode(array: ByteArray): String { return Base64.encode(array) } From 943bc551e950bbf292058d26fc89c62278a87494 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:34:25 -0600 Subject: [PATCH 61/64] [skip ci] HlsPlaylistParser: use base64DecodeArray (#2929) --- .../com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt index 37d5f6285..cd5d752b3 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/HlsPlaylistParser.kt @@ -19,12 +19,11 @@ */ package com.lagradost.cloudstream3.utils +import com.lagradost.cloudstream3.base64DecodeArray import io.ktor.http.Url import java.io.IOException import java.nio.ByteBuffer import java.util.UUID -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi @Suppress("unused") object HlsPlaylistParser { @@ -1169,7 +1168,6 @@ object HlsPlaylistParser { return parseOptionalStringAttr(line, pattern, null, variableDefinitions) } - @OptIn(ExperimentalEncodingApi::class) @Throws(ParserException::class) private fun parseDrmSchemeData( line: String, keyFormat: String, variableDefinitions: Map @@ -1181,7 +1179,7 @@ object HlsPlaylistParser { return SchemeData( uuid = C.WIDEVINE_UUID, mimeType = MimeTypes.VIDEO_MP4, - data = Base64.Default.decode(urlString.substring(urlString.indexOf(','))) + data = base64DecodeArray(urlString.substring(urlString.indexOf(','))) ) } else if (KEYFORMAT_WIDEVINE_PSSH_JSON == keyFormat) { return SchemeData( @@ -1192,7 +1190,7 @@ object HlsPlaylistParser { } else if (KEYFORMAT_PLAYREADY == keyFormat && "1" == keyFormatVersions) { val urlString = parseStringAttr(line, REGEX_URI, variableDefinitions) val data: ByteArray = - Base64.Default.decode(urlString.substring(urlString.indexOf(','))) + base64DecodeArray(urlString.substring(urlString.indexOf(','))) val psshData: ByteArray = PsshAtomUtil.buildPsshAtom( systemId = C.PLAYREADY_UUID, From b4100dbfca6ef9cc6ea9842abbed189709b94eb2 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Thu, 18 Jun 2026 01:40:30 +0200 Subject: [PATCH 62/64] feat(extractors): add flyfile.app extractor (#2925) --- .../cloudstream3/extractors/Flyfile.kt | 48 +++++++++++++++++++ .../cloudstream3/utils/ExtractorApi.kt | 2 + 2 files changed, 50 insertions(+) create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt new file mode 100644 index 000000000..eb6d474a5 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt @@ -0,0 +1,48 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.Prerelease +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.app +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 kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Prerelease +open class Flyfile : ExtractorApi() { + override val name: String = "FlyFile" + override val mainUrl: String = "https://flyfile.app" + open val apiUrl: String = "https://api.flyfile.app" + override val requiresReferer: Boolean = false + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val videoId = url.substringAfterLast("/") + val videoInfo = app.get("$apiUrl/api/streaming/assign/$videoId") + .parsed() + + val streamUrl = "${videoInfo.url}/hls/${videoInfo.token}/master.m3u8" + callback.invoke( + newExtractorLink( + source = name, + name = name, + url = streamUrl, + type = ExtractorLinkType.M3U8 + ) + ) + } + + @Serializable + private data class StreamInfo( + @SerialName("url") + val url: String, + @SerialName("token") + val token: String + ) +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 8a71714cf..f9167d08c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -81,6 +81,7 @@ import com.lagradost.cloudstream3.extractors.FilemoonV2 import com.lagradost.cloudstream3.extractors.Filesim import com.lagradost.cloudstream3.extractors.Multimoviesshg import com.lagradost.cloudstream3.extractors.FlaswishCom +import com.lagradost.cloudstream3.extractors.Flyfile import com.lagradost.cloudstream3.extractors.FourCX import com.lagradost.cloudstream3.extractors.FourPichive import com.lagradost.cloudstream3.extractors.FourPlayRu @@ -1298,6 +1299,7 @@ val extractorApis: AtomicMutableList = atomicListOf( GUpload(), HlsWish(), ByseQekaho(), + Flyfile() ) From 6f458fc9b57b014ee0fce42c8d90a3b4bc6c6e15 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:41:02 -0600 Subject: [PATCH 63/64] Remove unused classgraph dependency (#2924) --- app/build.gradle.kts | 1 - .../com/lagradost/cloudstream3/SerializationClassTester.kt | 3 +-- gradle/libs.versions.toml | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c784f3ef..02c1f99e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -207,7 +207,6 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) - androidTestImplementation(libs.classgraph) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.instancio.core) diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt index d1a11e003..80c7b49b0 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt @@ -101,7 +101,7 @@ class SerializationClassTester { } // DEX files are the best solution to read all our classes dynamically. - // ClassGraph() can be used instead, but it only gives results on the JVM, not Android. + // classgraph could be used instead, but it only gives results on the JVM, not Android. @Suppress("DEPRECATION") private fun findSerializableClasses(packageName: String): List> { val context = InstrumentationRegistry @@ -109,7 +109,6 @@ class SerializationClassTester { .targetContext val dexFile = DexFile(context.packageCodePath) - return dexFile.entries() .toList() .filter { it.startsWith(packageName) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f28bc41ab..80e342c2c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,6 @@ annotation = "1.10.0" appcompat = "1.7.1" biometric = "1.4.0-alpha07" buildkonfigGradlePlugin = "0.21.2" -classgraph = "4.8.184" coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later colorpicker = "6b46b49" conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything @@ -70,7 +69,6 @@ anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeD annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -classgraph = { group = "io.github.classgraph", name = "classgraph", version.ref = "classgraph" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } colorpicker = { module = "com.github.recloudstream:color-picker-android", version.ref = "colorpicker" } From 3c6bf2984e6976e20e8861dcc543bcf3e628e79b Mon Sep 17 00:00:00 2001 From: KingLucius Date: Fri, 19 Jun 2026 01:06:46 +0300 Subject: [PATCH 64/64] SyncApi Search query fix (#2932) --- .../syncproviders/providers/AniListApi.kt | 25 ++++++++++--------- .../syncproviders/providers/MALApi.kt | 14 +++++------ .../syncproviders/providers/SimklApi.kt | 2 +- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 7a46b4113..177018e19 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -50,7 +50,8 @@ class AniListApi : SyncAPI() { override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { val sanitizer = splitRedirectUrl(redirectUrl) val token = AuthToken( - accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), + accessToken = sanitizer["access_token"] + ?: throw ErrorLoadingException("No access token"), //refreshToken = sanitizer["refresh_token"], accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), ) @@ -83,8 +84,8 @@ class AniListApi : SyncAPI() { return "$mainUrl/anime/$id" } - override suspend fun search(auth : AuthData?, query: String): List? { - val data = searchShows(name) ?: return null + override suspend fun search(auth: AuthData?, query: String): List? { + val data = searchShows(query) ?: return null return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, @@ -96,7 +97,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { + override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") val season = getSeason(internalId).data.media @@ -158,7 +159,7 @@ class AniListApi : SyncAPI() { ) } - override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null val data = getDataAboutId(auth ?: return null, internalId) ?: return null @@ -459,7 +460,7 @@ class AniListApi : SyncAPI() { } } - private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { + private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? { val q = """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) @@ -506,7 +507,7 @@ class AniListApi : SyncAPI() { } - private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { + private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? { return app.post( "https://graphql.anilist.co/", headers = mapOf( @@ -638,7 +639,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> @@ -666,7 +667,7 @@ class AniListApi : SyncAPI() { ) } - private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { + private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? { val userID = auth.user.id val mediaType = "ANIME" @@ -714,7 +715,7 @@ class AniListApi : SyncAPI() { return text?.toKotlinObject() } - suspend fun toggleLike(auth : AuthData, id: Int): Boolean { + suspend fun toggleLike(auth: AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -737,7 +738,7 @@ class AniListApi : SyncAPI() { data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( - auth : AuthData, + auth: AuthData, id: Int, type: AniListStatusType, score: Score?, @@ -786,7 +787,7 @@ class AniListApi : SyncAPI() { return data != "" } - private suspend fun getUser(token : AuthToken): AniListUser? { + private suspend fun getUser(token: AuthToken): AniListUser? { val q = """ { Viewer { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index ba0195be6..c0a80b3c9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -98,9 +98,9 @@ class MALApi : SyncAPI() { ) } - override suspend fun search(auth : AuthData?, query: String): List? { + override suspend fun search(auth: AuthData?, query: String): List? { val auth = auth?.token?.accessToken ?: return null - val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" + val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT" val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", @@ -122,7 +122,7 @@ class MALApi : SyncAPI() { Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() override suspend fun updateStatus( - auth : AuthData?, + auth: AuthData?, id: String, newStatus: SyncAPI.AbstractSyncStatus ): Boolean { @@ -225,7 +225,7 @@ class MALApi : SyncAPI() { ) } - override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { + override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? { val auth = auth?.token?.accessToken ?: return null val internalId = id.toIntOrNull() ?: return null val url = @@ -271,7 +271,7 @@ class MALApi : SyncAPI() { } } - override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val auth = auth?.token?.accessToken ?: return null // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get @@ -477,7 +477,7 @@ class MALApi : SyncAPI() { @JsonProperty("start_time") val startTime: String? ) - override suspend fun library(auth : AuthData?): LibraryMetadata? { + override suspend fun library(auth: AuthData?): LibraryMetadata? { val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> @@ -505,7 +505,7 @@ class MALApi : SyncAPI() { ) } - private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { + private suspend fun getMalAnimeListSmart(auth: AuthData): Array? { return if (requireLibraryRefresh) { val list = getMalAnimeList(auth.token) setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index 84a498bb0..3110b23ac 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -911,7 +911,7 @@ class SimklApi : SyncAPI() { override suspend fun search(auth: AuthData?, query: String): List? { return app.get( - "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } }