add previews in binary content

This commit is contained in:
2022-03-18 19:04:57 +06:00
parent 787f8d7526
commit 72578f6b58
13 changed files with 254 additions and 49 deletions
features
common
server
src
jvmMain
kotlin
dev
inmo
postssystem
features
common
server
content
binary
server
src
jvmMain
kotlin
dev
inmo
postssystem
files
common
src
jvmMain
kotlin
dev
inmo
postssystem
features
gradle
server/src/main/java/dev/inmo/postssystem/server
services/posts/client/src/commonMain/kotlin/dev/inmo/postssystem/services/posts/client/ui/create

@ -3,13 +3,6 @@ package dev.inmo.postssystem.features.common.server.sessions
import org.koin.core.qualifier.StringQualifier import org.koin.core.qualifier.StringQualifier
object Qualifiers { object Qualifiers {
val binaryFilesFolderQualifier = StringQualifier("binaryFilesFolder") val filesFolderQualifier = StringQualifier("filesFolder")
val originalFilesMetasKeyValueRepoQualifier = StringQualifier("OriginalFilesMetaKV")
val binaryOriginalFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryOriginalFilesMetaKV")
val commonFilesMetasKeyValueRepoQualifier = StringQualifier("CommonFilesMetaKV")
val binaryFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryFilesMetaKV")
val filesFolderQualifier = StringQualifier("rootFilesFolder")
val commonFilesFolderQualifier = StringQualifier("commonFilesFolder")
val usersRolesKeyValueFactoryQualifier = StringQualifier("usersRolesKeyValueFactory") val usersRolesKeyValueFactoryQualifier = StringQualifier("usersRolesKeyValueFactory")
val binaryStorageFilesQualifier = StringQualifier("binaryContentFiles")
} }

@ -14,5 +14,10 @@ kotlin {
api project(":postssystem.features.files.server") api project(":postssystem.features.files.server")
} }
} }
jvmMain {
dependencies {
api libs.scrimage
}
}
} }
} }

