diff --git a/app/src/main/kotlin/io/sakurasou/exception/service/image/io/ImageThumbnailNotFoundException.kt b/app/src/main/kotlin/io/sakurasou/exception/service/image/io/ImageThumbnailNotFoundException.kt new file mode 100644 index 00000000..d2f064ef --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/exception/service/image/io/ImageThumbnailNotFoundException.kt @@ -0,0 +1,14 @@ +package io.sakurasou.exception.service.image.io + +import io.sakurasou.exception.ServiceThrowable + +/** + * @author Shiina Kin + * 2024/9/12 12:57 + */ +class ImageThumbnailNotFoundException : ServiceThrowable() { + override val code: Int + get() = 4004 + override val message: String + get() = "Image thumbnail not found, will be generated later" +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/sakurasou/execute/executor/image/ImageExecutor.kt b/app/src/main/kotlin/io/sakurasou/execute/executor/image/ImageExecutor.kt new file mode 100644 index 00000000..002dbfb7 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/execute/executor/image/ImageExecutor.kt @@ -0,0 +1,62 @@ +package io.sakurasou.execute.executor.image + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.sakurasou.execute.task.image.DeleteImageTask +import io.sakurasou.execute.task.image.DeleteThumbnailTask +import io.sakurasou.execute.task.image.ImageTask +import io.sakurasou.execute.task.image.PersistImageThumbnailTask +import io.sakurasou.execute.task.image.RePersistImageThumbnailTask +import io.sakurasou.model.entity.Strategy +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import java.awt.image.BufferedImage + +/** + * @author Shiina Kin + * 2024/12/18 18:31 + */ +object ImageExecutor { + private val logger = KotlinLogging.logger {} + + private val imageExecuteScope = CoroutineScope( + Dispatchers.IO + SupervisorJob() + CoroutineName("ImageExecutor") + ) + + private val taskChannel = Channel(Channel.UNLIMITED) + + init { + imageExecuteScope.launch { + logger.debug { "image executor started" } + for (task in taskChannel) { + launch { + try { + task.execute() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + suspend fun persistThumbnail(strategy: Strategy, subFolder: String, fileName: String, image: BufferedImage) { + taskChannel.send(PersistImageThumbnailTask(strategy, subFolder, fileName, image)) + } + + suspend fun rePersistThumbnail(strategy: Strategy, relativePath: String) { + taskChannel.send(RePersistImageThumbnailTask(strategy, relativePath)) + } + + suspend fun deleteImage(strategy: Strategy, relativePath: String) { + taskChannel.send(DeleteImageTask(strategy, relativePath)) + } + + suspend fun deleteThumbnail(strategy: Strategy, relativePath: String) { + taskChannel.send(DeleteThumbnailTask(strategy, relativePath)) + } + + fun shutdown() { + taskChannel.close() + imageExecuteScope.cancel() + } +} diff --git a/app/src/main/kotlin/io/sakurasou/execute/task/image/ImageTask.kt b/app/src/main/kotlin/io/sakurasou/execute/task/image/ImageTask.kt new file mode 100644 index 00000000..6f018ed8 --- /dev/null +++ b/app/src/main/kotlin/io/sakurasou/execute/task/image/ImageTask.kt @@ -0,0 +1,123 @@ +package io.sakurasou.execute.task.image + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.sakurasou.exception.service.image.io.ImageFileDeleteFailedException +import io.sakurasou.exception.service.image.io.ImageFileNotFoundException +import io.sakurasou.model.entity.Strategy +import io.sakurasou.model.group.ImageType +import io.sakurasou.model.strategy.LocalStrategy +import io.sakurasou.model.strategy.S3Strategy +import io.sakurasou.util.ImageUtils +import io.sakurasou.util.S3Utils +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +/** + * @author Shiina Kin + * 2024/12/18 18:38 + */ + +private const val THUMBNAIL_HEIGHT = 256 +private const val THUMBNAIL_QUALITY = 0.9 + +sealed class ImageTask() { + val logger = KotlinLogging.logger {} + abstract fun execute() +} + +class PersistImageThumbnailTask( + private val strategy: Strategy, + private val subFolder: String, + private val fileName: String, + private val image: BufferedImage +) : ImageTask() { + override fun execute() { + val relativePath = "$subFolder/$fileName" + val imageType = ImageType.valueOf(fileName.substringAfterLast('.').uppercase()) + val thumbnailBytes = ImageUtils.transformImageByHeight(image, imageType, THUMBNAIL_HEIGHT, THUMBNAIL_QUALITY) + ImageUtils.saveThumbnail(strategy, subFolder, fileName, thumbnailBytes, relativePath) + logger.debug { "persist thumbnail $fileName in strategy(id ${strategy.id})" } + } +} + +class RePersistImageThumbnailTask( + private val strategy: Strategy, + private val relativePath: String +) : ImageTask() { + override fun execute() { + val fileName = relativePath.substringAfterLast('/') + when (val strategyConfig = strategy.config) { + is LocalStrategy -> { + val uploadFolderStr = strategyConfig.uploadFolder + val uploadFile = File(uploadFolderStr, relativePath) + if (!uploadFile.exists()) throw ImageFileNotFoundException() + uploadFile.inputStream() + } + + is S3Strategy -> { + val filePath = "${strategyConfig.uploadFolder}/$relativePath" + S3Utils.fetchImage(filePath, strategy) + } + }.use { rawImageInputStream -> + val rawImage = ImageIO.read(rawImageInputStream) + val subFolder = relativePath.substringBeforeLast('/') + val imageType = ImageType.valueOf(fileName.substringAfterLast('.').uppercase()) + val thumbnailBytes = + ImageUtils.transformImageByHeight(rawImage, imageType, THUMBNAIL_HEIGHT, THUMBNAIL_QUALITY) + ImageUtils.saveThumbnail(strategy, subFolder, fileName, thumbnailBytes, relativePath) + } + logger.debug { "re-persist thumbnail $fileName in strategy(id ${strategy.id})" } + } +} + +class DeleteImageTask( + private val strategy: Strategy, + private val relativePath: String +) : ImageTask() { + override fun execute() { + when (val strategyConfig = strategy.config) { + is LocalStrategy -> { + val uploadFolderStr = strategyConfig.uploadFolder + val uploadFile = File(uploadFolderStr, relativePath) + + if (uploadFile.exists() && !uploadFile.delete()) { + logger.error { "Failed to delete image at $uploadFolderStr/$relativePath" } + throw ImageFileDeleteFailedException() + } + } + + is S3Strategy -> { + val filePath = "${strategyConfig.uploadFolder}/$relativePath" + S3Utils.deleteImage(filePath, strategy) + } + } + val fileName = relativePath.substringAfterLast('/') + logger.debug { "delete image $fileName from strategy(id ${strategy.id})" } + } +} + +class DeleteThumbnailTask( + private val strategy: Strategy, + private val relativePath: String +) : ImageTask() { + override fun execute() { + when (val strategyConfig = strategy.config) { + is LocalStrategy -> { + val thumbnailFolder = strategyConfig.thumbnailFolder + val thumbnailFile = File(thumbnailFolder, relativePath) + + if (thumbnailFile.exists() && !thumbnailFile.delete()) { + logger.error { "Failed to delete thumbnail at $thumbnailFolder/$relativePath" } + } + } + + is S3Strategy -> { + val filePath = "${strategyConfig.thumbnailFolder}/$relativePath" + S3Utils.deleteImage(filePath, strategy) + } + } + val fileName = relativePath.substringAfterLast('/') + logger.debug { "delete thumbnail $fileName from strategy(id ${strategy.id})" } + } +} diff --git a/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt b/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt index fe14d2b3..a60f6ab1 100644 --- a/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt +++ b/app/src/main/kotlin/io/sakurasou/service/image/ImageServiceImpl.kt @@ -1,5 +1,6 @@ package io.sakurasou.service.image +import io.github.oshai.kotlinlogging.KotlinLogging import io.sakurasou.controller.request.ImageManagePatchRequest import io.sakurasou.controller.request.ImagePatchRequest import io.sakurasou.controller.request.ImageRawFile @@ -13,8 +14,10 @@ import io.sakurasou.exception.service.album.AlbumAccessDeniedException import io.sakurasou.exception.service.album.AlbumNotFoundException import io.sakurasou.exception.service.group.GroupNotFoundException import io.sakurasou.exception.service.image.* +import io.sakurasou.exception.service.image.io.ImageThumbnailNotFoundException import io.sakurasou.exception.service.strategy.StrategyNotFoundException import io.sakurasou.exception.service.user.UserNotFoundException +import io.sakurasou.execute.executor.image.ImageExecutor import io.sakurasou.model.DatabaseSingleton.dbQuery import io.sakurasou.model.dao.album.AlbumDao import io.sakurasou.model.dao.group.GroupDao @@ -30,9 +33,6 @@ import io.sakurasou.model.strategy.S3Strategy import io.sakurasou.service.setting.SettingService import io.sakurasou.util.ImageUtils import io.sakurasou.util.PlaceholderUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -52,6 +52,8 @@ class ImageServiceImpl( private val strategyDao: StrategyDao, private val settingService: SettingService ) : ImageService { + private val logger = KotlinLogging.logger {} + override suspend fun saveImage(userId: Long, imageRawFile: ImageRawFile): String { val siteSetting = settingService.getSiteSetting() return runCatching { @@ -143,10 +145,7 @@ class ImageServiceImpl( imageDao.saveImage(imageInsertDTO) - // TODO use a mq to do this - CoroutineScope(Dispatchers.IO).launch { - ImageUtils.createAndUploadThumbnail(strategy, subFolder, storageFileName, image) - } + ImageExecutor.persistThumbnail(strategy, subFolder, storageFileName, image) if (user.isDefaultImagePrivate) "" else "${siteSetting.siteExternalUrl}/s/$uniqueName" @@ -167,8 +166,8 @@ class ImageServiceImpl( val strategy = strategyDao.findStrategyById(image.strategyId) ?: throw StrategyNotFoundException() imageDao.deleteImageById(imageId) - ImageUtils.deleteImage(strategy, image.path) - ImageUtils.deleteThumbnail(strategy, image.path) + ImageExecutor.deleteImage(strategy, image.path) + ImageExecutor.deleteThumbnail(strategy, image.path) } }.onFailure { if (it is ServiceThrowable) throw ImageDeleteFailedException(it) @@ -183,8 +182,8 @@ class ImageServiceImpl( val strategy = strategyDao.findStrategyById(image.strategyId) ?: throw StrategyNotFoundException() imageDao.deleteImageById(imageId) - ImageUtils.deleteImage(strategy, image.path) - ImageUtils.deleteThumbnail(strategy, image.path) + ImageExecutor.deleteImage(strategy, image.path) + ImageExecutor.deleteThumbnail(strategy, image.path) } }.onFailure { if (it is ServiceThrowable) throw ImageDeleteFailedException(it) @@ -333,6 +332,12 @@ class ImageServiceImpl( when (strategy.config) { is LocalStrategy -> ImageFileDTO(bytes = ImageUtils.fetchLocalImage(strategy, image.path, true)) is S3Strategy -> ImageFileDTO(url = ImageUtils.fetchS3Image(strategy, image.path, true)) + }.also { + if (it.bytes == null && it.url.isNullOrBlank()) { + logger.debug { "thumbnail of image $imageId doesn't exist, will be generate later." } + ImageExecutor.rePersistThumbnail(strategy, image.path) + throw ImageThumbnailNotFoundException() + } } } } diff --git a/app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt b/app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt index 642e2331..3fc2928d 100644 --- a/app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt +++ b/app/src/main/kotlin/io/sakurasou/util/ImageUtils.kt @@ -2,7 +2,6 @@ package io.sakurasou.util import io.github.oshai.kotlinlogging.KotlinLogging import io.sakurasou.exception.service.image.io.ImageFileCreateFailedException -import io.sakurasou.exception.service.image.io.ImageFileDeleteFailedException import io.sakurasou.exception.service.image.io.ImageFileNotFoundException import io.sakurasou.exception.service.image.io.ImageParentFolderCreateFailedException import io.sakurasou.model.entity.Strategy @@ -20,11 +19,10 @@ import java.io.File * @author Shiina Kin * 2024/10/12 08:52 */ -private val logger = KotlinLogging.logger {} - -private const val THUMBNAIL_HEIGHT = 250 object ImageUtils { + private val logger = KotlinLogging.logger {} + suspend fun uploadImageAndGetRelativePath( strategy: Strategy, subFolder: String, @@ -47,7 +45,7 @@ object ImageUtils { logger.error(it) { "Failed to save image $fileName at ${uploadFile.absolutePath}" } throw ImageFileCreateFailedException() }.onSuccess { - logger.trace { "save image $fileName at ${uploadFile.absolutePath}" } + logger.debug { "save image $fileName at ${uploadFile.absolutePath}" } } } @@ -60,86 +58,40 @@ object ImageUtils { } } - suspend fun createAndUploadThumbnail( + fun saveThumbnail( strategy: Strategy, subFolder: String, fileName: String, - image: BufferedImage + thumbnailBytes: ByteArray, + relativePath: String ) { - return withContext(Dispatchers.IO) { - val relativePath = "$subFolder/$fileName" - val imageType = ImageType.valueOf(fileName.substringAfterLast('.').uppercase()) - val thumbnailBytes = transformImageByHeight(image, imageType, THUMBNAIL_HEIGHT, 0.9) - - when (val strategyConfig = strategy.config) { - is LocalStrategy -> { - val thumbnailFolderStr = strategyConfig.thumbnailFolder - val thumbnailFolder = File(thumbnailFolderStr, subFolder) - if (!thumbnailFolder.exists() && !thumbnailFolder.mkdirs()) { - logger.error { "Failed to create thumbnail folder" } - return@withContext - } - val thumbnailFile = File(thumbnailFolder, fileName) - runCatching { - thumbnailFile.writeBytes(thumbnailBytes) - }.onFailure { - logger.error(it) { "Failed to save thumbnail of image $fileName at ${thumbnailFile.absolutePath}" } - return@withContext - }.onSuccess { - logger.trace { "save thumbnail of image $fileName at ${thumbnailFile.absolutePath}" } - } + when (val strategyConfig = strategy.config) { + is LocalStrategy -> { + val thumbnailFolderStr = strategyConfig.thumbnailFolder + val thumbnailFolder = File(thumbnailFolderStr, subFolder) + if (!thumbnailFolder.exists() && !thumbnailFolder.mkdirs()) { + logger.error { "Failed to create thumbnail folder" } + return } - - is S3Strategy -> { - val filePath = "${strategyConfig.thumbnailFolder}/$relativePath" - S3Utils.uploadImage(filePath, thumbnailBytes, strategy) + val thumbnailFile = File(thumbnailFolder, fileName) + runCatching { + thumbnailFile.writeBytes(thumbnailBytes) + }.onFailure { + logger.error(it) { "Failed to save thumbnail of image $fileName at ${thumbnailFile.absolutePath}" } + return + }.onSuccess { + logger.debug { "save thumbnail of image $fileName at ${thumbnailFile.absolutePath}" } } } - } - } - - suspend fun deleteImage(strategy: Strategy, relativePath: String) { - return withContext(Dispatchers.IO) { - when (val strategyConfig = strategy.config) { - is LocalStrategy -> { - val uploadFolderStr = strategyConfig.uploadFolder - val uploadFile = File(uploadFolderStr, relativePath) - if (uploadFile.exists() && !uploadFile.delete()) { - logger.error { "Failed to delete image at $uploadFolderStr/$relativePath" } - throw ImageFileDeleteFailedException() - } - } - - is S3Strategy -> { - val filePath = "${strategyConfig.uploadFolder}/$relativePath" - S3Utils.deleteImage(filePath, strategy) - } + is S3Strategy -> { + val filePath = "${strategyConfig.thumbnailFolder}/$relativePath" + S3Utils.uploadImage(filePath, thumbnailBytes, strategy) } } } - suspend fun deleteThumbnail(strategy: Strategy, relativePath: String) { - return withContext(Dispatchers.IO) { - when (val strategyConfig = strategy.config) { - is LocalStrategy -> { - val thumbnailFolder = strategyConfig.thumbnailFolder - val thumbnailFile = File(thumbnailFolder, relativePath) - - if (thumbnailFile.exists() && !thumbnailFile.delete()) { - logger.error { "Failed to delete thumbnail at $thumbnailFolder/$relativePath" } - } - } - - is S3Strategy -> { - val filePath = "${strategyConfig.thumbnailFolder}/$relativePath" - S3Utils.deleteImage(filePath, strategy) - } - } - } - } - - suspend fun fetchLocalImage(strategy: Strategy, relativePath: String, isThumbnail: Boolean = false): ByteArray { + suspend fun fetchLocalImage(strategy: Strategy, relativePath: String, isThumbnail: Boolean = false): ByteArray? { return withContext(Dispatchers.IO) { when (val strategyConfig = strategy.config) { is S3Strategy -> throw RuntimeException("Not supported") @@ -148,8 +100,7 @@ object ImageUtils { val parentFolderStr = if (isThumbnail) strategyConfig.thumbnailFolder else strategyConfig.uploadFolder val uploadFile = File(parentFolderStr, relativePath) - if (!uploadFile.exists()) throw ImageFileNotFoundException() - uploadFile.readBytes() + if (uploadFile.exists()) uploadFile.readBytes() else null } } } @@ -162,7 +113,12 @@ object ImageUtils { is S3Strategy -> { val folder = if (isThumbnail) strategyConfig.thumbnailFolder else strategyConfig.uploadFolder - "${strategyConfig.publicUrl}/$folder/$relativePath" + val relativePath = "$folder/$relativePath" + if (!S3Utils.isImageExist(relativePath, strategy)) { + if (isThumbnail) return@withContext "" + throw ImageFileNotFoundException() + } + "${strategyConfig.publicUrl}/$relativePath" } } } @@ -178,16 +134,6 @@ object ImageUtils { } } - suspend fun transformImage(rawImage: BufferedImage, targetImageType: ImageType): ByteArray { - return withContext(Dispatchers.IO) { - ByteArrayOutputStream().apply { - Thumbnails.of(rawImage) - .outputFormat(targetImageType.name) - .toOutputStream(this) - }.use { it.toByteArray() } - } - } - suspend fun transformImage(rawImage: BufferedImage, targetImageType: ImageType, quality: Double): ByteArray { return withContext(Dispatchers.IO) { ByteArrayOutputStream().apply { @@ -214,31 +160,27 @@ object ImageUtils { .toOutputStream(this) }.use { it.toByteArray() } - private suspend fun transformImageByWidth( + fun transformImageByWidth( rawImage: BufferedImage, targetImageType: ImageType, newWidth: Int, quality: Double ): ByteArray { - return withContext(Dispatchers.IO) { - val originalWidth = rawImage.width - val originalHeight = rawImage.height - val newHeight = (originalHeight * newWidth) / originalWidth - transformImage(rawImage, targetImageType, newWidth, newHeight, quality) - } + val originalWidth = rawImage.width + val originalHeight = rawImage.height + val newHeight = (originalHeight * newWidth) / originalWidth + return transformImage(rawImage, targetImageType, newWidth, newHeight, quality) } - private suspend fun transformImageByHeight( + fun transformImageByHeight( rawImage: BufferedImage, targetImageType: ImageType, newHeight: Int, quality: Double ): ByteArray { - return withContext(Dispatchers.IO) { - val originalWidth = rawImage.width - val originalHeight = rawImage.height - val newWidth = (originalWidth * newHeight) / originalHeight - transformImage(rawImage, targetImageType, newWidth, newHeight, quality) - } + val originalWidth = rawImage.width + val originalHeight = rawImage.height + val newWidth = (originalWidth * newHeight) / originalHeight + return transformImage(rawImage, targetImageType, newWidth, newHeight, quality) } } \ No newline at end of file