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
object Qualifiers {
val binaryFilesFolderQualifier = StringQualifier("binaryFilesFolder")
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 filesFolderQualifier = StringQualifier("filesFolder")
val usersRolesKeyValueFactoryQualifier = StringQualifier("usersRolesKeyValueFactory")
val binaryStorageFilesQualifier = StringQualifier("binaryContentFiles")
}

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

@ -1,18 +1,105 @@
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.server.sessions.Qualifiers
import dev.inmo.postssystem.features.common.server.sessions.ServerModuleLoader
import dev.inmo.postssystem.features.content.common.BinaryContent
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.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 {
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 {
ServerContentStorageWrapper(
BinaryServerContentStorage(get(Qualifiers.binaryStorageFilesQualifier)),
BinaryServerContentStorage(
get(binaryStorageFilesQualifier),
get(binaryPreviewStorageFilesQualifier),
get(),
get()
),
BinaryContent::class
)
}

@ -1,16 +1,29 @@
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.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.server.storage.ServerContentStorage
import dev.inmo.postssystem.features.files.common.*
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.map
import java.io.File
class BinaryServerContentStorage(
private val filesStorage: FilesStorage
private val filesStorage: FilesStorage,
private val previewFilesStorage: FilesStorage,
private val cropper: ImagesCropper,
private val scope: CoroutineScope
) : ServerContentStorage<BinaryContent> {
private val FileId.asContentId
get() = ContentId(string)
@ -35,6 +48,50 @@ class BinaryServerContentStorage(
override val newObjectsFlow: Flow<RegisteredContent> = filesStorage.newObjectsFlow.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> {
return filesStorage.create(
values.map { it.asFullFileInfo }
@ -83,6 +140,8 @@ class BinaryServerContentStorage(
}
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())
}
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)
}
}
FullFileInfoStorageWrapper(newId, it)
FullFileInfoStorageWrapper(newId, it).also {
_newObjectsFlow.emit(it)
}
}
override suspend fun deleteById(ids: List<FileId>) {

@ -10,6 +10,7 @@ ktor = "1.6.8"
klock = "2.6.3"
exposed = "0.37.3"
psql = "42.3.0"
scrimage = "4.0.31"
android-dexcount = "3.0.1"
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" }
scrimage = { module = "com.sksamuel.scrimage:scrimage-core", version.ref = "scrimage" }
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" }

@ -32,9 +32,4 @@ data class Config(
}.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>().authConfig }
singleWithBinds(Qualifiers.filesFolderQualifier) { get<Config>().filesFolderFile }
singleWithBinds(Qualifiers.commonFilesFolderQualifier) { get<Config>().commonFilesFolder }
singleWithBinds(Qualifiers.binaryFilesFolderQualifier) { get<Config>().binaryFilesFolder }
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 { exposedUsersAuthenticator(get(), get()) }
@ -139,18 +122,6 @@ fun getDIModule(
factory<CoroutineScope> { baseScope.LinkedSupervisorScope() }
// 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(
ServerReadContentStorage::class,

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