add previews in binary content

This commit is contained in:
InsanusMokrassar 2022-03-18 19:04:57 +06:00
parent 787f8d7526
commit 72578f6b58
13 changed files with 254 additions and 49 deletions

View File

@ -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")
}

View File

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

View File

@ -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
)
}

View File

@ -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
}
}

View File

@ -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>
}

View File

@ -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,
)

View File

@ -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()
}
}
}

View File

@ -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
}
}
}

View File

@ -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>) {

View File

@ -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" }

View File

@ -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() }
}

View File

@ -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,

View File

@ -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 {