diff --git a/.gitignore b/.gitignore index 2afa612..292fe61 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ src/main/resources/application-local.properties src/main/resources/static !src/main/resources/static/index.html + +# mac +.DS_Store \ No newline at end of file diff --git a/src/main/kotlin/com/pischule/memevizor/bot/BotConfiguration.kt b/src/main/kotlin/com/pischule/memevizor/bot/BotConfiguration.kt index c28908c..c7b8fa9 100644 --- a/src/main/kotlin/com/pischule/memevizor/bot/BotConfiguration.kt +++ b/src/main/kotlin/com/pischule/memevizor/bot/BotConfiguration.kt @@ -3,18 +3,14 @@ package com.pischule.memevizor.bot import com.github.kotlintelegrambot.Bot import com.github.kotlintelegrambot.bot import com.github.kotlintelegrambot.dispatch -import com.github.kotlintelegrambot.dispatcher.command +import com.github.kotlintelegrambot.dispatcher.* import com.github.kotlintelegrambot.dispatcher.handlers.media.MediaHandlerEnvironment -import com.github.kotlintelegrambot.dispatcher.message -import com.github.kotlintelegrambot.dispatcher.photos -import com.github.kotlintelegrambot.dispatcher.video import com.github.kotlintelegrambot.entities.ChatId import com.github.kotlintelegrambot.entities.Message import com.github.kotlintelegrambot.entities.ParseMode import com.pischule.memevizor.bot.handler.MediaHandlerService import com.pischule.memevizor.bot.handler.ThisCommandHandlerService -import com.pischule.memevizor.util.getMaxResPhotoId -import com.pischule.memevizor.util.getVideoFileId +import com.pischule.memevizor.util.getMedia import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.withLoggingContext import org.springframework.boot.context.properties.EnableConfigurationProperties @@ -40,19 +36,12 @@ class BotConfiguration( } private fun Bot.Builder.setupDispatchers() = dispatch { - message { - withLoggingContext(messageContext(message)) { - try { - thisCommandHandlerService.create(this) - } catch (e: Error) { - logger.error(e) { "Error while handling message" } - } - } - } + message { withLogAndErrorHandling(message) { thisCommandHandlerService.create(this) } } photos { handleMedia() } video { handleMedia() } + videoNote { handleMedia() } command("whoami") { - withLoggingContext(messageContext(message)) { + withLogAndErrorHandling(message) { bot.sendMessage( chatId = ChatId.fromId(message.chat.id), text = "chatId: `${message.chat.id}`\nuserId: `${message.from?.id}`", @@ -64,21 +53,22 @@ class BotConfiguration( } private suspend fun MediaHandlerEnvironment<*>.handleMedia() { - withLoggingContext(messageContext(message)) { - try { - mediaHandlerService.create(this) - } catch (e: Error) { - logger.error(e) { "Error while handling photo" } - } - } + withLogAndErrorHandling(message) { mediaHandlerService.create(this) } } - private fun messageContext(message: Message): Map = - mapOf( + private suspend fun withLogAndErrorHandling(message: Message, block: suspend () -> Unit) { + withLoggingContext( "message_id" to message.messageId.toString(), "chat_id" to message.chat.id.toString(), "from_user_id" to message.from?.id.toString(), "from_user_username" to message.from?.username.toString(), - "file_id" to (message.getMaxResPhotoId() ?: message.getVideoFileId()), - ) + "file_id" to (message.getMedia()?.fileId), + ) { + try { + block.invoke() + } catch (e: Exception) { + logger.error(e) { "Error while handling message" } + } + } + } } diff --git a/src/main/kotlin/com/pischule/memevizor/bot/handler/ThisCommandHandlerService.kt b/src/main/kotlin/com/pischule/memevizor/bot/handler/ThisCommandHandlerService.kt index de718dc..f2ac271 100644 --- a/src/main/kotlin/com/pischule/memevizor/bot/handler/ThisCommandHandlerService.kt +++ b/src/main/kotlin/com/pischule/memevizor/bot/handler/ThisCommandHandlerService.kt @@ -6,9 +6,8 @@ import com.github.kotlintelegrambot.entities.ChatId import com.github.kotlintelegrambot.entities.reaction.ReactionType import com.pischule.memevizor.bot.BotProps import com.pischule.memevizor.upload.FileUploaderService -import com.pischule.memevizor.util.getMaxResPhotoId -import com.pischule.memevizor.util.getVideoFileId -import com.pischule.memevizor.video.VideoEncoderService +import com.pischule.memevizor.util.* +import com.pischule.memevizor.video.VideoTranscoderService import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.withLoggingContext import org.springframework.stereotype.Component @@ -19,39 +18,30 @@ private val logger = KotlinLogging.logger {} class ThisCommandHandlerService( private val botProps: BotProps, private val fileUploaderService: FileUploaderService, - private val videoEncoderService: VideoEncoderService, + private val videoTranscoderService: VideoTranscoderService, ) { private val confirmCommands = listOf("this", "!soxok") + private val mediaFileName = "media" suspend fun create(env: MessageHandlerEnvironment) { if (!shouldHandleMessage(env)) return val replyToMessage = env.message.replyToMessage ?: return - - val imageFileId = replyToMessage.getMaxResPhotoId() - val videoFileId = replyToMessage.getVideoFileId() - val fileId = imageFileId ?: videoFileId ?: return + val media = replyToMessage.getMedia() ?: return env.bot.sendChatAction(ChatId.fromId(env.message.chat.id), ChatAction.TYPING) - withLoggingContext("file_id" to fileId) { - val fileBytes = env.bot.downloadFileBytes(fileId) ?: return + withLoggingContext("file_id" to media.fileId) { + val fileBytes = env.bot.downloadFileBytes(media.fileId) ?: return logger.info { "Downloaded a file from Telegram, size=${fileBytes.size}" } - val convertedBytes = - if (videoFileId != null) { - videoEncoderService.encodeToWebm(fileBytes) - } else { - fileBytes + val (convertedBytes, contentType) = + when (media.type) { + MessageMedia.Type.PHOTO -> Pair(fileBytes, "image/jpeg") + MessageMedia.Type.VIDEO -> + Pair(videoTranscoderService.transcode(fileBytes), "video/webm") } - val contentType = - if (videoFileId != null) { - "video/webm" - } else { - "image/jpeg" - } - - fileUploaderService.uploadFile(convertedBytes, "_", contentType) + fileUploaderService.uploadFile(convertedBytes, mediaFileName, contentType) reactToMessage(env, "👍") } @@ -61,11 +51,7 @@ class ThisCommandHandlerService( val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true val command = env.message.text?.lowercase() val isConfirmCommand = command in confirmCommands - val hasMediaReply = - env.message.replyToMessage?.let { - it.photo?.isNotEmpty() == true || it.video != null - } == true - return isApprover && isConfirmCommand && hasMediaReply + return isApprover && isConfirmCommand } private suspend fun reactToMessage(env: MessageHandlerEnvironment, emoji: String) { diff --git a/src/main/kotlin/com/pischule/memevizor/upload/FileUploaderService.kt b/src/main/kotlin/com/pischule/memevizor/upload/FileUploaderService.kt index e8a4cb7..f61ac00 100644 --- a/src/main/kotlin/com/pischule/memevizor/upload/FileUploaderService.kt +++ b/src/main/kotlin/com/pischule/memevizor/upload/FileUploaderService.kt @@ -1,6 +1,7 @@ package com.pischule.memevizor.upload import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.oshai.kotlinlogging.withLoggingContext import io.minio.MinioClient import io.minio.PutObjectArgs import org.springframework.boot.autoconfigure.condition.ConditionalOnBean @@ -12,15 +13,16 @@ private val logger = KotlinLogging.logger {} @Service class FileUploaderService(private val s3Client: MinioClient, private val s3Props: S3Props) { fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) { - logger.info { "Before upload" } - s3Client.putObject( - PutObjectArgs.builder() - .bucket(s3Props.bucket) - .`object`(filename) - .stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1) - .contentType(contentType) - .build() - ) - logger.info { "File $filename has been uploaded to S3" } + withLoggingContext("filename" to filename, "bucket" to s3Props.bucket) { + s3Client.putObject( + PutObjectArgs.builder() + .bucket(s3Props.bucket) + .`object`(filename) + .stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1) + .contentType(contentType) + .build() + ) + logger.info { "Uploaded a file to S3" } + } } } diff --git a/src/main/kotlin/com/pischule/memevizor/upload/IndexInitializer.kt b/src/main/kotlin/com/pischule/memevizor/upload/IndexInitializer.kt index 6cdc39f..b588189 100644 --- a/src/main/kotlin/com/pischule/memevizor/upload/IndexInitializer.kt +++ b/src/main/kotlin/com/pischule/memevizor/upload/IndexInitializer.kt @@ -15,7 +15,7 @@ class IndexInitializer(val fileUploaderService: FileUploaderService) { @EventListener fun applicationStartedHandler(event: ApplicationStartedEvent) { try { - val fileBytes = readResourceAsByteArray("/static/index.html") + val fileBytes = readResourceAsByteArray("static/index.html") fileUploaderService.uploadFile(fileBytes, "index.html", "text/html") } catch (e: Exception) { logger.warn(e) { "Failed to upload " } diff --git a/src/main/kotlin/com/pischule/memevizor/util/TelegramHelper.kt b/src/main/kotlin/com/pischule/memevizor/util/TelegramHelper.kt index 04765a5..8894b3e 100644 --- a/src/main/kotlin/com/pischule/memevizor/util/TelegramHelper.kt +++ b/src/main/kotlin/com/pischule/memevizor/util/TelegramHelper.kt @@ -2,6 +2,21 @@ package com.pischule.memevizor.util import com.github.kotlintelegrambot.entities.Message -fun Message.getMaxResPhotoId(): String? = photo?.lastOrNull()?.fileId +fun Message.getMedia(): MessageMedia? { + photo?.lastOrNull()?.fileId?.let { fileId -> + return MessageMedia(fileId, MessageMedia.Type.PHOTO) + } -fun Message.getVideoFileId(): String? = video?.fileId ?: videoNote?.fileId + (video?.fileId ?: videoNote?.fileId)?.let { fileId -> + return MessageMedia(fileId, MessageMedia.Type.VIDEO) + } + + return null +} + +data class MessageMedia(val fileId: String, val type: Type) { + enum class Type { + PHOTO, + VIDEO, + } +} diff --git a/src/main/kotlin/com/pischule/memevizor/video/VideoEncoderService.kt b/src/main/kotlin/com/pischule/memevizor/video/VideoEncoderService.kt deleted file mode 100644 index b7c465f..0000000 --- a/src/main/kotlin/com/pischule/memevizor/video/VideoEncoderService.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.pischule.memevizor.video - -import io.github.oshai.kotlinlogging.KotlinLogging -import kotlin.io.path.createTempFile -import kotlin.io.path.deleteIfExists -import kotlin.io.path.readBytes -import kotlin.io.path.writeBytes -import org.springframework.stereotype.Service - -private val logger = KotlinLogging.logger {} - -@Service -class VideoEncoderService { - - fun encodeToWebm(inputVideo: ByteArray): ByteArray { - logger.info { "Started encoding a file" } - - val inputFile = createTempFile() - val outputFile = createTempFile() - val command: List = - listOf( - "ffmpeg", - "-i", - "$inputFile", - // video - "-map", - "0:v", - "-c:v", - "libvpx-vp9", - "-cpu-used", - "6", - "-crf", - "40", - "-b:v", - "0k", - // file - "-f", - "webm", - "-y", - "$outputFile", - ) - - try { - inputFile.writeBytes(inputVideo) - - val process = ProcessBuilder(command).redirectErrorStream(true).start() - - val output = process.inputStream.bufferedReader().use { it.readText() } - val exitCode = process.waitFor() - - if (exitCode != 0) { - error("Video conversion failed: exit code $exitCode, $output") - } - - logger.info { "Converted" } - - return outputFile.readBytes() - } finally { - inputFile.deleteIfExists() - outputFile.deleteIfExists() - } - } -} diff --git a/src/main/kotlin/com/pischule/memevizor/video/VideoTranscoderService.kt b/src/main/kotlin/com/pischule/memevizor/video/VideoTranscoderService.kt new file mode 100644 index 0000000..d4e6086 --- /dev/null +++ b/src/main/kotlin/com/pischule/memevizor/video/VideoTranscoderService.kt @@ -0,0 +1,85 @@ +package com.pischule.memevizor.video + +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlin.io.path.createTempFile +import kotlin.io.path.deleteIfExists +import kotlin.io.path.readBytes +import kotlin.io.path.writeBytes +import kotlin.time.measureTime +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class VideoTranscoderService { + + fun transcode(inputVideo: ByteArray): ByteArray { + logger.info { "Started transcoding a file" } + + val inputFile = createTempFile() + val outputFile = createTempFile() + + val command: List = + listOf( + "ffmpeg", + "-nostdin", + "-nostats", + "-hide_banner", + // input + "-i", + "$inputFile", + // video + "-map", + "0:v", + "-c:v", + "libsvtav1", + "-preset", + "6", + "-crf", + "35", + "-svtav1-params", + "film-grain=10", + // audio + "-map", + "0:a?", + "-c:a", + "libopus", + "-b:a", + "96k", + "-vbr", + "on", + "-compression_level", + "10", + // output + "-f", + "webm", + "-y", + "$outputFile", + ) + + try { + inputFile.writeBytes(inputVideo) + + val process = ProcessBuilder(command).redirectErrorStream(true).start() + val exitCode: Int + val timeTaken = measureTime { exitCode = process.waitFor() } + val processOutput = process.inputStream.bufferedReader().use { it.readText() } + if (exitCode != 0) { + throw VideoTranscodingException(exitCode, processOutput) + } + + logger.atInfo { + message = "Finished transcoding a file" + payload = + mapOf( + "processOutput" to processOutput, + "timeTakenMs" to timeTaken.inWholeMilliseconds, + ) + } + return outputFile.readBytes() + } finally { + inputFile.deleteIfExists() + outputFile.deleteIfExists() + } + } +} diff --git a/src/main/kotlin/com/pischule/memevizor/video/VideoTranscodingException.kt b/src/main/kotlin/com/pischule/memevizor/video/VideoTranscodingException.kt new file mode 100644 index 0000000..67018c8 --- /dev/null +++ b/src/main/kotlin/com/pischule/memevizor/video/VideoTranscodingException.kt @@ -0,0 +1,4 @@ +package com.pischule.memevizor.video + +data class VideoTranscodingException(val ffmpegExitCode: Int, val ffmpegOutput: String) : + RuntimeException("Video transcoding failed. Code=$ffmpegOutput. Output=$ffmpegOutput") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9579573..23fe2d9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,2 @@ -spring.application.name=memevizor \ No newline at end of file +spring.application.name=memevizor +logging.structured.format.console=ecs \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index a74174f..dc77b3a 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -3,7 +3,9 @@ - Dynamic Media Refresh + + memevizor