add FuzzyMovieSearchFilter

This commit is contained in:
2026-08-09 17:46:36 +03:00
parent c17a0732ad
commit 9e40ab2fd1
5 changed files with 111 additions and 12 deletions
@@ -25,7 +25,7 @@ class VboxModule {
@Single
fun provideUserAgent(): UserAgentProvider {
return object : UserAgentProvider {
override fun provide(): String? = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
override fun provide() = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
}
}
}
@@ -0,0 +1,87 @@
package ru.shadowsparky.vbox.backend.data
import org.koin.core.annotation.Single
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
import kotlin.math.max
@Single
class FuzzyMovieSearchFilter {
fun filter(original: List<VideoItem>, searchQuery: String): List<VideoItem> {
val query = searchQuery.normalize()
if (query.isBlank()) return emptyList()
return original.map { movie ->
val titleNormalized = movie.title.normalize()
if (titleNormalized.contains(query)) {
return@map Pair(movie, 1.0)
}
val queryWords = query.split(" ")
val titleWords = titleNormalized.split(" ")
var maxScore = 0.0
for (qWord in queryWords) {
for (tWord in titleWords) {
val score = tWord.fuzzySimilarity(qWord)
if (score > maxScore) maxScore = score
}
}
Pair(movie, maxScore)
}
.filter { it.second >= THRESHOLD }
.sortedByDescending { it.second }
.map { it.first }
}
private fun String.normalize(): String {
if (this.isBlank()) return ""
return this.lowercase()
.replace('ё', 'е')
.convertRomanToArabic()
.replace(Regex("[^a-zа-я0-9\\s]"), "")
.replace(Regex("\\s+"), " ")
.trim()
}
private fun String.convertRomanToArabic(): String {
return this.split(" ").joinToString(" ") { word ->
when (word) {
"i" -> "1"
"ii" -> "2"
"iii" -> "3"
"iv" -> "4"
"v" -> "5"
"vi" -> "6"
"vii" -> "7"
"viii" -> "8"
"ix" -> "9"
"x" -> "10"
else -> word
}
}
}
private fun String.fuzzySimilarity(other: String): Double {
if (this == other) return 1.0
if (this.isEmpty() || other.isEmpty()) return 0.0
val s1 = this
val dp = IntArray(other.length + 1) { it }
for (i in 1..s1.length) {
var prev = dp[0]
dp[0] = i
for (j in 1..other.length) {
val temp = dp[j]
if (s1[i - 1] == other[j - 1]) {
dp[j] = prev
} else {
dp[j] = minOf(dp[j - 1], dp[j], prev) + 1
}
prev = temp
}
}
val maxLen = max(s1.length, other.length)
return (maxLen - dp[other.length]).toDouble() / maxLen
}
private companion object {
const val THRESHOLD: Double = 0.5
}
}
@@ -2,6 +2,7 @@ package ru.shadowsparky.vbox.backend.data.http
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
@@ -9,7 +10,10 @@ import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
import java.util.Collections
import kotlin.time.Duration.Companion.days
class SessionVideoApi(private val wrapper: VideoApi) : VideoApi {
class SessionVideoApi(
private val wrapper: VideoApi,
private val filter: FuzzyMovieSearchFilter
) : VideoApi {
private val cacheTtlMs = 3.days.inWholeMilliseconds
private val maxCacheSize = 100
@@ -26,11 +30,17 @@ class SessionVideoApi(private val wrapper: VideoApi) : VideoApi {
videosCache.getValid(cacheKey)?.let { return it }
return videosMutex.withLock {
videosCache.getValid(cacheKey) ?: run {
var remoteData = wrapper.fetchNewVideos(query)
remoteData = if (remoteData == videosCache[""]?.data) {
VideosResponse(false, emptyList(), 1)
val remoteData = wrapper.fetchNewVideos(query).let { response ->
if (cacheKey.isNotEmpty()) {
response.copy(
items = filter.filter(
response.items,
cacheKey
)
)
} else {
remoteData
response
}
}
videosCache[cacheKey] = CacheEntry(remoteData)
remoteData
@@ -2,6 +2,7 @@ package ru.shadowsparky.vbox.backend.di
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import ru.shadowsparky.vbox.backend.data.FuzzyMovieSearchFilter
import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi
import ru.shadowsparky.vbox.backend.data.http.SessionVideoApi
import ru.shadowsparky.vbox.shared.domain.VideoApi
@@ -10,7 +11,10 @@ import ru.shadowsparky.vbox.shared.domain.VideoApi
class HttpModule {
@Factory
fun provideVideoApi(impl: ExternalBackendApi): VideoApi {
return SessionVideoApi(impl)
fun provideVideoApi(
impl: ExternalBackendApi,
filter: FuzzyMovieSearchFilter
): VideoApi {
return SessionVideoApi(impl, filter)
}
}
@@ -100,9 +100,7 @@ class HttpClientSettings(
defaultRequest {
header(HttpHeaders.Accept, ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json)
userAgentProvider?.provide() ?: appConfig?.userAgent?.let {
header(HttpHeaders.UserAgent, it)
}
header(HttpHeaders.UserAgent, (userAgentProvider?.provide() ?: appConfig?.userAgent))
}
install(HttpTimeout) {