diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae530192998..8d93ef52e30 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -255,7 +255,6 @@ 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 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 83a7a09847c..961c65d187b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -40,7 +40,6 @@ 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 -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 e5f9aca8493..f30a647483a 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 dfc61eba54c..0cbef9cf27a 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 a97145c3f81..7bd4b3b4664 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" @@ -74,7 +73,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 073e49e6483..7d09f9246fe 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -57,7 +57,6 @@ kotlin { implementation(libs.nicehttp) // HTTP Lib implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.kotlinx.coroutines.core) - implementation(libs.fuzzywuzzy) // Match Extractors implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript implementation(libs.newpipeextractor) 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 ab80cf2cade..94d7504052c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -310,7 +310,6 @@ import com.lagradost.cloudstream3.mvvm.logError 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 @@ -882,7 +881,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 00000000000..4b2d5e89cd1 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt @@ -0,0 +1,513 @@ +/** + * 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 kotlin.math.round + +// Taken from https://github.com/terrakok/FuzzyKot/blob/f794d43/fuzzykot/src/commonMain/kotlin/com/github/terrakok/fuzzykot/Levenshtein.kt +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 8d5479cc0ab..cc5bdb2c843 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() ) )