@ -1,18 +1,105 @@
package dev.inmo.postssystem.features.content.binary.server package dev.inmo.postssystem.features.content.binary.server
import dev.inmo.micro_utils.repos.exposed.keyvalue.ExposedKeyValueRepo
import dev.inmo.postssystem.features.common.common.singleWithBinds
import dev.inmo.postssystem.features.common.common.singleWithRandomQualifier import dev.inmo.postssystem.features.common.common.singleWithRandomQualifier
import dev.inmo.postssystem.features.common.server.sessions.Qualifiers import dev.inmo.postssystem.features.common.server.sessions.Qualifiers
import dev.inmo.postssystem.features.common.server.sessions.ServerModuleLoader import dev.inmo.postssystem.features.common.server.sessions.ServerModuleLoader
import dev.inmo.postssystem.features.content.common.BinaryContent import dev.inmo.postssystem.features.content.common.BinaryContent
import dev.inmo.postssystem.features.content.server.ServerContentStorageWrapper import dev.inmo.postssystem.features.content.server.ServerContentStorageWrapper
import kotlinx.serialization.json.JsonObject import dev.inmo.postssystem.features.files.common.*
import dev.inmo.postssystem.features.files.common.storage.DefaultFilesStorage
import dev.inmo.postssystem.features.files.common.storage.FilesStorage
import kotlinx.serialization.json.*
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.qualifier.StringQualifier
import java.io.File
/**
* This provider is declaring one additional optional section: "previewDimensions". This section is an object with two
* fields:
*
* * maxWidth
* * maxHeight
*
* for preview images
*/
class BinaryContentServerModuleLoader : ServerModuleLoader { class BinaryContentServerModuleLoader : ServerModuleLoader {
override fun Module.load(config: JsonObject) { override fun Module.load(config: JsonObject) {
val binaryFilesFolderQualifier = StringQualifier("binaryFilesFolder")
val binaryStorageFilesQualifier = StringQualifier("binaryContentFiles")
val binaryPreviewStorageFilesQualifier = StringQualifier("binaryPreviewContentFiles")
val binaryPreviewFilesFolderQualifier = StringQualifier("binaryPreviewFilesFolder")
val binaryFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryFilesMetaKV")
val binaryPreviewFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryPreviewFilesMetaKV")
val binaryOriginalFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryOriginalFilesMetaKV")
val binaryOriginalPreviewFilesMetasKeyValueRepoQualifier = StringQualifier("BinaryPreviewOriginalFilesMetaKV")
singleWithBinds(binaryFilesFolderQualifier) {
File(get<File>(Qualifiers.filesFolderQualifier), "binary_content").apply {
mkdirs()
}
}
single {
val dimensionsSection = config["previewDimensions"] ?: return@single PreviewImageDimensionsConfig()
get<Json>().decodeFromJsonElement(
PreviewImageDimensionsConfig.serializer(),
dimensionsSection
)
}
single<ImagesCropper> {
ScrimageBasedImagesCropper(get())
}
singleWithBinds(binaryPreviewFilesFolderQualifier) {
File(get<File>(binaryFilesFolderQualifier), "preview").apply {
mkdirs()
}
}
singleWithBinds(binaryOriginalFilesMetasKeyValueRepoQualifier) {
ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "BinaryContentFileIdsToMetas")
}
singleWithBinds(binaryOriginalPreviewFilesMetasKeyValueRepoQualifier) {
ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "BinaryPreviewContentFileIdsToMetas")
}
singleWithBinds(binaryFilesMetasKeyValueRepoQualifier) {
MetasKeyValueRepo(
get(),
get(binaryOriginalFilesMetasKeyValueRepoQualifier)
)
}
singleWithBinds(binaryPreviewFilesMetasKeyValueRepoQualifier) {
MetasKeyValueRepo(
get(),
get(binaryOriginalPreviewFilesMetasKeyValueRepoQualifier)
)
}
single<FilesStorage>(binaryStorageFilesQualifier) {
DefaultFilesStorage(
DiskReadFilesStorage(get(binaryFilesFolderQualifier), get(binaryFilesMetasKeyValueRepoQualifier)),
WriteDistFilesStorage(get(binaryFilesFolderQualifier), get(binaryFilesMetasKeyValueRepoQualifier))
)
}
single<FilesStorage>(binaryPreviewStorageFilesQualifier) {
DefaultFilesStorage(
DiskReadFilesStorage(get(binaryPreviewFilesFolderQualifier), get(binaryPreviewFilesMetasKeyValueRepoQualifier)),
WriteDistFilesStorage(get(binaryPreviewFilesFolderQualifier), get(binaryPreviewFilesMetasKeyValueRepoQualifier))
)
}
singleWithRandomQualifier { singleWithRandomQualifier {
ServerContentStorageWrapper( ServerContentStorageWrapper(
BinaryServerContentStorage(get(Qualifiers.binaryStorageFilesQualifier)), BinaryServerContentStorage(
get(binaryStorageFilesQualifier),
get(binaryPreviewStorageFilesQualifier),
get(),
get()
),
BinaryContent::class BinaryContent::class
) )
} }

@ -1,16 +1,29 @@
package dev.inmo.postssystem.features.content.binary.server package dev.inmo.postssystem.features.content.binary.server
import com.benasher44.uuid.uuid4
import dev.inmo.micro_utils.coroutines.plus
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
import dev.inmo.micro_utils.mime_types.KnownMimeTypes
import dev.inmo.micro_utils.pagination.* import dev.inmo.micro_utils.pagination.*
import dev.inmo.micro_utils.repos.UpdatedValuePair import dev.inmo.micro_utils.repos.*
import dev.inmo.postssystem.features.common.common.FileBasedInputProvider
import dev.inmo.postssystem.features.content.common.* import dev.inmo.postssystem.features.content.common.*
import dev.inmo.postssystem.features.content.server.storage.ServerContentStorage import dev.inmo.postssystem.features.content.server.storage.ServerContentStorage
import dev.inmo.postssystem.features.files.common.* import dev.inmo.postssystem.features.files.common.*
import dev.inmo.postssystem.features.files.common.storage.FilesStorage import dev.inmo.postssystem.features.files.common.storage.FilesStorage
import io.ktor.util.asStream
import io.ktor.utils.io.core.copyTo
import io.ktor.utils.io.streams.asOutput
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.File
class BinaryServerContentStorage( class BinaryServerContentStorage(
private val filesStorage: FilesStorage private val filesStorage: FilesStorage,
private val previewFilesStorage: FilesStorage,
private val cropper: ImagesCropper,
private val scope: CoroutineScope
) : ServerContentStorage<BinaryContent> { ) : ServerContentStorage<BinaryContent> {
private val FileId.asContentId private val FileId.asContentId
get() = ContentId(string) get() = ContentId(string)
@ -35,6 +48,50 @@ class BinaryServerContentStorage(
override val newObjectsFlow: Flow<RegisteredContent> = filesStorage.newObjectsFlow.map { it.asRegisteredContent } override val newObjectsFlow: Flow<RegisteredContent> = filesStorage.newObjectsFlow.map { it.asRegisteredContent }
override val updatedObjectsFlow: Flow<RegisteredContent> = filesStorage.updatedObjectsFlow.map { it.asRegisteredContent } override val updatedObjectsFlow: Flow<RegisteredContent> = filesStorage.updatedObjectsFlow.map { it.asRegisteredContent }
private val fullsRemovedJob = deletedObjectsIdsFlow.subscribeSafelyWithoutExceptions(scope) {
previewFilesStorage.deleteById(it.asFileId)
}
private val previewCroppingJob = (newObjectsFlow + updatedObjectsFlow).subscribeSafelyWithoutExceptions(scope) {
val content = it.content
val fileId = it.id.asFileId
if (content !is BinaryContent) {
return@subscribeSafelyWithoutExceptions
}
val fullFileInfo = filesStorage.getFullFileInfo(fileId) ?: return@subscribeSafelyWithoutExceptions
val fileInfo = fullFileInfo.fileInfo
if (fileInfo.mimeType is KnownMimeTypes.Image) {
cropper.crop(fileInfo.inputProvider).subscribeSafelyWithoutExceptions(scope) {
val tempFile = File.createTempFile(uuid4().toString(), ".${fileInfo.name.extension}").apply {
deleteOnExit()
createNewFile()
}
runCatching {
tempFile.outputStream().use { output ->
it.asStream().use { input ->
input.copyTo(output)
}
}
val newFullFileInfo = FullFileInfo(
fileInfo.name,
fileInfo.mimeType,
FileBasedInputProvider(tempFile)
)
if (previewFilesStorage.contains(fileId)) {
previewFilesStorage.update(
fullFileInfo.id,
newFullFileInfo
)
} else {
previewFilesStorage.create(newFullFileInfo).firstOrNull()
}
}
tempFile.delete()
}
}
}
override suspend fun create(values: List<BinaryContent>): List<RegisteredContent> { override suspend fun create(values: List<BinaryContent>): List<RegisteredContent> {
return filesStorage.create( return filesStorage.create(
values.map { it.asFullFileInfo } values.map { it.asFullFileInfo }
@ -83,6 +140,8 @@ class BinaryServerContentStorage(
} }
override suspend fun getContentPreview(id: ContentId): RegisteredContent? { override suspend fun getContentPreview(id: ContentId): RegisteredContent? {
TODO("Not yet implemented") val fileId = id.asFileId
val fileInfo = previewFilesStorage.getFullFileInfo(fileId) ?: return null
return fileInfo.asRegisteredContent
} }
} }

@ -0,0 +1,9 @@
package dev.inmo.postssystem.features.content.binary.server
import dev.inmo.postssystem.features.common.common.SimpleInputProvider
import io.ktor.utils.io.core.Input
import kotlinx.coroutines.flow.Flow
interface ImagesCropper {
suspend fun crop(inputProvider: SimpleInputProvider): Flow<Input>
}

@ -0,0 +1,9 @@
package dev.inmo.postssystem.features.content.binary.server
import kotlinx.serialization.Serializable
@Serializable
data class PreviewImageDimensionsConfig(
val maxWidth: Int = 1024,
val maxHeight: Int = 1024,
)

@ -0,0 +1,51 @@
package dev.inmo.postssystem.features.content.binary.server
import com.benasher44.uuid.uuid4
import com.sksamuel.scrimage.ImmutableImage
import com.sksamuel.scrimage.nio.JpegWriter
import dev.inmo.postssystem.features.common.common.SimpleInputProvider
import io.ktor.util.asStream
import io.ktor.utils.io.core.Input
import io.ktor.utils.io.streams.asInput
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import java.io.File
class ScrimageBasedImagesCropper(
private val config: PreviewImageDimensionsConfig
) : ImagesCropper {
override suspend fun crop(inputProvider: SimpleInputProvider): Flow<Input> = flow {
val outputTempFile = File.createTempFile(uuid4().toString(), ".temp").apply {
createNewFile()
deleteOnExit()
}
ImmutableImage.loader().fromStream(
inputProvider().asStream()
).max(
config.maxWidth,
config.maxHeight
).let {
if (currentCoroutineContext().isActive) {
it.output(
JpegWriter(),
outputTempFile
)
} else {
return@flow
}
}
val input = outputTempFile.inputStream().asInput()
runCatching {
emit(input)
}.onSuccess {
if (input.endOfInput) { // remove file if it was fully read inside of emit
outputTempFile.delete()
}
}.onFailure {
outputTempFile.delete()
}
}
}

@ -115,4 +115,25 @@ class ServerContentStorageAggregator(
return currentResults.createPaginationResult(pagination, count()) return currentResults.createPaginationResult(pagination, count())
} }
override suspend fun getContentPreview(id: ContentId): RegisteredContent? {
val result = CompletableDeferred<RegisteredContent>()
storages.map {
scope.launch {
val content = it.getContentPreview(id)
if (content != null) {
result.complete(content)
}
}.also { job ->
result.invokeOnCompletion { job.cancel() }
}
}.joinAll()
return if (result.isCompleted) {
result.getCompleted()
} else {
return null
}
}
} }

@ -44,7 +44,9 @@ class WriteDistFilesStorage(
input.copyTo(output) input.copyTo(output)
} }
} }
FullFileInfoStorageWrapper(newId, it) FullFileInfoStorageWrapper(newId, it).also {
_newObjectsFlow.emit(it)
}
} }
override suspend fun deleteById(ids: List<FileId>) { override suspend fun deleteById(ids: List<FileId>) {

@ -10,6 +10,7 @@ ktor = "1.6.8"
klock = "2.6.3" klock = "2.6.3"
exposed = "0.37.3" exposed = "0.37.3"
psql = "42.3.0" psql = "42.3.0"
scrimage = "4.0.31"
android-dexcount = "3.0.1" android-dexcount = "3.0.1"
android-junit = "4.12" android-junit = "4.12"
@ -50,6 +51,8 @@ tgbotapi = { module = "dev.inmo:tgbotapi", version.ref = "tgbotapi" }
klock = { module = "com.soywiz.korlibs.klock:klock", version.ref = "klock" } klock = { module = "com.soywiz.korlibs.klock:klock", version.ref = "klock" }
scrimage = { module = "com.sksamuel.scrimage:scrimage-core", version.ref = "scrimage" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "android-test-junit" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "android-test-junit" }
androidx-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "android-espresso-core" } androidx-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "android-espresso-core" }

