From 23d99124497fe1fb9bc6f9d93720c5acf3cd9601 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 7 Aug 2026 13:39:00 +0300 Subject: [PATCH] add SessionVideoApi --- .../vbox/backend/data/http/SessionVideoApi.kt | 81 +++++++++++++++++++ .../vbox/backend/di/HttpModule.kt | 5 +- .../vbox/backend/presentation/Routing.kt | 2 - .../presentation/routing/AuthRouting.kt | 21 ++--- .../presentation/routing/BaseRouting.kt | 8 +- .../preview/PreferencePreview.kt | 12 --- .../presentation/settings/SettingsScreen.kt | 25 +++--- .../presentation/settings/tag/TagScreen.kt | 3 +- .../shadowsparky/http/domain/HttpException.kt | 4 +- .../ui/components/TextPreference.kt | 8 +- 10 files changed, 119 insertions(+), 50 deletions(-) create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt delete mode 100644 apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt 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 new file mode 100644 index 0000000..a49d88f --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/SessionVideoApi.kt @@ -0,0 +1,81 @@ +package ru.shadowsparky.vbox.backend.data.http + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import ru.shadowsparky.vbox.shared.domain.VideoApi +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails +import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse +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 { + private val cacheTtlMs = 3.days.inWholeMilliseconds + private val maxCacheSize = 100 + + private val videosCache = createBoundedCache() + private val detailsCache = createBoundedCache() + private val linksCache = createBoundedCache() + + private val videosMutex = Mutex() + private val detailsMutex = Mutex() + private val linksMutex = Mutex() + + override suspend fun fetchNewVideos(query: String?): VideosResponse { + val cacheKey = query ?: "" + videosCache.getValid(cacheKey)?.let { return it } + return videosMutex.withLock { + videosCache.getValid(cacheKey) ?: run { + val remoteData = wrapper.fetchNewVideos(query) + videosCache[cacheKey] = CacheEntry(remoteData) + remoteData + } + } + } + + override suspend fun fetchDetails(id: Long): VideoDetails { + detailsCache.getValid(id)?.let { return it } + return detailsMutex.withLock { + detailsCache.getValid(id) ?: run { + val remoteData = wrapper.fetchDetails(id) + detailsCache[id] = CacheEntry(remoteData) + remoteData + } + } + } + + override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse { + val cacheKey = VideoLinksKey(id, seasonId) + linksCache.getValid(cacheKey)?.let { return it } + return linksMutex.withLock { + linksCache.getValid(cacheKey) ?: run { + val remoteData = wrapper.fetchVideoLinks(id, seasonId) + linksCache[cacheKey] = CacheEntry(remoteData) + remoteData + } + } + } + + private fun MutableMap>.getValid(key: K): V? { + val entry = this[key] ?: return null + val isExpired = System.currentTimeMillis() - entry.createdAt > cacheTtlMs + return if (isExpired) { + this.remove(key) + null + } else { + entry.data + } + } + + private fun createBoundedCache(): MutableMap> { + val map = object : LinkedHashMap>(maxCacheSize + 1, 0.75f, false) { + override fun removeEldestEntry(eldest: Map.Entry>?): Boolean { + return size > maxCacheSize + } + } + return Collections.synchronizedMap(map) + } + + private data class CacheEntry(val data: T, val createdAt: Long = System.currentTimeMillis()) + private data class VideoLinksKey(val id: Long, val seasonId: Long?) +} 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 428b284..9a4a47d 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 @@ -3,11 +3,14 @@ package ru.shadowsparky.vbox.backend.di import org.koin.core.annotation.Factory import org.koin.core.annotation.Module import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi +import ru.shadowsparky.vbox.backend.data.http.SessionVideoApi import ru.shadowsparky.vbox.shared.domain.VideoApi @Module class HttpModule { @Factory - fun provideVideoApi(impl: ExternalBackendApi): VideoApi = impl + fun provideVideoApi(impl: ExternalBackendApi): VideoApi { + return SessionVideoApi(impl) + } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt index 336c515..497d337 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt @@ -12,7 +12,6 @@ import ru.shadowsparky.http.domain.HttpException import ru.shadowsparky.vbox.backend.di.AuthEntryPoint import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods -import ru.shadowsparky.vbox.backend.presentation.routing.setupBaseMethods import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo val routingLogger: Logger = LoggerFactory.getLogger("routing") @@ -38,7 +37,6 @@ fun Application.configureRouting( } } routing { - setupBaseMethods(routingEntryPoint.videoApi) setupAuthMethods(routingEntryPoint, authEntryPoint) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt index 9c4f6af..0ecb4d0 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt @@ -28,22 +28,23 @@ fun Routing.setupAuthMethods( post(AuthTokenRepository.UPDATE_PATH) { call.respond(default.update(call.receive())) } - post(AuthTokenRepository.REVOKE_PATH) { - default.revoke(call.receive()) - call.respond(HttpStatusCode.OK) - } authenticate(AUTH_JWT_NAME) { - get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) } - post(AuthTokenRepository.CHANGE_PASS_PATH) { - authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId()) - .changePassword(call.receive()) - call.respond(HttpStatusCode.OK) - } + setupBaseMethods(routingEntryPoint.videoApi) setupSearch(searchFactory) setupRecentlyWatched(recentlyFactory) setupSavedMovie(savedMovieFactory) setupTagsRouting(userTagFactory, movieTagFactory) setupUpdates(updateFetcherFactory) setupChat(chatRepositoryFactory, processUserMessageUseCase, remoteEventHandler) + get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) } + post(AuthTokenRepository.CHANGE_PASS_PATH) { + authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId()) + .changePassword(call.receive()) + call.respond(HttpStatusCode.OK) + } + post(AuthTokenRepository.REVOKE_PATH) { + default.revoke(call.receive()) + call.respond(HttpStatusCode.OK) + } } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt index 833bb44..7a44b09 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt @@ -1,13 +1,12 @@ package ru.shadowsparky.vbox.backend.presentation.routing -import io.ktor.http.HttpStatusCode import io.ktor.server.response.respond -import io.ktor.server.routing.Routing +import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.util.getOrFail import ru.shadowsparky.vbox.shared.domain.VideoApi -fun Routing.setupBaseMethods(api: VideoApi) { +fun Route.setupBaseMethods(api: VideoApi) { get(VideoApi.VIDEOS_PATH) { val p = call.parameters val obj = api.fetchNewVideos(p[VideoApi.SEARCH_ARG]) @@ -26,7 +25,4 @@ fun Routing.setupBaseMethods(api: VideoApi) { ) ) } - get("/") { - call.respond(HttpStatusCode.NotFound) - } } diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt deleted file mode 100644 index a88ee98..0000000 --- a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ru.shadowsparky.videobox.multiplatform.preview - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import ru.shadowsparky.ui.components.TextPreference - -@Preview -@Composable -fun SwitchPreferencePreview() { - TextPreference(Modifier, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "summary") -} diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt index 97b48d6..01e7d12 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt @@ -10,7 +10,6 @@ import androiddev.apps.vbox.client.shared.generated.resources.edit_password import androiddev.apps.vbox.client.shared.generated.resources.external_player_title import androiddev.apps.vbox.client.shared.generated.resources.offline_video import androiddev.apps.vbox.client.shared.generated.resources.user_tags -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -44,9 +43,9 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { if (servAddr != null && comp.isDebug) { item { TextPreference( - modifier = Modifier.clickable { comp.editServAddr() }, stringResource(Res.string.custom_serv_addr), - servAddr!! + { comp.editServAddr() }, + summary = servAddr!! ) } } @@ -64,21 +63,21 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { item { TextPreference( text = stringResource(Res.string.offline_video), - modifier = Modifier.clickable { comp.showOffline() } + onClick = { comp.showOffline() } ) } } item { TextPreference( text = stringResource(Res.string.user_tags), - modifier = Modifier.clickable { comp.showTags() } + onClick = { comp.showTags() } ) } if (comp.updateComponent.hasSupport) { item { TextPreference( text = stringResource(Res.string.check_for_updates), - modifier = Modifier.clickable { comp.checkUpdates() } + onClick = { comp.checkUpdates() } ) } } @@ -92,24 +91,22 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { Res.string.account_logout_auth } TextPreference( - modifier = Modifier.clickable { - if (needAuth) comp.doAuth() else comp.logout() - }, - stringResource(msg) + stringResource(msg), + { if (needAuth) comp.doAuth() else comp.logout() } ) } if (needAuth) { item { TextPreference( - modifier = Modifier.clickable { comp.doRegister() }, - stringResource(Res.string.account_reg) + stringResource(Res.string.account_reg), + { comp.doRegister() } ) } } else { item { TextPreference( - modifier = Modifier.clickable { comp.editPassword() }, - stringResource(Res.string.edit_password) + stringResource(Res.string.edit_password), + { comp.editPassword() } ) } } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/tag/TagScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/tag/TagScreen.kt index dbc452c..3ef7a0b 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/tag/TagScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/tag/TagScreen.kt @@ -8,7 +8,6 @@ import androiddev.apps.vbox.client.shared.generated.resources.user_tags_new_tag import androiddev.apps.vbox.client.shared.generated.resources.user_tags_new_tag_save import androiddev.apps.vbox.client.shared.generated.resources.user_tags_save import androiddev.apps.vbox.client.shared.generated.resources.user_tags_tag_delete -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -66,7 +65,7 @@ fun TagScreen(component: TagComponent) { ) { tag -> TextPreference( text = tag.tag, - modifier = Modifier.clickable { activeTag = tag } + { activeTag = tag } ) } } diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt index bf1295c..c7d47e2 100644 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt @@ -2,7 +2,9 @@ package ru.shadowsparky.http.domain import io.ktor.http.HttpStatusCode -open class HttpException(val httpCode: Int, override val message: String?) : RuntimeException() +open class HttpException(val httpCode: Int, override val message: String?) : RuntimeException() { + constructor(code: HttpStatusCode) : this(code.value, code.description) +} class BadRequestException(msg: String) : HttpException(HttpStatusCode.BadRequest.value, msg) open class NotFoundException(msg: String) : HttpException(HttpStatusCode.NotFound.value, msg) diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt index 02886cf..0aab711 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt @@ -1,6 +1,7 @@ package ru.shadowsparky.ui.components import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -10,16 +11,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun TextPreference( - modifier: Modifier = Modifier, text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, summary: String? = null ) { ListItem( colors = ListItemDefaults.colors(Color.Transparent), + onClick = onClick, modifier = modifier.fillMaxWidth(), - headlineContent = { + content = { Text( text, maxLines = 1,