add SessionVideoApi

This commit is contained in:
2026-08-07 13:39:00 +03:00
parent b851cf5d6d
commit 23d9912449
10 changed files with 119 additions and 50 deletions
@@ -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<String, VideosResponse>()
private val detailsCache = createBoundedCache<Long, VideoDetails>()
private val linksCache = createBoundedCache<VideoLinksKey, VideoLinksResponse>()
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 <K, V> MutableMap<K, CacheEntry<V>>.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 <K, V> createBoundedCache(): MutableMap<K, CacheEntry<V>> {
val map = object : LinkedHashMap<K, CacheEntry<V>>(maxCacheSize + 1, 0.75f, false) {
override fun removeEldestEntry(eldest: Map.Entry<K, CacheEntry<V>>?): Boolean {
return size > maxCacheSize
}
}
return Collections.synchronizedMap(map)
}
private data class CacheEntry<T>(val data: T, val createdAt: Long = System.currentTimeMillis())
private data class VideoLinksKey(val id: Long, val seasonId: Long?)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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")
}
@@ -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() }
)
}
}
@@ -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 }
)
}
}
@@ -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)
@@ -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,