@ -32,9 +32,4 @@ data class Config(
}.getOrNull() }.getOrNull()
} }
} }
val commonFilesFolder: File
get() = File(filesFolderFile, "common").also { it.mkdirs() }
val binaryFilesFolder: File
get() = File(filesFolderFile, "binary_content").also { it.mkdirs() }
} }

@ -94,25 +94,8 @@ fun getDIModule(
singleWithBinds { get<Config>().databaseConfig } singleWithBinds { get<Config>().databaseConfig }
singleWithBinds { get<Config>().authConfig } singleWithBinds { get<Config>().authConfig }
singleWithBinds(Qualifiers.filesFolderQualifier) { get<Config>().filesFolderFile } singleWithBinds(Qualifiers.filesFolderQualifier) { get<Config>().filesFolderFile }
singleWithBinds(Qualifiers.commonFilesFolderQualifier) { get<Config>().commonFilesFolder }
singleWithBinds(Qualifiers.binaryFilesFolderQualifier) { get<Config>().binaryFilesFolder }
singleWithBinds { get<DatabaseConfig>().database } singleWithBinds { get<DatabaseConfig>().database }
singleWithBinds(Qualifiers.originalFilesMetasKeyValueRepoQualifier) {
ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "FileIdsToMetas")
}
singleWithBinds(Qualifiers.binaryOriginalFilesMetasKeyValueRepoQualifier) {
ExposedKeyValueRepo(get(), { text("fileid") }, { text("metaInfo") }, "BinaryContentFileIdsToMetas")
}
singleWithBinds(Qualifiers.commonFilesMetasKeyValueRepoQualifier) {
MetasKeyValueRepo(
get(),
get(Qualifiers.originalFilesMetasKeyValueRepoQualifier)
)
}
single<ReadFilesStorage> { DiskReadFilesStorage(get(Qualifiers.commonFilesFolderQualifier), get(Qualifiers.commonFilesMetasKeyValueRepoQualifier)) }
single<WriteFilesStorage> { WriteDistFilesStorage(get(Qualifiers.commonFilesFolderQualifier), get(Qualifiers.commonFilesMetasKeyValueRepoQualifier)) }
single<FilesStorage> { DefaultFilesStorage(get(), get()) }
singleWithBinds { ExposedUsersStorage(get()) } singleWithBinds { ExposedUsersStorage(get()) }
singleWithBinds { exposedUsersAuthenticator(get(), get()) } singleWithBinds { exposedUsersAuthenticator(get(), get()) }
@ -139,18 +122,6 @@ fun getDIModule(
factory<CoroutineScope> { baseScope.LinkedSupervisorScope() } factory<CoroutineScope> { baseScope.LinkedSupervisorScope() }
// Content storages // Content storages
singleWithBinds(Qualifiers.binaryFilesMetasKeyValueRepoQualifier) {
MetasKeyValueRepo(
get(),
get(Qualifiers.binaryOriginalFilesMetasKeyValueRepoQualifier)
)
}
single<FilesStorage>(Qualifiers.binaryStorageFilesQualifier) {
DefaultFilesStorage(
DiskReadFilesStorage(get(Qualifiers.binaryFilesFolderQualifier), get(Qualifiers.binaryFilesMetasKeyValueRepoQualifier)),
WriteDistFilesStorage(get(Qualifiers.binaryFilesFolderQualifier), get(Qualifiers.binaryFilesMetasKeyValueRepoQualifier))
)
}
single<ServerContentStorage<Content>> { ServerContentStorageAggregator(getAll(), get()) } binds arrayOf( single<ServerContentStorage<Content>> { ServerContentStorageAggregator(getAll(), get()) } binds arrayOf(
ServerReadContentStorage::class, ServerReadContentStorage::class,

@ -20,7 +20,7 @@ class DefaultPostCreateUIModel(
FullNewPost(content) FullNewPost(content)
) ?: return@runCatching ) ?: return@runCatching
delay(1000L) delay(1000L)
publicationService.publish(post.id) // publicationService.publish(post.id)
}.onFailure { }.onFailure {
_currentState.value = PostCreateUIState.Fail _currentState.value = PostCreateUIState.Fail
}.onSuccess { }.onSuccess {