From 9e40ab2fd1ba787394ae8683dd9b75159d631349 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 9 Aug 2026 17:46:36 +0300 Subject: [PATCH] add FuzzyMovieSearchFilter --- .../vbox/backend/BackendModule.kt | 2 +- .../vbox/backend/data/FuzzySearchFilter.kt | 87 +++++++++++++++++++ .../vbox/backend/data/http/SessionVideoApi.kt | 22 +++-- .../vbox/backend/di/HttpModule.kt | 8 +- .../http/data/HttpClientSettings.kt | 4 +- 5 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/FuzzySearchFilter.kt diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt index 6bd55b6..92c7a15 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt @@ -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" } } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/FuzzySearchFilter.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/FuzzySearchFilter.kt new file mode 100644 index 0000000..f8c8764 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/FuzzySearchFilter.kt @@ -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, searchQuery: String): List { + 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 + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt index b1b829a..d7022f8 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt @@ -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) - } else { - remoteData + val remoteData = wrapper.fetchNewVideos(query).let { response -> + if (cacheKey.isNotEmpty()) { + response.copy( + items = filter.filter( + response.items, + cacheKey + ) + ) + } else { + response + } } videosCache[cacheKey] = CacheEntry(remoteData) remoteData diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt index 9a4a47d..ec6f139 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt @@ -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) } } diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt index 1909ede..70c95ec 100644 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt @@ -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) {