init projects

This commit is contained in:
2026-06-17 18:58:41 +03:00
parent 8543c4e241
commit 21a768edd5
505 changed files with 19998 additions and 93 deletions
+4
View File
@@ -0,0 +1,4 @@
/build
/videobox
/bin
back.env
+6
View File
@@ -0,0 +1,6 @@
FROM bellsoft/liberica-openjdk-alpine:21
WORKDIR /usr/local/app
COPY ./build/libs/server-fat-1.0.jar ./server-fat-1.0.jar
EXPOSE 8181
CMD ["java", "-jar", "server-fat-1.0.jar"]
+111
View File
@@ -0,0 +1,111 @@
plugins {
alias(libs.plugins.convention.backend.app)
alias(libs.plugins.ktor)
alias(libs.plugins.sqldelight)
alias(libs.plugins.serialization)
alias(libs.plugins.convention.koin)
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("ru.shadowsparky.vbox.backend")
dialect("app.cash.sqldelight:postgresql-dialect:2.0.2")
}
}
}
dependencies {
implementation(project(":apps:vbox:common"))
implementation(project(":libs:backend-base"))
implementation(project(":libs:base"))
implementation(project(":libs:http-client"))
implementation(libs.ktor.server.core.jvm)
implementation(libs.ktor.server.host.common.jvm)
implementation(libs.ktor.server.status.pages.jvm)
implementation(libs.ktor.server.content.negotiation.jvm)
implementation(libs.ktor.serialization.kotlinx.json.jvm)
implementation(libs.ktor.server.netty.jvm)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.auth.jwt)
implementation(libs.ktor.server.websockets.jvm)
implementation(libs.ktor.server.cors)
implementation(libs.androidx.datastore.preferences.core)
implementation(libs.kxml2)
implementation(libs.jsoup)
implementation(libs.jdbc.driver)
implementation(libs.postgresql)
implementation(libs.coroutines.extensions)
testImplementation(libs.koin.test)
testImplementation(libs.mockk)
testImplementation(libs.junit)
}
java {
sourceCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
targetCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
}
group = "ru.shadowsparky.vbox"
version = "1.0"
val mainClassName = "ru.shadowsparky.vbox.backend.MainKt"
application {
mainClass.set(mainClassName)
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
tasks {
register("fatJar", Jar::class) {
archiveBaseName = "server-fat"
manifest {
attributes["Implementation-Title"] = "Videobox Server"
attributes["Implementation-Version"] = version
attributes["Main-Class"] = mainClassName
}
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
duplicatesStrategy = DuplicatesStrategy.WARN
with(jar.get() as CopySpec)
}
"build" {
dependsOn("fatJar")
}
register<Exec>("deployRemote") {
dependsOn("fatJar")
commandLine(
getCommandLine(
"docker context use remote",
"docker build . -t videobox-server",
"docker context use default"
)
)
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
}
register<Exec>("deployLocal") {
dependsOn("fatJar")
commandLine(
getCommandLine(
"docker compose down",
"docker compose up -d --build --force-recreate"
)
)
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
}
}
private fun getCommandLine(vararg commands: String): List<String> {
val osName = System.getProperty("os.name").lowercase()
return if (osName.contains("win")) {
listOf("cmd", "/c") + commands.joinToString(" && ")
} else {
listOf("sh", "-c") + commands.joinToString(" && ")
}
}
+39
View File
@@ -0,0 +1,39 @@
version: '3'
services:
postgres:
image: postgres:16-alpine
restart: always
env_file:
- postgres.env
ports:
- "5432:5432"
networks:
- serv-network
volumes:
- pgdata:/var/lib/postgresql/data
- ./src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq:/docker-entrypoint-initdb.d/init.sql:ro
videobox:
container_name: videobox
build:
context: .
dockerfile: Dockerfile
env_file:
- postgres.env
- back.env
ports:
- 8181:8181
restart: always
volumes:
- ./videobox/cache/:/usr/local/app/cache:rw
networks:
- serv-network
depends_on:
- postgres
networks:
serv-network:
driver: bridge
volumes:
pgdata:
+8
View File
@@ -0,0 +1,8 @@
POSTGRES_DB=videobox
POSTGRES_USER=login
POSTGRES_PASSWORD=password
VIDEOBOX_SERVER_URI=postgres
VIDEOBOX_SECRET=144b5446caea8d7d1a33cc87b0447f3be608f0b12bef3d130ec37264375df912468411e37b432299d7f037db81f099b4b3572d4898fa6cc154e6931d7d12f78f
ALLOW_REGISTRATION=1
+9
View File
@@ -0,0 +1,9 @@
# Videobox Server
## Deploy
### Remote
Сначала нужно добавить remote context
`docker context create remote --docker "host=ssh://root@localhost"`.
Далее публикация происходит таской gradle: `gradle :apps:vbox:backend:deployRemote`
@@ -0,0 +1,35 @@
package ru.shadowsparky.vbox.backend
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.backend.domain.JwtInfo
import ru.shadowsparky.vbox.backend.di.DbModule
import ru.shadowsparky.vbox.backend.di.EventModule
import ru.shadowsparky.vbox.backend.di.HttpModule
@Module
class VboxModule {
@Single
fun provideJwtInfo(envFetcher: EnvFetcher): JwtInfo {
return JwtInfo(
envFetcher.get("VIDEOBOX_SECRET", "secret"),
"https://shadowsparky.ru/",
"https://box.shadowsparky.ru/",
"server side data storage"
)
}
}
@Module(
includes = [
DbModule::class,
HttpModule::class,
EventModule::class,
VboxModule::class
]
)
@ComponentScan
class BackendModule
@@ -0,0 +1,27 @@
package ru.shadowsparky.vbox.backend
import io.ktor.server.application.Application
import org.koin.core.annotation.KoinApplication
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.plugin.module.dsl.startKoin
import ru.shadowsparky.vbox.backend.presentation.configureJwt
import ru.shadowsparky.vbox.backend.presentation.configureRouting
import ru.shadowsparky.vbox.backend.presentation.configureSerialization
import ru.shadowsparky.vbox.backend.presentation.configureWebSocket
@KoinApplication
object VBoxBackend
fun main(args: Array<String>) {
startKoin<VBoxBackend>()
io.ktor.server.netty.EngineMain.main(args)
}
fun Application.module() {
val koin = object : KoinComponent {}
configureSerialization()
configureJwt(koin.get())
configureWebSocket(koin.get())
configureRouting(koin.get(), koin.get())
}
@@ -0,0 +1,103 @@
package ru.shadowsparky.vbox.backend.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
import java.sql.SQLException
class BackendRecentlyWatchedRepository(
private val db: AppDatabase,
private val userId: Long,
private val dispatcherProvider: DispatcherProvider,
private val remoteEventHandler: RemoteEventHandler
) : RecentlyWatchedRepository {
override suspend fun remove(info: RecentlyWatchedInfo) =
withContext(dispatcherProvider.io) {
db.recentQueries.deleteRwByIds(info.movieId, info.episode, info.season, userId)
notifyChanged(info.movieId)
}
override suspend fun write(info: RecentlyWatchedInfo) =
withContext(dispatcherProvider.io) {
try {
db.recentQueries.insertRecentlyWatched(
userId,
info.movieId,
info.episode,
info.season
)
notifyChanged(info.movieId)
} catch (ignored: SQLException) {
}
}
suspend fun toggleInfo(info: RecentlyWatchedInfo) =
withContext(dispatcherProvider.io) {
val hasInfo =
db.recentQueries.selectByIds(info.movieId, info.episode, info.season, userId)
.executeAsOneOrNull() != null
if (hasInfo) {
remove(info)
} else {
write(info)
}
}
override fun queryRecentlyWatched(
movieId: Long,
seasonId: Long?
): Flow<List<RecentlyWatchedInfo>> = flow {
val rsp = if (seasonId == null) {
db.recentQueries.selectByMovieId(movieId, userId)
.executeAsList()
.map {
RecentlyWatchedInfo(
it.movie_id,
it.episode,
it.season
)
}
} else {
db.recentQueries.selectByMovieIdAndSeason(movieId, userId, seasonId)
.executeAsList()
.map {
RecentlyWatchedInfo(
it.movie_id,
it.episode,
seasonId
)
}
}
emit(rsp)
}
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
return withContext(dispatcherProvider.io) {
db.recentQueries.selectAll(userId).executeAsList()
.map {
RecentlyWatchedInfo(
it.movie_id,
it.episode,
it.season
)
}
}
}
private suspend fun notifyChanged(seasonId: Long) {
remoteEventHandler.notify(
RemoteEvent.OnRecent(
System.currentTimeMillis(),
userId,
seasonId
)
)
}
}
@@ -0,0 +1,43 @@
package ru.shadowsparky.vbox.backend.data
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
val eventLogger: Logger = LoggerFactory.getLogger("events")
@Factory
class BackendRemoteEventHandler(
private val json: Json,
private val sessionCache: SessionCache
) : RemoteEventHandler {
suspend fun put(userId: Long, socketSession: SessionCache.Writer) {
sessionCache.put(userId, socketSession)
}
suspend fun remove(userId: Long, socketSession: SessionCache.Writer) {
sessionCache.remove(userId, socketSession)
}
override suspend fun notify(eventInfo: RemoteEvent) {
val json = json.encodeToString(eventInfo)
val writers = sessionCache.get(eventInfo.userId)
if (writers.isNullOrEmpty()) {
eventLogger.info("unable to notify $eventInfo. sessions not found, cache $sessionCache")
} else {
writers.forEach {
eventLogger.info("notify[$eventInfo]. session $it", RuntimeException("called"))
try {
it.writeText(json)
} catch (_: Exception) {
eventLogger.error("unable to notify $it. delete session")
remove(eventInfo.userId, it)
}
}
}
}
}
@@ -0,0 +1,79 @@
package ru.shadowsparky.vbox.backend.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import ru.shadowsparky.backend.data.Logger
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.server.Saved_movie
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import java.sql.SQLException
class BackendSavedMovieRepository(
private val db: AppDatabase,
private val userId: Long,
private val dispatcherProvider: DispatcherProvider,
private val logger: Logger,
private val eventHandler: RemoteEventHandler
) : SavedMovieRepository {
override fun getAll(): Flow<List<VideoDetails>> {
val request = db.saved_movieQueries.selectByUserId(userId)
return flow {
emit(request.executeAsList().parse().reversed())
}
}
private suspend fun List<Saved_movie>.parse(): List<VideoDetails> =
withContext(dispatcherProvider.io) {
mapNotNull {
val movie = db.movieQueries.selectMovie(it.movie_id)
.executeAsOneOrNull() ?: return@mapNotNull null
VideoDetails(
movie.movie_id,
movie.poster_url,
movie.description,
movie.title,
(movie.flags and SERIAL_FLAG) != 0
)
}
}
override suspend fun save(details: VideoDetails) {
logger.debug(TAG, "save(${details.id}) ${details.title}")
withContext(dispatcherProvider.io) {
try {
db.saved_movieQueries.addSavedMovie(
details.id,
userId
)
eventHandler.notify(RemoteEvent.OnSaved(userId, details.id))
} catch (_: SQLException) {
}
}
}
override fun isSaved(id: Long): Flow<Boolean> {
return flow {
val request = db.saved_movieQueries.selectByUserIdAndMovieId(userId, id)
emit(request.executeAsOneOrNull() != null)
}
}
override suspend fun remove(id: Long) {
logger.debug(TAG, "remove(${id})")
withContext(dispatcherProvider.io) {
db.saved_movieQueries.removeSavedMovie(userId, id)
eventHandler.notify(RemoteEvent.OnSaved(userId, id))
}
}
private companion object {
const val TAG = "BackendSavedMovieRepository"
}
}
@@ -0,0 +1,64 @@
package ru.shadowsparky.vbox.backend.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.SearchRepository
import java.sql.SQLException
class BackendSearchRepository(
private val db: AppDatabase,
private val userId: Long,
private val dispatcherProvider: DispatcherProvider,
private val eventHandler: RemoteEventHandler
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return flow { emit(searchSingle(query)) }
}
private suspend fun searchSingle(query: String): List<String> = withContext(Dispatchers.IO) {
if (query.trim().isEmpty()) {
db.searchQueries.selectAll(userId)
} else {
db.searchQueries.selectByQuery(query.addWilcard(), userId)
}.executeAsList().map { it.query }
}
private fun String.addWilcard(): String {
return if (this.endsWith("%")) this else "$this%"
}
override suspend fun addToSearch(query: String) {
withContext(dispatcherProvider.io) {
try {
db.searchQueries.insertSearchInfo(query, userId)
notifyChanged()
} catch (_: SQLException) {
}
}
}
override suspend fun deleteFromSearch(query: String) {
withContext(dispatcherProvider.io) {
db.searchQueries.deleteSearchInfo(query, userId)
notifyChanged()
}
}
override suspend fun clear() {
withContext(dispatcherProvider.io) {
db.searchQueries.deleteAll(userId)
notifyChanged()
}
}
private suspend fun notifyChanged() {
eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId))
}
}
@@ -0,0 +1,42 @@
package ru.shadowsparky.vbox.backend.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Factory
import java.util.Collections
typealias SessionMap = MutableMap<Long, List<SessionCache.Writer>>
@Factory
class SessionCache {
private val sessionMap: SessionMap = Collections.synchronizedMap(hashMapOf())
private val mutex = Mutex()
suspend fun put(userId: Long, socketSession: Writer) {
eventLogger.info("put[$userId]=$socketSession")
mutex.withLock {
sessionMap[userId] = (sessionMap[userId] ?: mutableListOf()) + listOf(socketSession)
}
}
suspend fun get(userId: Long): List<Writer>? {
mutex.withLock {
return sessionMap[userId]
}
}
suspend fun remove(userId: Long, session: Writer) {
eventLogger.info("remove[$userId]=$session")
mutex.withLock {
val sessionFromMap = sessionMap[userId]?.toMutableList()
val item = sessionFromMap?.firstOrNull { it == session } ?: return
sessionFromMap.remove(item)
sessionMap[userId] = sessionFromMap
}
}
fun interface Writer {
suspend fun writeText(text: String)
}
}
@@ -0,0 +1,169 @@
package ru.shadowsparky.vbox.backend.data.auth
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.backend.data.JwtPreparer
import ru.shadowsparky.backend.data.StringFetcher
import ru.shadowsparky.backend.data.StringResource
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.http.domain.TokenInfo
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.server.Refresh_tokens
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
import ru.shadowsparky.vbox.shared.domain.TokenRequest
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import kotlin.io.encoding.Base64
@Factory
class AuthTokenRepositoryFactory(
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
private val jwtPreparer: JwtPreparer,
private val loginVerifier: LoginVerifier,
private val log: Log,
private val stringFetcher: StringFetcher,
private val envFetcher: EnvFetcher
) {
fun create(userId: Long = -1): AuthTokenRepository {
return BackendAuthTokenRepository(
db,
dispatcherProvider,
jwtPreparer,
loginVerifier,
userId,
log,
stringFetcher,
envFetcher
)
}
}
class BackendAuthTokenRepository(
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
private val jwtPreparer: JwtPreparer,
private val loginVerifier: LoginVerifier,
private val userId: Long,
private val log: Log,
private val stringFetcher: StringFetcher,
private val envFetcher: EnvFetcher
) : AuthTokenRepository {
private val random = SecureRandom()
override suspend fun register(loginInfo: LoginInfo): TokenInfo {
return if (envFetcher.get("ALLOW_REGISTRATION", "0") == "1") {
registerInternal(loginInfo)
} else {
throw BadRequestException(stringFetcher.get(StringResource.REG_BLOCKED))
}
}
private suspend fun registerInternal(loginInfo: LoginInfo): TokenInfo {
return withContext(dispatcherProvider.io) {
val user = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
if (user != null) throw BadRequestException(stringFetcher.get(StringResource.ALREADY_REGISTERED))
db.usersQueries.addUser(
loginInfo.login, loginInfo.passwordHash, System.currentTimeMillis(), null
)
TokenInfo("")
}
}
override suspend fun login(loginInfo: LoginInfo): TokenInfo =
withContext(dispatcherProvider.io) {
val userInfo = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
if (loginInfo.passwordHash != userInfo.password_hash) {
throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
}
create(loginInfo.login)
}
override suspend fun update(refresh: TokenRequest): TokenInfo {
val info = db.refresh_tokensQueries.selectByHash(refresh.token.toHash())
.executeAsOneOrNull()
?: throw BadRequestException("Token not found")
val userInfo = db.usersQueries.selectUserByUserId(info.user_id).executeAsOneOrNull()
?: throw BadRequestException("User not found")
val newTokens = create(userInfo.login)
revokeInternal(refresh)
return newTokens
}
override suspend fun revoke(tokenRequest: TokenRequest) {
revokeInternal(tokenRequest)
}
private suspend fun revokeInternal(request: TokenRequest) {
log.d("BackendAuthTokenRepository", "revoke ${request.token.toHash()}")
val info = getTokenAndCheck(request)
db.refresh_tokensQueries.revokeToken(info.token_id).await()
}
private suspend fun getTokenAndCheck(tokenRequest: TokenRequest): Refresh_tokens =
withContext(dispatcherProvider.io) {
val info = db.refresh_tokensQueries.selectByHash(tokenRequest.token.toHash())
.executeAsOneOrNull()
?: throw BadRequestException("Token not found")
if (info.revoked) throw BadRequestException("Token already revoked")
if (info.expires_at < System.currentTimeMillis()) {
throw BadRequestException(
"Refresh token expired. " +
"Expires at ${info.expires_at} < ${System.currentTimeMillis()}"
)
}
info
}
private suspend fun create(login: String): TokenInfo = withContext(dispatcherProvider.io) {
loginVerifier.verify(login)
val userInfo = db.usersQueries.selectUserByLogin(login).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
val rsp = jwtPreparer.prepare(login, userInfo.user_id)
val refresh = ByteArray(32)
random.nextBytes(refresh)
val refreshStr = refresh.toHexString()
db.refresh_tokensQueries.insertToken(
userInfo.user_id,
refreshStr.toHash(),
System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30)
).executeAsOneOrNull()
log.d(
"BackendAuthTokenRepository",
"${refreshStr.toHash()} created for user ${userInfo.user_id}"
)
TokenInfo(rsp, refreshStr)
}
override suspend fun changePassword(request: ChangePasswordRequest): Unit =
withContext(dispatcherProvider.io) {
val user = db.usersQueries.selectUserByUserId(userId).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
if (user.password_hash != request.oldPasswordHash) {
throw BadRequestException(stringFetcher.get(StringResource.INVALID_PASSWORD))
} else if (user.password_hash == request.newPasswordHash) {
throw BadRequestException(stringFetcher.get(StringResource.NO_CHANGES_PASS))
}
db.usersQueries.updatePassword(request.newPasswordHash, userId).await()
}
private fun String.toHash(): String {
val md = MessageDigest.getInstance("SHA-256")
md.update(SALT.toByteArray())
val result = md.digest(this.toByteArray())
return Base64.encode(result)
}
private companion object {
const val SALT = "G0yCPoy7gCj1a5OxPaeYLeJm69NvPQ50HbV0ZVYVqahUPdKun4MKgg86u9HbWq2e"
}
}
@@ -0,0 +1,26 @@
package ru.shadowsparky.vbox.backend.data.auth
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.StringFetcher
import ru.shadowsparky.backend.data.StringResource
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.backend.domain.VerifyTokenException
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.shared.domain.FLAG_USER_BLOCKED
@Factory
class BackendLoginVerifier(
private val db: AppDatabase,
private val stringFetcher: StringFetcher
) : LoginVerifier {
override suspend fun verify(login: String) {
val user = db.usersQueries.selectUserByLogin(login)
.executeAsOneOrNull()
?: throw VerifyTokenException(stringFetcher.get(StringResource.UNKNOWN_USER))
user.flags?.let {
if ((it and FLAG_USER_BLOCKED) != 0) {
throw VerifyTokenException(stringFetcher.get(StringResource.USER_BLOCKED))
}
}
}
}
@@ -0,0 +1,137 @@
package ru.shadowsparky.vbox.backend.data.http
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.backend.data.StringFetcher
import ru.shadowsparky.backend.data.StringResource
import ru.shadowsparky.http.domain.NotFoundException
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.Season
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
private const val FALLBACK_SEASON_ID = -1L
private const val SEASON_FALLBACK_NAME = "Сезон 1"
@Factory(binds = [ExternalBackendApi::class])
class ExternalBackendApi(
private val client: HttpClient,
private val mapper: ExternalBackendMapper,
private val cache: ExternalCache,
private val envFetcher: EnvFetcher,
private val stringFetcher: StringFetcher
) : VideoApi {
private val notFoundException =
NotFoundException(stringFetcher.get(StringResource.VIDEO_NOT_FOUND))
private val endpoint = envFetcher.get(BACKEND_HOST)
override suspend fun fetchNewVideos(query: String?): VideosResponse {
return coroutineScope {
val movies = async { fetchFavoritesInternal("movie", query) }
val series = async { fetchFavoritesInternal("series", query) }
val anime = async { fetchFavoritesInternal("anime", query) }
val result = (series.await() + movies.await() + anime.await())
result.map {
async { cache.put(it) }
}.awaitAll()
return@coroutineScope VideosResponse(false, result, 1)
}
}
private suspend fun fetchFavoritesInternal(request: String, search: String?): List<VideoItem> {
val rsp = client.get {
url("https", endpoint, path = "${envFetcher.get(FAVORITES_PREFIX)}$request")
if (search != null) {
parameter("search", search)
}
}
val rspText = rsp.bodyAsText()
return mapper.movieRspToItems(rspText, request)
}
override suspend fun fetchDetails(id: Long): VideoDetails {
val info = cache.get(id) ?: throw notFoundException
return VideoDetails(
poster = mapper.mapPoster(info.poster),
id = info.id,
title = info.title,
desc = info.description ?: "",
isSerial = info.isSerial
)
}
override suspend fun fetchVideoLinks(
id: Long,
seasonId: Long?
): VideoLinksResponse {
val isSerial = cache.get(id)?.isSerial ?: throw notFoundException
return if (isSerial) {
val seasons = mutableListOf<Season>()
if (seasonId == null) {
getSerialSeasonIds(id).forEach { seasons.add(Season(emptyList(), it.id, it.title)) }
if (seasons.isEmpty()) {
seasons.add(Season(emptyList(), FALLBACK_SEASON_ID, SEASON_FALLBACK_NAME))
}
} else {
if (seasonId == FALLBACK_SEASON_ID) {
seasons.add(getSerialSeasonFallback(id))
} else {
val entry = getSerialSeasonIds(id).firstOrNull { it.id == seasonId }
?: throw notFoundException
seasons.add(getSeason(entry))
}
}
if (seasons.isEmpty()) throw notFoundException
VideoLinksResponse(seasons = seasons)
} else {
val rsp = client.get {
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
parameter("id", id)
parameter("movie", "")
}.bodyAsText()
VideoLinksResponse(files = mapper.parseMovie(rsp) ?: throw notFoundException)
}
}
private suspend fun getSerialSeasonIds(id: Long): List<ExternalBackendMapper.SeasonEntry> {
val rsp = client.get {
url("https", endpoint, path = envFetcher.get(SEASON_IDS_PATH))
parameter("id", id)
}
val text = rsp.bodyAsText()
return mapper.categoryRspToSeasonInfo(text)
}
private suspend fun getSerialSeasonFallback(id: Long): Season {
val rsp = client.get {
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
parameter("id", id)
}
val text = rsp.bodyAsText()
return mapper.parseSeason(
text,
ExternalBackendMapper.SeasonEntry(
SEASON_FALLBACK_NAME,
FALLBACK_SEASON_ID
)
)
}
private suspend fun getSeason(entry: ExternalBackendMapper.SeasonEntry): Season {
val rsp2 = client.get {
url("https", endpoint, path = envFetcher.get(SEASON_PATH))
parameter("id", entry.id)
}.bodyAsText()
return mapper.parseSeason(rsp2, entry)
}
}
@@ -0,0 +1,199 @@
package ru.shadowsparky.vbox.backend.data.http
import org.jsoup.Jsoup
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.vbox.shared.domain.model.Episode
import ru.shadowsparky.vbox.shared.domain.model.File
import ru.shadowsparky.vbox.shared.domain.model.Season
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
import java.net.URI
@Factory
class ExternalBackendMapper(
private val envFetcher: EnvFetcher
) {
private val readerFactory: () -> XmlReader = { XmlReader() }
fun movieRspToItems(rsp: String, rootTag: String): List<VideoItem> {
val reader = readerFactory().apply { setInput(rsp) }
return readRoot(reader, rootTag).toItems()
}
fun categoryRspToSeasonInfo(rsp: String): List<SeasonEntry> {
val reader = readerFactory().apply { setInput(rsp) }
val movies = readRoot(reader, "category").unit
return movies.map { SeasonEntry(it.title!!, it.id.toLong()) }
}
fun parseSeason(rsp: String, seasonEntry: SeasonEntry): Season {
val reader = readerFactory().apply { setInput(rsp) }
val videos = readRoot(reader, "video").unit
val episodes = mutableListOf<Episode>()
videos.forEachIndexed { index, hlamerUnit ->
episodes.add(hlamerUnit.toEpisode(index + 1L))
}
return Season(episodes, seasonEntry.id, seasonEntry.title.parseHtml())
}
fun parseMovie(rsp: String): List<File>? {
val reader = readerFactory().apply { setInput(rsp) }
val files = readRoot(reader, "video").unit.firstOrNull() ?: return null
return listOf(File(false, -1, files.file?.loadMp4FromSparky() ?: return null))
}
private fun ExternalUnit.toEpisode(epNumber: Long): Episode {
val hasTitle = title?.trim()?.isNotEmpty() == true
val splitTitle = title?.split("&quot;")
?.lastOrNull { it.isNotEmpty() }
?: title?.removeSurrounding("&quot;")
val episodeText = "Серия"
return Episode(
epNumber,
listOf(File(false, -1, file!!.loadMp4FromSparky())),
if (hasTitle) "$epNumber. $splitTitle" else "$episodeText $epNumber"
)
}
private fun readRoot(reader: XmlReader, rootTag: String): Movie {
reader.require(XmlReader.Tag.START, rootTag)
val entries = mutableListOf<ExternalUnit>()
while (reader.next != XmlReader.Tag.END) {
if (reader.eventType != XmlReader.Tag.START) {
continue
}
if (reader.name == "unit") {
val entry = readUnitEntry(reader)
entries.add(entry)
}
}
return Movie(entries)
}
private fun readUnitEntry(reader: XmlReader): ExternalUnit {
reader.require(XmlReader.Tag.START, "unit")
val id = reader.getAttributeValue("id")
var title: String? = null
var description: String? = null
var thumb: String? = null
var section: String? = null
var file: String? = null
var image: String? = null
while (reader.next != XmlReader.Tag.END) {
if (reader.eventType != XmlReader.Tag.START) {
continue
}
when (reader.name) {
"title" -> title = readText(reader)
"description" -> description = readText(reader)
"thumb" -> thumb = readText(reader)
"section" -> section = readText(reader)
"file" -> file = readText(reader)
"image" -> image = readText(reader)
else -> readText(reader) // skip
}
}
return ExternalUnit(id, title, description, thumb, section, file, image)
}
private fun readText(reader: XmlReader): String {
var result = ""
if (reader.next == XmlReader.Tag.TEXT) {
result = reader.text
reader.nextTag()
}
return result
}
private fun Movie.toItems(): List<VideoItem> {
return unit.mapNotNull { it.toVideoItem() }
}
private fun ExternalUnit.toVideoItem(): VideoItem? {
val poster = thumb?.loadPosterFromSparky() ?: return null
if (poster.endsWith("/_180.jpg")) {
return null
}
val isMovie = section == "movie"
return VideoItem(
id = id.toLong(),
title = title?.parseHtml() ?: return null,
poster = poster,
description = description?.parseHtml(),
isSerial = !isMovie
)
}
private fun String.loadMp4FromSparky(): String {
return mapExternalToBox(this) ?: this
}
private val hostRegex = Regex("""^m(\d+)\.${envFetcher.get(REGEX_HOST)}\.ru$""")
private fun mapExternalToBox(sourceUrl: String): String? {
val uri = try {
URI(sourceUrl)
} catch (_: Exception) {
return null
}
val host = uri.host ?: return null
val m = hostRegex.matchEntire(host) ?: return null
val idx = m.groupValues[1]
val rawPath = uri.rawPath ?: return null
if (!rawPath.startsWith("/video")) return null
val rest = rawPath.removePrefix("/video")
val newPath = "/video/m$idx$rest"
return URI(
"https",
null,
"box.shadowsparky.ru",
uri.port,
newPath,
uri.rawQuery,
uri.rawFragment
).toString()
}
fun mapPoster(poster: String): String {
return poster.loadPosterFromSparky()
}
private fun String.loadPosterFromSparky(): String {
val imageEndpoint = envFetcher.get(IMAGE_ENDPOINT)
if (startsWith(imageEndpoint)) {
return replace(imageEndpoint, SPARKY_IMAGE_ENDPOINT)
}
return this
}
data class SeasonEntry(
val title: String,
val id: Long
)
private data class Movie(
val unit: List<ExternalUnit>
)
private data class ExternalUnit(
val id: String,
val title: String?,
val description: String?,
val thumb: String?,
val section: String?,
val file: String?,
val image: String?
)
private fun String.parseHtml(): String {
return Jsoup.parse(this@parseHtml).wholeText()
}
companion object {
private const val SPARKY_IMAGE_ENDPOINT = "https://box.shadowsparky.ru/images/"
}
}
@@ -0,0 +1,66 @@
package ru.shadowsparky.vbox.backend.data.http
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.model.MOVIE_FLAG
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
@Single
class ExternalCache(
private val dispatcherProvider: DispatcherProvider,
private val db: AppDatabase
) {
suspend fun put(item: VideoItem) {
insertIfNeeded(item)
}
suspend fun get(id: Long): VideoItem? = queryOrNull(id)
private suspend fun queryOrNull(id: Long): VideoItem? {
return withContext(dispatcherProvider.io) {
val oldInfo = db.movieQueries.selectMovie(id)
.executeAsOneOrNull()
?: return@withContext null
VideoItem(
oldInfo.movie_id,
oldInfo.poster_url,
oldInfo.title,
oldInfo.description,
oldInfo.flags == SERIAL_FLAG
)
}
}
private suspend fun insertIfNeeded(new: VideoItem) = withContext(dispatcherProvider.io) {
val old = queryOrNull(new.id)
if (old == null) {
db.movieQueries.insertMovie(
new.id,
new.poster,
new.description ?: "",
new.title,
new.serializeFlags()
)
} else if (old != new) {
db.movieQueries.updateMovie(
new.poster,
new.description ?: "",
new.title,
new.serializeFlags(),
new.id
)
}
}
private fun VideoItem.serializeFlags(): Int {
return if (isSerial) {
SERIAL_FLAG
} else {
MOVIE_FLAG
}
}
}
@@ -0,0 +1,10 @@
package ru.shadowsparky.vbox.backend.data.http
const val REGEX_HOST = "EXTERNAL_HOST"
const val IMAGE_ENDPOINT = "EXTERNAL_IMAGE_ENDPOINT"
const val CACHE_DIR = "EXTERNAL_CACHE_DIR"
const val BACKEND_HOST = "EXTERNAL_BACKEND_HOST"
const val FAVORITES_PREFIX = "EXTERNAL_FAVORITES_PREFIX"
const val LINKS_PATH = "EXTERNAL_LINKS_PATH"
const val SEASON_IDS_PATH = "EXTERNAL_SEASON_IDS_PATH"
const val SEASON_PATH = "EXTERNAL_SEASON_PATH"
@@ -0,0 +1,61 @@
package ru.shadowsparky.vbox.backend.data.http
import org.kxml2.io.KXmlParser
import org.xmlpull.v1.XmlPullParser
import java.io.StringReader
class XmlReader {
private var parser: XmlPullParser? = null
val name: String
get() = parser!!.name
val eventType: Tag
get() = parser!!.eventType.fromType()
val next: Tag
get() = parser!!.next().fromType()
fun setInput(raw: String) {
parser = KXmlParser().apply {
setInput(StringReader(raw))
nextTag()
}
}
fun require(
tag: Tag,
name: String
) {
parser?.require(tag.toType(), null, name)
}
fun Tag.toType(): Int {
return when (this) {
Tag.START -> XmlPullParser.START_TAG
Tag.END -> XmlPullParser.END_TAG
Tag.TEXT -> XmlPullParser.TEXT
}
}
fun Int.fromType(): Tag {
return when (this) {
XmlPullParser.START_TAG -> Tag.START
XmlPullParser.END_TAG -> Tag.END
else -> Tag.TEXT
}
}
fun getAttributeValue(name: String): String {
return parser!!.getAttributeValue(null, name)
}
fun nextTag() {
parser!!.nextTag()
}
enum class Tag {
START, END, TEXT
}
val text: String
get() = parser!!.text
}
@@ -0,0 +1,64 @@
package ru.shadowsparky.vbox.backend.data.tags
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
@Factory
class MovieTagRepositoryFactory(
private val appDatabase: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
@Named(EventType.MOVIE_TAG) private val movieEventHandler: RemoteEventHandler
) {
fun create(userId: Long): MovieTagRepository {
return BackendMovieTagRepository(
appDatabase,
dispatcherProvider,
userId,
movieEventHandler
)
}
}
class BackendMovieTagRepository(
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
private val userId: Long,
private val movieEventHandler: RemoteEventHandler
) : MovieTagRepository {
private val dbQueries get() = db.tagsQueries
override fun query(movieId: Long): Flow<List<TagInfo>> = dbQueries
.getTagsForMovie(movieId, userId)
.asFlow()
.mapToList(dispatcherProvider.io)
.map { list ->
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
}
override fun queryMovies(tagId: Long): Flow<Set<Long>> = dbQueries
.getMoviesByTagId(tagId, userId)
.asFlow()
.mapToList(dispatcherProvider.io)
.map { list -> list.toSet() }
override suspend fun link(movieId: Long, tagId: Long) {
dbQueries.linkMovieTag(movieId, tagId, userId).await()
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
}
override suspend fun unlink(movieId: Long, tagId: Long) {
dbQueries.unlinkMovieTag(movieId, tagId, userId).await()
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
}
}
@@ -0,0 +1,72 @@
package ru.shadowsparky.vbox.backend.data.tags
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
@Factory
class UserTagRepositoryFactory(
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
@Named(EventType.USER_TAG) private val userTagEventHandler: RemoteEventHandler
) {
fun create(userId: Long): UserTagRepository {
return BackendUserTagRepository(
userId,
db,
dispatcherProvider,
userTagEventHandler
)
}
}
class BackendUserTagRepository(
private val userId: Long,
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
private val userTagEventHandler: RemoteEventHandler
) : UserTagRepository {
private val queries get() = db.tagsQueries
override val userTags: Flow<List<TagInfo>> by lazy {
queries.getUserTagsByUserId(user_id = userId)
.asFlow()
.mapToList(dispatcherProvider.io)
.map { list ->
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
}
}
override suspend fun add(tag: String) {
queries.insertUserTag(userId, tag).await()
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
}
override suspend fun edit(id: Long, newTag: String) {
checkOwner(id)
queries.updateTagText(newTag, id).await()
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
}
override suspend fun delete(id: Long) {
checkOwner(id)
queries.deleteTagById(id).await()
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
}
private suspend fun checkOwner(id: Long) = withContext(dispatcherProvider.io) {
val ownerId = queries.getUserIdByTagId(id).executeAsOneOrNull()
check(ownerId == userId) { "tag owner mismatch. owner: $ownerId, current user: $userId" }
}
}
@@ -0,0 +1,33 @@
package ru.shadowsparky.vbox.backend.di
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.asJdbcDriver
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.postgresql.ds.PGSimpleDataSource
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.vbox.backend.AppDatabase
@Module
class DbModule {
@Factory
fun provideSqlDriver(envFetcher: EnvFetcher): SqlDriver {
return PGSimpleDataSource().apply {
val db = envFetcher.get("POSTGRES_DB", "videobox")
val uri = envFetcher.get("VIDEOBOX_SERVER_URI", "localhost")
val login = envFetcher.get("POSTGRES_USER", "login")
val pass = envFetcher.get("POSTGRES_PASSWORD", "password")
setUrl("jdbc:postgresql://$uri:5432/$db")
user = login
password = pass
}.asJdbcDriver()
}
@Single
fun provideDb(driver: SqlDriver): AppDatabase {
return AppDatabase.Companion.invoke(driver)
}
}
@@ -0,0 +1,46 @@
package ru.shadowsparky.vbox.backend.di
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.TokenVerifier
import ru.shadowsparky.backend.domain.JwtInfo
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.VideoApi
@Single
class RoutingEntryPoint(
val videoApi: VideoApi,
val searchFactory: SearchRepositoryFactory,
val recentlyFactory: RecentlyWatchedRepositoryFactory,
val savedMovieFactory: SavedMovieRepositoryFactory,
val json: Json,
val movieTagFactory: MovieTagRepositoryFactory,
val userTagFactory: UserTagRepositoryFactory
)
@Single
class WebSocketEntryPoint(
@Named(EventType.SEARCH) val searchEventHandler: BackendRemoteEventHandler,
@Named(EventType.RECENT) val recentlyEventHandler: BackendRemoteEventHandler,
@Named(EventType.SAVED) val savedEventHandler: BackendRemoteEventHandler,
@Named(EventType.USER_TAG) val userTagEventHandler: BackendRemoteEventHandler,
@Named(EventType.MOVIE_TAG) val movieTagEventHandler: BackendRemoteEventHandler,
val tokenVerifier: TokenVerifier
)
@Single
class AuthEntryPoint(
val loginVerifier: LoginVerifier,
val authTokenRepositoryFactory: AuthTokenRepositoryFactory,
val jwtInfo: JwtInfo,
val tokenVerifier: TokenVerifier
)
@@ -0,0 +1,32 @@
package ru.shadowsparky.vbox.backend.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
@Module
class EventModule {
@Single(binds = [BackendRemoteEventHandler::class])
@Named(EventType.SEARCH)
fun provideSearch(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
@Single(binds = [BackendRemoteEventHandler::class])
@Named(EventType.RECENT)
fun provideRecent(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
@Single(binds = [BackendRemoteEventHandler::class])
@Named(EventType.SAVED)
fun provideSaved(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
@Single(binds = [BackendRemoteEventHandler::class])
@Named(EventType.USER_TAG)
fun provideUserTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
@Single(binds = [BackendRemoteEventHandler::class])
@Named(EventType.MOVIE_TAG)
fun provideMovieTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
}
@@ -0,0 +1,13 @@
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.shared.domain.VideoApi
@Module
class HttpModule {
@Factory
fun provideVideoApi(impl: ExternalBackendApi): VideoApi = impl
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.vbox.backend.di.factory
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.backend.data.BackendRecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
@Factory
class RecentlyWatchedRepositoryFactory(
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
@Named(EventType.RECENT) private val remoteEventHandler: RemoteEventHandler
) {
fun create(userId: Long): BackendRecentlyWatchedRepository {
return BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler)
}
}
@@ -0,0 +1,23 @@
package ru.shadowsparky.vbox.backend.di.factory
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.backend.data.Logger
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
import ru.shadowsparky.vbox.backend.data.BackendSavedMovieRepository
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
@Factory
class SavedMovieRepositoryFactory(
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
private val logger: Logger,
@Named(EventType.SAVED) private val eventHandler: BackendRemoteEventHandler
) {
fun create(userId: Long): SavedMovieRepository {
return BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler)
}
}
@@ -0,0 +1,21 @@
package ru.shadowsparky.vbox.backend.di.factory
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.backend.data.BackendSearchRepository
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.SearchRepository
@Factory
class SearchRepositoryFactory(
private val db: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
@Named(EventType.SEARCH) private val eventHandler: RemoteEventHandler
) {
fun create(userId: Long): SearchRepository {
return BackendSearchRepository(db, userId, dispatcherProvider, eventHandler)
}
}
@@ -0,0 +1,70 @@
package ru.shadowsparky.vbox.backend.presentation
import com.auth0.jwt.JWT
import com.auth0.jwt.interfaces.DecodedJWT
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.response.respond
import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG
import ru.shadowsparky.backend.domain.LOGIN_NAME
import ru.shadowsparky.backend.domain.VerifyTokenException
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
import ru.shadowsparky.vbox.shared.domain.USER_ID_ARG
import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo
const val AUTH_JWT_NAME = "auth-jwt"
fun Application.configureJwt(authEntryPoint: AuthEntryPoint) {
install(Authentication) {
jwt(AUTH_JWT_NAME) {
val jwtInfo = authEntryPoint.jwtInfo
realm = jwtInfo.realm
verifier(authEntryPoint.tokenVerifier.verifier)
validate { credential ->
val tokenRepo = authEntryPoint.loginVerifier
val login = credential.payload.getClaim(LOGIN_NAME).asString()
try {
if (login != null) {
tokenRepo.verify(login)
if (credential.payload.expiresAt == null) {
throw VerifyTokenException("Static tokens not supported!")
}
JWTPrincipal(credential.payload)
} else {
null
}
} catch (e: VerifyTokenException) {
routingLogger.error("verify token failed ${e.message}")
null
}
}
challenge { defaultScheme, realm ->
call.respond(
HttpStatusCode.Unauthorized,
ServerExceptionInfo(INVALID_TOKEN_MSG)
)
}
}
}
}
private fun String?.obtainToken(): DecodedJWT {
val token = this?.removePrefix("Bearer ")
?: throw VerifyTokenException(INVALID_TOKEN_MSG)
return JWT.decode(token)
}
fun ApplicationCall.obtainToken(): DecodedJWT {
return request.headers["Authorization"].obtainToken()
}
fun ApplicationCall.obtainUserId(): Long {
return obtainToken()
.getClaim(USER_ID_ARG)
.asLong()
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.vbox.backend.presentation
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond
import io.ktor.server.routing.routing
import org.slf4j.Logger
import org.slf4j.LoggerFactory
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")
fun Application.configureRouting(
routingEntryPoint: RoutingEntryPoint,
authEntryPoint: AuthEntryPoint
) {
install(StatusPages) {
exception<HttpException> { call, cause ->
routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause)
call.respond(
status = HttpStatusCode.fromValue(cause.httpCode),
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
)
}
exception<Throwable> { call, cause ->
routingLogger.error("error occurred. returns 500...", cause)
call.respond(
status = HttpStatusCode.InternalServerError,
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
)
}
}
routing {
setupBaseMethods(routingEntryPoint.videoApi)
setupAuthMethods(routingEntryPoint, authEntryPoint)
}
}
@@ -0,0 +1,12 @@
package ru.shadowsparky.vbox.backend.presentation
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
}
@@ -0,0 +1,67 @@
package ru.shadowsparky.vbox.backend.presentation
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.routing.Route
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CompletableDeferred
import ru.shadowsparky.backend.data.TokenVerifier
import ru.shadowsparky.backend.domain.USER_ID_ARG
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
import ru.shadowsparky.vbox.backend.data.SessionCache
import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import kotlin.time.Duration.Companion.seconds
fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) {
install(WebSockets) {
pingPeriod = (15).seconds
timeout = (15).seconds
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
hashMapOf(
EventType.SEARCH to searchEventHandler,
EventType.RECENT to recentlyEventHandler,
EventType.SAVED to savedEventHandler,
EventType.USER_TAG to userTagEventHandler,
EventType.MOVIE_TAG to movieTagEventHandler
).forEach { (key, handler) -> webSocket(key, handler, tokenVerifier) }
}
}
private fun Route.webSocket(
eventType: String,
handler: BackendRemoteEventHandler,
tokenVerifier: TokenVerifier
) {
val path = when (eventType) {
EventType.SEARCH -> RemoteEventHandler.SEARCH_CHANGED
EventType.RECENT -> RemoteEventHandler.RECENTLY_CHANGED
EventType.SAVED -> RemoteEventHandler.SAVED_CHANGED
EventType.USER_TAG -> RemoteEventHandler.USER_TAG_CHANGED
EventType.MOVIE_TAG -> RemoteEventHandler.MOVIE_TAG_CHANGED
else -> error("Unsupported type $eventType")
}
webSocket(path) {
val frame = (incoming.receive() as Frame.Text).readText()
val userId = tokenVerifier.verify(frame).getClaim(USER_ID_ARG).asLong()
val session = SessionCache.Writer { text -> outgoing.trySend(Frame.Text(text)) }
handler.put(userId, session)
val deferred = CompletableDeferred<Unit?>()
try {
outgoing.invokeOnClose { deferred.complete(null) }
deferred.await()
} finally {
handler.remove(userId, session)
}
}
}
@@ -0,0 +1,50 @@
package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint
import ru.shadowsparky.vbox.backend.presentation.AUTH_JWT_NAME
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.HeathCheck
fun Routing.setupAuthMethods(
routingEntryPoint: RoutingEntryPoint,
authEntryPoint: AuthEntryPoint
) = with(routingEntryPoint) {
val default = authEntryPoint.authTokenRepositoryFactory.create()
post(AuthTokenRepository.REGISTER_PATH) {
call.respond(default.register(call.receive()))
}
post(AuthTokenRepository.LOGIN_PATH) {
call.respond(default.login(call.receive()))
}
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("/chech") { call.respond(HttpStatusCode.OK) }
get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) }
post(AuthTokenRepository.CHANGE_PASS_PATH) {
authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId())
.changePassword(call.receive())
call.respond(HttpStatusCode.OK)
}
setupSearch(searchFactory)
setupRecentlyWatched(recentlyFactory)
setupSavedMovie(savedMovieFactory)
setupTagsRouting(userTagFactory, movieTagFactory)
}
}
@@ -0,0 +1,32 @@
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.get
import io.ktor.server.util.getOrFail
import ru.shadowsparky.vbox.shared.domain.VideoApi
fun Routing.setupBaseMethods(api: VideoApi) {
get(VideoApi.VIDEOS_PATH) {
val p = call.parameters
val obj = api.fetchNewVideos(p[VideoApi.SEARCH_ARG])
call.respond(obj)
}
get(VideoApi.DETAILS_PATH) {
val p = call.parameters
call.respond(api.fetchDetails(p.getOrFail(VideoApi.ID_ARG).toLong()))
}
get(VideoApi.LINKS_PATH) {
val p = call.parameters
call.respond(
api.fetchVideoLinks(
p.getOrFail(VideoApi.ID_ARG).toLong(),
p[VideoApi.SEASON_ID_ARG]?.toLong()
)
)
}
get("/") {
call.respond(HttpStatusCode.NotFound)
}
}
@@ -0,0 +1,48 @@
package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import kotlinx.coroutines.flow.first
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse
fun Route.setupRecentlyWatched(recentlyFactory: RecentlyWatchedRepositoryFactory) {
get(RecentlyWatchedRepository.GET_INFO) {
val p = call.parameters
val recentlyWatchedId = p[RecentlyWatchedRepository.MOVIE_ID_ARG]?.toLong()
val seasonId = p[RecentlyWatchedRepository.SEASON_ID_ARG]?.toLong()
val repo = recentlyFactory.create(call.obtainUserId())
val list = if (recentlyWatchedId != null) {
repo.queryRecentlyWatched(recentlyWatchedId, seasonId).first()
} else {
repo.getAllRecentlyWatched()
}
call.respond(RecentlyWatchedResponse(list))
}
post(RecentlyWatchedRepository.WRITE) {
val info = call.receive<RecentlyWatchedRequest>()
recentlyFactory.create(call.obtainUserId()).writeAll(info.info)
call.respond(HttpStatusCode.OK)
}
post(RecentlyWatchedRepository.REMOVE) {
val repo = recentlyFactory.create(call.obtainUserId())
call.receive<RecentlyWatchedRequest>().info.forEach {
repo.remove(it)
}
call.respond(HttpStatusCode.OK)
}
post(RecentlyWatchedRepository.TOGGLE) {
val repo = recentlyFactory.create(call.obtainUserId())
call.receive<RecentlyWatchedRequest>().info.forEach {
repo.toggleInfo(it)
}
call.respond(HttpStatusCode.OK)
}
}
@@ -0,0 +1,43 @@
package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.util.getOrFail
import kotlinx.coroutines.flow.first
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.IsSavedResponse
import ru.shadowsparky.vbox.shared.domain.RemoveRequest
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.SavedMoviesResponse
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
fun Route.setupSavedMovie(savedMovieFactory: SavedMovieRepositoryFactory) {
post(SavedMovieRepository.REMOVE) {
val saveRequest = call.receive<RemoveRequest>()
savedMovieFactory.create(call.obtainUserId()).remove(saveRequest.movieId)
call.respond(HttpStatusCode.OK)
}
post(SavedMovieRepository.WRITE) {
val saveRequest = call.receive<VideoDetails>()
savedMovieFactory.create(call.obtainUserId()).save(saveRequest)
call.respond(HttpStatusCode.OK)
}
get(SavedMovieRepository.QUERY_SAVED) {
val p = call.parameters
val saved = savedMovieFactory.create(call.obtainUserId())
.isSaved(p.getOrFail(SavedMovieRepository.MOVIE_ID_ARG).toLong())
.first()
call.respond(IsSavedResponse(saved))
}
get(SavedMovieRepository.QUERY_ALL) {
val saved = savedMovieFactory.create(call.obtainUserId())
.getAll()
.first()
call.respond(SavedMoviesResponse(saved))
}
}
@@ -0,0 +1,42 @@
package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import kotlinx.coroutines.flow.first
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest
import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest
import ru.shadowsparky.vbox.shared.domain.SearchQueryResponse
import ru.shadowsparky.vbox.shared.domain.SearchRepository
fun Route.setupSearch(searchFactory: SearchRepositoryFactory) {
get(SearchRepository.SEARCH) {
val p = call.parameters
val userId = call.obtainUserId()
val search = searchFactory.create(userId)
call.respond(SearchQueryResponse(search.search(p[SearchRepository.QUERY_ARG] ?: "").first()))
}
post(SearchRepository.ADD_TO_SEARCH) {
val userId = call.obtainUserId()
val request = call.receive<SearchQueryRequest>()
searchFactory.create(userId).addToSearch(request.query)
call.respond(HttpStatusCode.OK)
}
delete(SearchRepository.CLEAR) {
val userId = call.obtainUserId()
val request = call.receive<SearchDeleteRequest>()
val repo = searchFactory.create(userId)
if (request.query == null) {
repo.clear()
} else {
repo.deleteFromSearch(request.query!!)
}
call.respond(HttpStatusCode.OK)
}
}
@@ -0,0 +1,79 @@
package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import kotlinx.coroutines.flow.first
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.tag.AddRequest
import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest
import ru.shadowsparky.vbox.shared.domain.tag.EditRequest
import ru.shadowsparky.vbox.shared.domain.tag.LinkRequest
import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
fun Route.setupTagsRouting(
userTagFactory: UserTagRepositoryFactory,
movieTagFactory: MovieTagRepositoryFactory
) {
setupUserTag(userTagFactory)
setupMovieTag(movieTagFactory)
}
private fun Route.setupUserTag(userTagFactory: UserTagRepositoryFactory) {
get(UserTagRepository.LIST) {
val userTag = userTagFactory.create(call.obtainUserId())
call.respond(TagResponse(userTag.userTags.first()))
}
post(UserTagRepository.ADD) {
val userTag = userTagFactory.create(call.obtainUserId())
userTag.add(call.receive<AddRequest>().tag)
call.respond(HttpStatusCode.OK)
}
post(UserTagRepository.EDIT) {
val userTag = userTagFactory.create(call.obtainUserId())
val edit = call.receive<EditRequest>()
userTag.edit(edit.id, edit.newTag)
call.respond(HttpStatusCode.OK)
}
delete(UserTagRepository.DELETE) {
val userTag = userTagFactory.create(call.obtainUserId())
userTag.delete(call.receive<DeleteRequest>().id)
call.respond(HttpStatusCode.OK)
}
}
private fun Route.setupMovieTag(movieTagFactory: MovieTagRepositoryFactory) {
get(MovieTagRepository.QUERY) {
val movieTag = movieTagFactory.create(call.obtainUserId())
val movieId = call.parameters[MovieTagRepository.MOVIE_ID]?.toLong()
val tags = movieTag.query(checkNotNull(movieId) { "movieId required" }).first()
call.respond(TagResponse(tags))
}
get(MovieTagRepository.QUERY_MOVIES) {
val movieTag = movieTagFactory.create(call.obtainUserId())
val tagId = call.parameters[MovieTagRepository.TAG_ID]?.toLong()
val ids = movieTag.queryMovies(checkNotNull(tagId) { "tagId required" }).first()
call.respond(LinkedMovies(ids))
}
post(MovieTagRepository.LINK) {
val movieTag = movieTagFactory.create(call.obtainUserId())
val link = call.receive<LinkRequest>()
movieTag.link(link.movieId, link.tagId)
call.respond(HttpStatusCode.OK)
}
delete(MovieTagRepository.UNLINK) {
val movieTag = movieTagFactory.create(call.obtainUserId())
val link = call.receive<LinkRequest>()
movieTag.unlink(link.movieId, link.tagId)
call.respond(HttpStatusCode.OK)
}
}
@@ -0,0 +1,8 @@
ktor {
deployment {
port = 8181
}
application {
modules = [ ru.shadowsparky.vbox.backend.MainKt.module ]
}
}
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
<logger name="org.eclipse.jetty" level="INFO" />
<logger name="io.netty" level="INFO" />
</configuration>
@@ -0,0 +1,80 @@
-- Создание таблицы movie
CREATE TABLE movie (
movie_id BIGSERIAL PRIMARY KEY,
poster_url VARCHAR(256) NOT NULL,
description TEXT NOT NULL,
title TEXT NOT NULL,
flags INT NOT NULL
);
-- Создание таблицы user
CREATE TABLE "user" (
user_id BIGSERIAL PRIMARY KEY,
login VARCHAR(256) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at BIGINT NOT NULL,
flags INT DEFAULT NULL
);
-- Создание таблицы recently_watched
CREATE TABLE recently_watched (
recently_watched_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
movie_id BIGINT NOT NULL,
episode BIGINT DEFAULT NULL,
season BIGINT DEFAULT NULL,
UNIQUE (user_id, movie_id),
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- Создание таблицы saved_movie
CREATE TABLE saved_movie (
saved_movie_id BIGSERIAL PRIMARY KEY,
movie_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
UNIQUE (movie_id, user_id),
FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- Создание таблицы search_info
CREATE TABLE search_info (
search_id BIGSERIAL PRIMARY KEY,
query VARCHAR(256) DEFAULT NULL,
user_id BIGINT DEFAULT NULL,
UNIQUE (query, user_id),
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE refresh_tokens (
token_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at BIGINT NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE user_tag (
id bigserial NOT NULL,
user_id bigint NOT NULL,
tag varchar NOT NULL,
CONSTRAINT user_tag_pk PRIMARY KEY (id),
CONSTRAINT user_tag_user_fk FOREIGN KEY (user_id) REFERENCES "user" (user_id)
);
CREATE UNIQUE INDEX user_tag_tag_idx ON user_tag (tag,user_id);
CREATE TABLE movie_tag (
id bigserial NOT NULL,
movie_id bigint NOT NULL,
tag_id bigint NOT NULL,
CONSTRAINT movie_tag_pk PRIMARY KEY (id),
CONSTRAINT movie_tag_movie_fk FOREIGN KEY (movie_id) REFERENCES movie(movie_id),
CONSTRAINT movie_tag_user_tag_fk FOREIGN KEY (tag_id) REFERENCES user_tag(id)
);
@@ -0,0 +1,10 @@
insertMovie:
INSERT INTO "movie" (movie_id, poster_url, description, title, flags) VALUES (?, ?, ?, ?, ?);
selectMovie:
SELECT * FROM "movie" WHERE movie_id = ?;
updateMovie:
UPDATE "movie"
SET poster_url = ?, description = ?, title = ?, flags = ?
WHERE movie_id = ?;
@@ -0,0 +1,23 @@
selectAll:
SELECT * FROM "recently_watched" WHERE user_id = ?;
selectByMovieId:
SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season IS NOT NULL AND episode IS NOT NULL;
selectByMovieIdAndSeason:
SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season = ? AND episode IS NOT NULL;
selectByIds:
SELECT * FROM "recently_watched" WHERE movie_id = ? AND episode = ? AND season = ? AND user_id = ?;
insertRecentlyWatched:
INSERT INTO "recently_watched" (user_id, movie_id, episode, season) VALUES (?, ?, ?, ?);
deleteRwByIds:
DELETE FROM "recently_watched" WHERE (movie_id = ? AND episode = ? AND season = ? AND user_id = ?);
deleteRecentlyWatchedById:
DELETE FROM "recently_watched" WHERE recently_watched_id = ? AND user_id = ?;
deleteRecentlyWatchedByMovieId:
DELETE FROM "recently_watched" WHERE movie_id = ? AND user_id = ?;
@@ -0,0 +1,19 @@
selectByHash:
SELECT *
FROM refresh_tokens
WHERE token_hash = ?;
insertToken:
INSERT INTO refresh_tokens(user_id, token_hash, expires_at, revoked)
VALUES (?, ?, ?, FALSE)
RETURNING token_id;
revokeToken:
UPDATE refresh_tokens
SET revoked = TRUE
WHERE token_id = ?;
revokeAllForUser:
UPDATE refresh_tokens
SET revoked = TRUE
WHERE user_id = ?;
@@ -0,0 +1,13 @@
-- videobox."saved_movie" definition
selectByUserId:
SELECT * FROM "saved_movie" WHERE user_id = ? ORDER BY saved_movie_id;
selectByUserIdAndMovieId:
SELECT * FROM "saved_movie" WHERE user_id = ? AND movie_id = ?;
addSavedMovie:
INSERT INTO "saved_movie" (movie_id, user_id) VALUES (?, ?);
removeSavedMovie:
DELETE FROM "saved_movie" WHERE user_id = ? AND movie_id = ?;
@@ -0,0 +1,14 @@
selectAll:
SELECT * FROM "search_info" WHERE user_id = ? ORDER BY search_id;
selectByQuery:
SELECT * FROM "search_info" WHERE query = ? AND user_id = ? ORDER BY search_id;
insertSearchInfo:
INSERT INTO "search_info" (query, user_id) VALUES (?, ?);
deleteSearchInfo:
DELETE FROM "search_info" WHERE query = ? AND user_id = ?;
deleteAll:
DELETE FROM "search_info" WHERE user_id = ?;
@@ -0,0 +1,47 @@
getUserTagsByUserId:
SELECT id, user_id, tag
FROM user_tag
WHERE user_id = ?;
getUserIdByTagId:
SELECT user_id
FROM user_tag
WHERE id = ?;
insertUserTag:
INSERT INTO user_tag (user_id, tag)
VALUES (?, ?);
updateTagText:
UPDATE user_tag
SET tag = ?
WHERE id = ?;
deleteTagById:
DELETE FROM user_tag
WHERE id = ?;
getTagsForMovie:
SELECT ut.id, ut.tag
FROM movie_tag mt
JOIN user_tag ut ON mt.tag_id = ut.id
WHERE mt.movie_id = ? AND ut.user_id = ?;
getMoviesByTagId:
SELECT mt.movie_id
FROM movie_tag mt
JOIN user_tag ut ON mt.tag_id = ut.id
WHERE mt.tag_id = ? AND ut.user_id = ?;
linkMovieTag:
INSERT INTO movie_tag (movie_id, tag_id)
SELECT ?, ut.id
FROM user_tag ut
WHERE ut.id = ? AND ut.user_id = ?
ON CONFLICT DO NOTHING;
unlinkMovieTag:
DELETE FROM movie_tag
WHERE movie_id = ?
AND tag_id = ?
AND tag_id IN (SELECT id FROM user_tag WHERE user_id = ?);
@@ -0,0 +1,16 @@
selectUserByLogin:
SELECT * FROM "user" WHERE login = ? LIMIT 1;
selectUserByUserId:
SELECT * FROM "user" WHERE user_id = ? LIMIT 1;
addUser:
INSERT INTO "user" (login, password_hash, created_at, flags) VALUES (?, ?, ?, ?);
removeUser:
DELETE FROM "user" WHERE login = ?;
updatePassword:
UPDATE "user"
SET password_hash = ?
WHERE user_id = ?;
+4
View File
@@ -0,0 +1,4 @@
keystore.properties
release.keystore
*.keystore
build
@@ -0,0 +1,37 @@
plugins {
alias(libs.plugins.convention.android.app)
alias(libs.plugins.convention.compose)
alias(libs.plugins.convention.koin)
alias(libs.plugins.convention.ktor.client)
}
android {
buildFeatures { buildConfig = true }
namespace = "ru.shadowsparky.videobox.multiplatform"
defaultConfig {
applicationId = namespace
minSdk = libs.versions.proj.min.get().toInt()
targetSdk = libs.versions.proj.target.get().toInt()
versionCode = 35
versionName = "1.18"
}
}
dependencies {
implementation(libs.activity.compose)
implementation(libs.material)
implementation(project(":apps:vbox:client:shared"))
implementation(project(":apps:vbox:common"))
implementation(project(":libs:base"))
implementation(project(":libs:ui"))
implementation(project(":libs:http-client"))
implementation(project(":libs:video-player"))
debugImplementation(libs.ui.tooling)
compileOnly(libs.ui.tooling.preview.android)
testImplementation(libs.koin.test)
testImplementation(libs.mockk)
testImplementation(kotlin("test-junit"))
androidTestImplementation(libs.junit)
androidTestImplementation(libs.runner)
}
+41
View File
@@ -0,0 +1,41 @@
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn java.lang.management.ManagementFactory
-dontwarn java.lang.management.RuntimeMXBean
-dontwarn kotlinx.atomicfu.**
-dontwarn io.netty.**
-dontwarn com.typesafe.**
-dontwarn org.slf4j.**
-dontwarn com.sun.nio.file.SensitivityWatchEventModifier
-dontwarn org.xmlpull.v1.**
-dontwarn javax.xml.namespace.**
-dontwarn androidx.test.platform.app.InstrumentationRegistry
-dontwarn androidx.window.extensions.WindowExtensions
-dontwarn androidx.window.extensions.WindowExtensionsProvider
-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation
-dontwarn androidx.window.extensions.core.util.function.Consumer
-dontwarn androidx.window.extensions.core.util.function.Function
-dontwarn androidx.window.extensions.core.util.function.Predicate
-dontwarn androidx.window.extensions.layout.DisplayFeature
-dontwarn androidx.window.extensions.layout.FoldingFeature
-dontwarn androidx.window.extensions.layout.WindowLayoutComponent
-dontwarn androidx.window.extensions.layout.WindowLayoutInfo
-dontwarn androidx.window.sidecar.SidecarDeviceState
-dontwarn androidx.window.sidecar.SidecarDisplayFeature
-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback
-dontwarn androidx.window.sidecar.SidecarInterface
-dontwarn androidx.window.sidecar.SidecarProvider
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent { *; }
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSaved { *; }
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnRecent { *; }
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSearch { *; }
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Videobox"
tools:ignore="UnusedAttribute">
<activity
android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|orientation|smallestScreenSize"
android:exported="true"
android:supportsPictureInPicture="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="box.shadowsparky.ru" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,34 @@
package ru.shadowsparky.videobox.multiplatform
import android.app.Application
import android.os.StrictMode
import com.google.android.material.color.DynamicColors
import org.koin.android.ext.koin.androidContext
import org.koin.core.annotation.KoinApplication
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.koin.plugin.module.dsl.startKoin
import ru.shadowsparky.domain.AppConfig
@KoinApplication
object VBox
class App : Application() {
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)
startKoin<VBox> { androidContext(this@App) }
if (BuildConfig.DEBUG) StrictMode.enableDefaults()
}
}
@Module
class AppModule {
@Single
fun provideAppConfig(): AppConfig {
return object : AppConfig {
override fun isDebug(): Boolean = BuildConfig.DEBUG
}
}
}
@@ -0,0 +1,101 @@
package ru.shadowsparky.videobox.multiplatform
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.lifecycle.lifecycleScope
import com.arkivanov.decompose.retainedComponent
import kotlinx.coroutines.launch
import ru.shadowsparky.koin
import ru.shadowsparky.vbox.shared.presentation.Nav
import ru.shadowsparky.vbox.shared.presentation.RootComponent
import ru.shadowsparky.vbox.shared.presentation.RootFactory
import ru.shadowsparky.vbox.shared.presentation.UrlRoutes
import ru.shadowsparky.vbox.shared.presentation.nav.Route
class MainActivity : ComponentActivity() {
private lateinit var root: RootComponent
override fun onCreate(savedInstanceState: Bundle?) {
edgeToEdge(false)
super.onCreate(savedInstanceState)
root = retainedComponent { componentContext ->
val routeFromIntent = obtainRouteOrNull(intent?.data)
val route = if (routeFromIntent == null) {
listOf(Route.default)
} else {
listOf(Route.default, routeFromIntent)
}
koin.get<RootFactory>().create(componentContext, route)
}
if (openSavedIfNeeded(intent) || openUrlIfNeeded(intent.data)) {
intent = Intent(intent)
.setData(null)
.setAction(null)
}
setContent {
Nav(rootComponent = root)
edgeToEdge(isSystemInDarkTheme())
}
lifecycleScope.launch {
root.onFinish.collect {
finishAndRemoveTask()
}
}
}
private fun edgeToEdge(isDarkTheme: Boolean) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
Color.TRANSPARENT,
Color.TRANSPARENT,
{ isDarkTheme }
),
navigationBarStyle = SystemBarStyle.light(
Color.TRANSPARENT,
Color.TRANSPARENT
)
)
}
private fun openSavedIfNeeded(intent: Intent?): Boolean {
if (intent?.action == "ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES") {
root.nav(Route.Bookmark(), true)
return true
}
return false
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (!openSavedIfNeeded(intent)) {
val uri = intent.data ?: return
val route = obtainRouteOrNull(uri)
if (route != null) {
root.nav(route, true)
} else {
openUrlIfNeeded(uri)
}
}
}
private fun openUrlIfNeeded(uri: Uri?): Boolean {
uri ?: return false
if (uri.path?.startsWith("/video/") == true) {
root.nav(Route.Overview.Player(url = uri.toString()))
return true
}
return false
}
private fun obtainRouteOrNull(uri: Uri?): Route? {
uri ?: return null
return UrlRoutes.parseUrl(uri.toString(), basePath = "/")
}
}
@@ -0,0 +1,12 @@
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")
}
@@ -0,0 +1,64 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package ru.shadowsparky.videobox.multiplatform.preview
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch
import ru.shadowsparky.vbox.shared.presentation.search.OldSearchInfo
import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar
@Preview
@Composable
fun AppBarWithSearchPreview() {
AppBarWithSearch(
title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
isDefault = false,
onBack = {}
)
}
@Preview
@Composable
fun AppBarWithSearchWithBackPreview() {
AppBarWithSearch(
title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
onBack = {}
)
}
@SuppressLint("RememberInComposition")
@Preview
@Composable
fun InternalAppBarPreview() {
SearchAppBar(
TextFieldValue(""),
FocusRequester(),
{},
{}
)
}
@Preview(showBackground = true)
@Composable
fun SearchInfoPreview() {
OldSearchInfo(
modifier = Modifier,
info = listOf(
"Почему дора дура?",
"клиинка",
"симпсоны",
"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
),
PaddingValues(0.dp),
onClick = {},
onDeleteRequested = {}
)
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.videobox.multiplatform.preview
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import ru.shadowsparky.vbox.shared.domain.model.Season
import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonsInfo
@Preview
@Composable
fun SeasonsInfoPreview() {
SeasonsInfo(
paddingValues = PaddingValues(0.dp),
seasons = listOf(Season(listOf(), 23, "Lalalal"))
) {
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.videobox.multiplatform.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import ru.shadowsparky.vbox.shared.presentation.list.ErrorAndRetry
import ru.shadowsparky.vbox.shared.presentation.list.Loading
@Preview
@Composable
fun ErrorPreview() {
ErrorAndRetry(
errorMsg = "Неизвестная ошибка"
) {}
}
@Preview
@Composable
fun LoadingPreview() {
Loading()
}
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14zM7.33,12.5l2.14,-1.53 2.14,1.53 -0.83,-2.46 2.15,-1.5h-2.62L9.47,6l-0.84,2.54L6,8.54l2.14,1.49z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
</vector>
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="0.38866666"
android:scaleY="0.38866666"
android:translateX="7.336"
android:translateY="7.336">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
</group>
</vector>
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="0.38866666"
android:scaleY="0.38866666"
android:translateX="7.336"
android:translateY="7.336">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
</group>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,6.5c3.79,0 7.17,2.13 8.82,5.5 -1.65,3.37 -5.02,5.5 -8.82,5.5S4.83,15.37 3.18,12C4.83,8.63 8.21,6.5 12,6.5m0,-2C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,9.5c1.38,0 2.5,1.12 2.5,2.5s-1.12,2.5 -2.5,2.5 -2.5,-1.12 -2.5,-2.5 1.12,-2.5 2.5,-2.5m0,-2c-2.48,0 -4.5,2.02 -4.5,4.5s2.02,4.5 4.5,4.5 4.5,-2.02 4.5,-4.5 -2.02,-4.5 -4.5,-4.5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_fragment_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".presentation.MainActivity">
</androidx.fragment.app.FragmentContainerView>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_banner_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Videobox</string>
<string name="saved_movies">Сохранённые фильмы</string>
<string name="saved_movies_long">Просмотр списка ранее сохранённых фильмов</string>
</resources>
@@ -0,0 +1,6 @@
<resources>
<style name="BaseTheme" parent="@style/Theme.Material3.DynamicColors.DayNight.NoActionBar" />
<style name="Theme.Videobox" parent="BaseTheme" />
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:icon="@drawable/baseline_saved_search_24"
android:shortcutId="saved_movies"
android:shortcutLongLabel="@string/saved_movies_long"
android:shortcutShortLabel="@string/saved_movies">
<intent
android:action="ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES"
android:targetClass="ru.shadowsparky.videobox.multiplatform.MainActivity"
android:targetPackage="ru.shadowsparky.videobox.multiplatform" />
</shortcut>
</shortcuts>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts></shortcuts>
+1
View File
@@ -0,0 +1 @@
build
+51
View File
@@ -0,0 +1,51 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.ktor.client)
alias(libs.plugins.convention.compose)
alias(libs.plugins.convention.serialization)
alias(libs.plugins.convention.sqldelight)
alias(libs.plugins.convention.koin)
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("ru.shadowsparky.vbox.app")
generateAsync.set(true)
}
}
}
kotlin {
sourceSets {
commonMain.dependencies {
api(project(":apps:vbox:common"))
implementation(libs.coil.compose)
implementation(project(":libs:base"))
implementation(project(":libs:ui"))
implementation(project(":libs:http-client"))
implementation(project(":libs:video-player"))
}
androidMain.dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.sqlite.framework)
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val wasmJsMain by getting {
dependencies {
implementation(libs.sqldelight.web.driver)
implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.1.0"))
implementation(npm("sql.js", "1.10.3")) // или актуальную 1.x
implementation(devNpm("copy-webpack-plugin", "11.0.0"))
}
}
}
android {
namespace = "ru.shadowsparky.vbox.shared"
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
@@ -0,0 +1,388 @@
package app.cash.sqldelight.driver.android
import android.content.Context
import android.database.AbstractWindowedCursor
import android.database.Cursor
import android.database.CursorWindow
import android.os.Build
import android.util.LruCache
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import androidx.sqlite.db.SupportSQLiteProgram
import androidx.sqlite.db.SupportSQLiteQuery
import androidx.sqlite.db.SupportSQLiteStatement
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import app.cash.sqldelight.Query
import app.cash.sqldelight.Transacter
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlCursor
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlPreparedStatement
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.Api28Impl.setWindowSize
import kotlinx.coroutines.runBlocking
private const val DEFAULT_CACHE_SIZE = 20
// workaround
// https://github.com/cashapp/sqldelight/issues/5058
class AndroidSqliteDriver private constructor(
private val openHelper: SupportSQLiteOpenHelper? = null,
database: SupportSQLiteDatabase? = null,
private val cacheSize: Int,
private val windowSizeBytes: Long? = null,
) : SqlDriver {
init {
require((openHelper != null) xor (database != null))
}
private val transactions = ThreadLocal<Transacter.Transaction>()
private val database by lazy {
openHelper?.writableDatabase ?: database!!
}
constructor(
openHelper: SupportSQLiteOpenHelper,
) : this(
openHelper = openHelper,
database = null,
cacheSize = DEFAULT_CACHE_SIZE,
windowSizeBytes = null
)
/**
* @param [cacheSize] The number of compiled sqlite statements to keep in memory per connection.
* Defaults to 20.
* @param [useNoBackupDirectory] Sets whether to use a no backup directory or not.
* @param [windowSizeBytes] Size of cursor window in bytes, per [CursorWindow] (Android 28+ only), or null to use the default.
*/
@JvmOverloads
constructor(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
context: Context,
name: String? = null,
factory: SupportSQLiteOpenHelper.Factory = FrameworkSQLiteOpenHelperFactory(),
callback: SupportSQLiteOpenHelper.Callback = Callback(schema),
cacheSize: Int = DEFAULT_CACHE_SIZE,
useNoBackupDirectory: Boolean = false,
windowSizeBytes: Long? = null,
) : this(
database = null,
openHelper = factory.create(
SupportSQLiteOpenHelper.Configuration.builder(context)
.callback(callback)
.name(name)
.noBackupDirectory(useNoBackupDirectory)
.build(),
),
cacheSize = cacheSize,
windowSizeBytes = windowSizeBytes,
)
@JvmOverloads
constructor(
database: SupportSQLiteDatabase,
cacheSize: Int = DEFAULT_CACHE_SIZE,
windowSizeBytes: Long? = null,
) : this(
openHelper = null,
database = database,
cacheSize = cacheSize,
windowSizeBytes = windowSizeBytes
)
private val statements = object : LruCache<Int, AndroidStatement>(cacheSize) {
override fun entryRemoved(
evicted: Boolean,
key: Int,
oldValue: AndroidStatement,
newValue: AndroidStatement?,
) {
if (evicted) oldValue.close()
}
}
private val listeners = linkedMapOf<String, MutableSet<Query.Listener>>()
override fun addListener(vararg queryKeys: String, listener: Query.Listener) {
synchronized(listeners) {
queryKeys.forEach {
listeners.getOrPut(it, { linkedSetOf() }).add(listener)
}
}
}
override fun removeListener(vararg queryKeys: String, listener: Query.Listener) {
synchronized(listeners) {
queryKeys.forEach {
listeners[it]?.remove(listener)
}
}
}
override fun notifyListeners(vararg queryKeys: String) {
val listenersToNotify = linkedSetOf<Query.Listener>()
synchronized(listeners) {
queryKeys.forEach { listeners[it]?.let(listenersToNotify::addAll) }
}
listenersToNotify.forEach(Query.Listener::queryResultsChanged)
}
override fun newTransaction(): QueryResult<Transacter.Transaction> {
val enclosing = transactions.get()
val transaction = Transaction(enclosing)
transactions.set(transaction)
if (enclosing == null) {
database.beginTransactionNonExclusive()
}
return QueryResult.Value(transaction)
}
override fun currentTransaction(): Transacter.Transaction? = transactions.get()
inner class Transaction(
override val enclosingTransaction: Transacter.Transaction?,
) : Transacter.Transaction() {
override fun endTransaction(successful: Boolean): QueryResult<Unit> {
if (enclosingTransaction == null) {
if (successful) {
database.setTransactionSuccessful()
database.endTransaction()
} else {
database.endTransaction()
}
}
transactions.set(enclosingTransaction)
return QueryResult.Unit
}
}
private fun <T> execute(
identifier: Int?,
createStatement: () -> AndroidStatement,
binders: (SqlPreparedStatement.() -> Unit)?,
result: AndroidStatement.() -> T,
): QueryResult.Value<T> {
var statement: AndroidStatement? = null
if (identifier != null) {
statement = statements.remove(identifier)
}
if (statement == null) {
statement = createStatement()
}
try {
if (binders != null) {
statement.binders()
}
return QueryResult.Value(statement.result())
} finally {
if (identifier != null) {
statements.put(identifier, statement)?.close()
} else {
statement.close()
}
}
}
override fun execute(
identifier: Int?,
sql: String,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
): QueryResult<Long> = execute(
identifier,
{ AndroidPreparedStatement(database.compileStatement(sql)) },
binders,
{ execute() })
override fun <R> executeQuery(
identifier: Int?,
sql: String,
mapper: (SqlCursor) -> QueryResult<R>,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
) = execute(
identifier,
{ AndroidQuery(sql, database, parameters, windowSizeBytes) },
binders
) { executeQuery(mapper) }
override fun close() {
statements.evictAll()
return openHelper?.close() ?: database.close()
}
open class Callback(
private val schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
private vararg val callbacks: AfterVersion,
) : SupportSQLiteOpenHelper.Callback(
if (schema.version > Int.MAX_VALUE) error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") else schema.version.toInt(),
) {
override fun onCreate(db: SupportSQLiteDatabase) {
runBlocking {
schema.create(AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1))
.await()
}
}
override fun onUpgrade(
db: SupportSQLiteDatabase,
oldVersion: Int,
newVersion: Int,
) {
runBlocking {
schema.migrate(
AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1),
oldVersion.toLong(),
newVersion.toLong(),
*callbacks,
).await()
}
}
}
}
internal interface AndroidStatement : SqlPreparedStatement {
fun execute(): Long
fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R
fun close()
}
private class AndroidPreparedStatement(
private val statement: SupportSQLiteStatement,
) : AndroidStatement {
override fun bindBytes(index: Int, bytes: ByteArray?) {
if (bytes == null) statement.bindNull(index + 1) else statement.bindBlob(index + 1, bytes)
}
override fun bindLong(index: Int, long: Long?) {
if (long == null) statement.bindNull(index + 1) else statement.bindLong(index + 1, long)
}
override fun bindDouble(index: Int, double: Double?) {
if (double == null) statement.bindNull(index + 1) else statement.bindDouble(
index + 1,
double
)
}
override fun bindString(index: Int, string: String?) {
if (string == null) statement.bindNull(index + 1) else statement.bindString(
index + 1,
string
)
}
override fun bindBoolean(index: Int, boolean: Boolean?) {
if (boolean == null) {
statement.bindNull(index + 1)
} else {
statement.bindLong(index + 1, if (boolean) 1L else 0L)
}
}
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R =
throw UnsupportedOperationException()
override fun execute(): Long {
return statement.executeUpdateDelete().toLong()
}
override fun close() {
statement.close()
}
}
private class AndroidQuery(
override val sql: String,
private val database: SupportSQLiteDatabase,
override val argCount: Int,
private val windowSizeBytes: Long?,
) : SupportSQLiteQuery, AndroidStatement {
private val binds = MutableList<((SupportSQLiteProgram) -> Unit)?>(argCount) { null }
override fun bindBytes(index: Int, bytes: ByteArray?) {
binds[index] =
{ if (bytes == null) it.bindNull(index + 1) else it.bindBlob(index + 1, bytes) }
}
override fun bindLong(index: Int, long: Long?) {
binds[index] =
{ if (long == null) it.bindNull(index + 1) else it.bindLong(index + 1, long) }
}
override fun bindDouble(index: Int, double: Double?) {
binds[index] =
{ if (double == null) it.bindNull(index + 1) else it.bindDouble(index + 1, double) }
}
override fun bindString(index: Int, string: String?) {
binds[index] =
{ if (string == null) it.bindNull(index + 1) else it.bindString(index + 1, string) }
}
override fun bindBoolean(index: Int, boolean: Boolean?) {
binds[index] = {
if (boolean == null) {
it.bindNull(index + 1)
} else {
it.bindLong(index + 1, if (boolean) 1L else 0L)
}
}
}
override fun execute() = throw UnsupportedOperationException()
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R {
return database.query(this)
.use { cursor -> mapper(AndroidCursor(cursor, windowSizeBytes)).value }
}
override fun bindTo(statement: SupportSQLiteProgram) {
for (action in binds) {
action!!(statement)
}
}
override fun toString() = sql
override fun close() {}
}
private class AndroidCursor(
private val cursor: Cursor,
windowSizeBytes: Long?,
) : SqlCursor {
init {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
windowSizeBytes != null &&
cursor is AbstractWindowedCursor
) {
cursor.setWindowSize(windowSizeBytes)
}
}
override fun next(): QueryResult.Value<Boolean> = QueryResult.Value(cursor.moveToNext())
override fun getString(index: Int) = if (cursor.isNull(index)) null else cursor.getString(index)
override fun getLong(index: Int) = if (cursor.isNull(index)) null else cursor.getLong(index)
override fun getBytes(index: Int) = if (cursor.isNull(index)) null else cursor.getBlob(index)
override fun getDouble(index: Int) = if (cursor.isNull(index)) null else cursor.getDouble(index)
override fun getBoolean(index: Int) =
if (cursor.isNull(index)) null else cursor.getLong(index) == 1L
}
@RequiresApi(Build.VERSION_CODES.P)
private object Api28Impl {
@JvmStatic
@DoNotInline
fun AbstractWindowedCursor.setWindowSize(windowSizeBytes: Long) {
window = CursorWindow(null, windowSizeBytes)
}
}
@@ -0,0 +1,134 @@
package ru.shadowsparky.vbox.shared.data
import app.cash.sqldelight.Query
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOneOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.domain.OfflineVideoQueries
import ru.shadowsparky.vbox.shared.domain.OfflineState
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
typealias SqlOfflineVideo = ru.shadowsparky.vbox.domain.OfflineVideo
@Factory
class AndroidOfflineStorage(
private val dbProvider: AppDatabaseProvider,
private val downloader: OfflineDownloader
) : OfflineStorage {
private suspend fun getQueries(): OfflineVideoQueries {
return dbProvider.get().offlineVideoQueries
}
override val videos: Flow<List<OfflineVideo>> = flow {
emitAll(getQueries().selectAll().toOfflineVideo())
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun Query<SqlOfflineVideo>.toOfflineVideo(): Flow<List<OfflineVideo>> {
return asFlow().mapToList(Dispatchers.IO)
.flatMapLatest { items: List<SqlOfflineVideo> ->
combine(
items.map { item ->
downloader.query(item.download_id)
.mapNotNull { state ->
if (state == null) remove(item.id)
item.toDomain(state)
}
}
) { it.toList() }.onStart { emit(emptyList()) }
}.flowOn(Dispatchers.IO)
}
override suspend fun query(id: Long): Flow<OfflineVideo?> =
withContext(Dispatchers.IO) {
getQueries()
.selectById(id)
.asFlow()
.mapToOneOrNull(Dispatchers.IO)
.map { it?.toDomain() }
}
private suspend fun queryRaw(id: Long): SqlOfflineVideo? =
withContext(Dispatchers.IO) {
getQueries()
.selectById(id)
.executeAsOneOrNull()
}
override fun queryByArgs(movieId: Long, seasonId: Long?): Flow<List<OfflineVideo>> = flow {
emitAll(
getQueries()
.selectByArgs(
movie_id = movieId,
season_id = seasonId
).toOfflineVideo()
)
}.flowOn(Dispatchers.IO)
override suspend fun find(movieId: Long, seasonId: Long?, episodeId: Long?): OfflineVideo? {
return withContext(Dispatchers.IO) {
getQueries().selectByFullArgs(movieId, seasonId, episodeId)
.executeAsOneOrNull()
?.toDomain()
}
}
override suspend fun add(item: OfflineVideo, uri: String) = withContext(Dispatchers.IO) {
val dmId = downloader.enqueue(uri)
upsertWithDm(item, dmId)
}
private suspend fun upsertWithDm(item: OfflineVideo, dmId: Long?) {
withContext(Dispatchers.IO) {
getQueries().upsert(
movie_id = item.movieId,
season_id = item.seasonId,
episode_id = item.episodeId,
id = item.id,
download_id = dmId
)
}
}
override suspend fun remove(item: OfflineVideo) {
val id = item.id ?: return
remove(id)
}
private suspend fun remove(id: Long) {
withContext(Dispatchers.IO) {
queryRaw(id)?.download_id?.let { downloader.delete(it) }
getQueries().deleteById(id)
}
}
private suspend fun SqlOfflineVideo.toDomain(state: OfflineState? = null): OfflineVideo? {
val state = state ?: run {
download_id?.let { downloader.query(it).firstOrNull() }
} ?: return null
return OfflineVideo(
id = id,
movieId = movie_id,
seasonId = season_id,
episodeId = episode_id,
state = state
)
}
}
@@ -0,0 +1,127 @@
package ru.shadowsparky.vbox.shared.data
import android.app.DownloadManager
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Environment
import android.os.Handler
import android.os.Looper
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.OfflineState
import java.util.UUID
@Factory
class OfflineDownloader(private val context: Context) {
private val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
suspend fun enqueue(url: String): Long = withContext(Dispatchers.IO) {
val request = DownloadManager.Request(url.toUri())
.setDestinationInExternalFilesDir(
context,
Environment.DIRECTORY_DOWNLOADS,
"/Videobox/${UUID.randomUUID()}.mp4"
)
dm.enqueue(request)
}
suspend fun delete(dmId: Long) = withContext(Dispatchers.IO) {
dm.remove(dmId)
}
fun query(dmId: Long?): Flow<OfflineState?> {
dmId ?: return flowOf(null)
return callbackFlow {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
trySend(Unit)
}
}
trySend(Unit)
context.contentResolver.registerContentObserver(getUri(dmId), false, observer)
awaitClose {
context.contentResolver.unregisterContentObserver(observer)
}
}.map { obtainState(dmId) }.flowOn(Dispatchers.IO)
}
private fun getUri(dmId: Long): Uri {
return "content://downloads/my_downloads/$dmId".toUri()
}
private fun obtainState(dmId: Long): OfflineState? {
val query = DownloadManager.Query().setFilterById(dmId)
dm.query(query).use { cursor ->
if (!cursor.moveToFirst()) return null
val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
return when (status) {
DownloadManager.STATUS_PENDING -> OfflineState.Pending
DownloadManager.STATUS_RUNNING -> obtainRunning(cursor)
DownloadManager.STATUS_PAUSED -> obtainPaused(cursor)
DownloadManager.STATUS_SUCCESSFUL -> {
val uri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)
) ?: return OfflineState.Error("Uri not found")
runCatching {
context.contentResolver.openFileDescriptor(uri.toUri(), "r").use {
return OfflineState.Available(uri)
}
}.getOrDefault(OfflineState.Error("File not found"))
}
DownloadManager.STATUS_FAILED -> {
val reason = cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)
)
OfflineState.Error("Download failed: reason=$reason")
}
else -> OfflineState.Error("Unknown status")
}
}
}
private fun obtainPaused(cursor: Cursor): OfflineState.Downloading {
val bytesDownloaded = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
)
val bytesTotal = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
)
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
} else 0
return OfflineState.Downloading(
isPaused = true,
progress = progress
)
}
private fun obtainRunning(cursor: Cursor): OfflineState.Downloading {
val bytesDownloaded = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
)
val bytesTotal = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
)
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
} else 0
return OfflineState.Downloading(
isPaused = false,
progress = progress
)
}
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.vbox.shared.di.factory
import android.app.Application
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.app.AppDatabase
@Factory
class AndroidDatabaseDriverFactory(private val app: Application) : DatabaseDriverFactory {
override suspend fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, app, "videobox.db")
}
}
@@ -0,0 +1,49 @@
package ru.shadowsparky.vbox.shared.di.factory
import android.app.Application
import com.seiko.imageloader.Bitmap
import com.seiko.imageloader.ImageLoader
import com.seiko.imageloader.cache.memory.MemoryCacheBuilder
import com.seiko.imageloader.cache.memory.MemoryKey
import com.seiko.imageloader.cache.memory.maxSizePercent
import com.seiko.imageloader.component.setupDefaultComponents
import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig
import com.seiko.imageloader.option.androidContext
import com.seiko.imageloader.size
import com.seiko.imageloader.util.identityHashCode
import io.ktor.client.HttpClient
import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Factory
@Factory
class AndroidImageLoaderProvider(
private val app: Application,
private val httpClient: HttpClient
) : ImageLoaderProvider {
override fun provide(): ImageLoader {
return ImageLoader {
options {
androidContext(app)
}
components {
setupDefaultComponents { httpClient }
}
interceptor {
// Set the max size to 25% of the app's available memory.
bitmapMemoryCacheConfig(
valueHashProvider = { identityHashCode(it) },
valueSizeProvider = { it.size },
block = fun MemoryCacheBuilder<MemoryKey, Bitmap>.() {
// Set the max size to 25% of the app's available memory.
maxSizePercent(app, 0.25)
}
)
diskCacheConfig {
directory(app.cacheDir.resolve("image_cache").toOkioPath())
maxSizeBytes(512L * 1024 * 1024) // 512MB
}
}
}
}
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.StateFlow
interface ActivityInfo {
val isActiveFlow: StateFlow<Boolean>
}
@@ -0,0 +1,5 @@
package ru.shadowsparky.vbox.shared.domain
actual fun getCurrentOsType(): OsType {
return OsType.ANDROID
}
@@ -0,0 +1,22 @@
package ru.shadowsparky.vbox.shared.presentation
import android.app.Application
import android.content.Intent
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
// adb shell am start -W -a android.intent.action.VIEW -d "https://box.shadowsparky.ru/deep-link/overview/191" ru.shadowsparky.videobox.multiplatform
@Factory
class AndroidShareManager(private val app: Application) : ShareManager {
override fun shareDetails(details: VideoDetails) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://box.shadowsparky.ru/overview/${details.id}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
app.startActivity(intent)
}
override fun canShare(): Boolean = true
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.vbox.shared.presentation
import android.app.Application
import android.content.Intent
import android.widget.Toast
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.cant_open_video_viewer
import androidx.core.net.toUri
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Factory
@Factory
class AndroidVideoLauncher(
private val app: Application
) : VideoLauncher {
override suspend fun launch(uri: String) {
if (!launchInternal(uri, true)) {
if (!launchInternal(uri, false)) {
Toast.makeText(
app,
getString(Res.string.cant_open_video_viewer),
Toast.LENGTH_LONG
).show()
}
}
}
private fun launchInternal(uri: String, withType: Boolean): Boolean {
val intent = Intent(Intent.ACTION_VIEW)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (withType) {
intent.setDataAndType(uri.toUri(), "video/*")
} else {
intent.data = uri.toUri()
}
try {
app.startActivity(intent)
} catch (_: Exception) {
return false
}
return true
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.vbox.shared.presentation.search
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
actual fun Modifier.requestFocusOnDownEvent(focusRequester: FocusRequester): Modifier {
return onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
if (it.key == Key.DirectionDown) {
focusRequester.requestFocus()
}
}
true
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
</vector>

Some files were not shown because too many files have changed in this diff Show More