diff --git a/core/src/main/kotlin/io/github/shaksternano/borgar/core/collect/CollectionUtil.kt b/core/src/main/kotlin/io/github/shaksternano/borgar/core/collect/CollectionUtil.kt index 055c7f26..f4cec42c 100644 --- a/core/src/main/kotlin/io/github/shaksternano/borgar/core/collect/CollectionUtil.kt +++ b/core/src/main/kotlin/io/github/shaksternano/borgar/core/collect/CollectionUtil.kt @@ -24,6 +24,11 @@ suspend fun Iterable.parallelForEach(action: suspend (T) -> Unit) { parallelMap(action) } +fun Iterable.indicesOf(element: T): List = + mapIndexedNotNull { index, element1 -> + index.takeIf { element1 == element } + } + fun MutableMap.putAllKeys(keys: Iterable, value: V) = keys.forEach { put(it, value) } diff --git a/core/src/main/kotlin/io/github/shaksternano/borgar/core/io/IOUtil.kt b/core/src/main/kotlin/io/github/shaksternano/borgar/core/io/IOUtil.kt index f0f91acd..ab315c15 100644 --- a/core/src/main/kotlin/io/github/shaksternano/borgar/core/io/IOUtil.kt +++ b/core/src/main/kotlin/io/github/shaksternano/borgar/core/io/IOUtil.kt @@ -30,9 +30,13 @@ import java.nio.file.StandardOpenOption import java.util.regex.Pattern import kotlin.io.path.* import kotlin.io.use +import kotlin.random.Random +import kotlin.random.nextULong private object IOUtil +private val TEMP_DIR: Path = Path(System.getProperty("java.io.tmpdir")) + val ALLOWED_DOMAINS: Set = setOf( "raw.githubusercontent.com", "cdn.discordapp.com", @@ -56,6 +60,23 @@ suspend fun createTemporaryFile(filenameWithoutExtension: String, extension: Str return path } +suspend fun getTemporaryFile(filename: String): Path = getTemporaryFile( + filenameWithoutExtension(filename), + fileExtension(filename), +) + +suspend fun getTemporaryFile(filenameWithoutExtension: String, extension: String): Path { + val extension1 = extension.ifBlank { "tmp" } + var filename = "" + var path = TEMP_DIR.resolve(filename) + while (filename.isBlank() || withContext(Dispatchers.IO) { path.exists() }) { + filename = filenameWithoutExtension + Random.nextULong() + "." + extension1 + path = TEMP_DIR.resolve(filename) + } + path.toFile().deleteOnExit() + return path +} + suspend fun getResource(resourcePath: String): InputStream = withContext(Dispatchers.IO) { IOUtil.javaClass.classLoader.getResourceAsStream(resourcePath) @@ -211,9 +232,8 @@ fun removeQueryParams(url: String): String = url.split('?').first() fun filename(filePath: String): String { - val noQueryParams = removeQueryParams(filePath) - val nameWithoutExtension = Files.getNameWithoutExtension(noQueryParams) - val extension = Files.getFileExtension(noQueryParams) + val nameWithoutExtension = filenameWithoutExtension(filePath) + val extension = fileExtension(filePath) return filename(nameWithoutExtension, extension) } @@ -258,3 +278,15 @@ suspend fun InputStream.readNBytesSuspend(n: Int): ByteArray = withContext(Dispa suspend fun InputStream.skipNBytesSuspend(n: Long) = withContext(Dispatchers.IO) { skipNBytes(n) } + +suspend inline fun DataSource.useFile(block: (FileDataSource) -> R): R { + val isInputTemp = path == null + val fileInput = getOrWriteFile() + return try { + block(fileInput) + } finally { + if (isInputTemp) { + fileInput.path.deleteSilently() + } + } +} diff --git a/core/src/main/kotlin/io/github/shaksternano/borgar/core/io/task/FFmpegTask.kt b/core/src/main/kotlin/io/github/shaksternano/borgar/core/io/task/FFmpegTask.kt new file mode 100644 index 00000000..cabf8b4f --- /dev/null +++ b/core/src/main/kotlin/io/github/shaksternano/borgar/core/io/task/FFmpegTask.kt @@ -0,0 +1,51 @@ +package io.github.shaksternano.borgar.core.io.task + +import io.github.shaksternano.borgar.core.collect.indicesOf +import io.github.shaksternano.borgar.core.exception.ErrorResponseException +import io.github.shaksternano.borgar.core.io.* +import io.github.shaksternano.borgar.core.util.splitWords +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bytedeco.ffmpeg.ffmpeg +import org.bytedeco.javacpp.Loader +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString + +private val FFMPEG_PATH: String = Loader.load(ffmpeg::class.java) + +class FFmpegTask( + private val arguments: String, +) : MappedFileTask() { + + override suspend fun process(input: DataSource): DataSource = input.useFile { fileInput -> + val inputPath = fileInput.path + val splitArguments = arguments.splitWords() + val inputIndices = splitArguments.indicesOf("-i") + val ffmpegArguments = splitArguments.filterIndexed { index, _ -> + (index !in inputIndices && index - 1 !in inputIndices) + }.ifEmpty { + listOf(inputPath.toString()) + } + val output = ffmpegArguments.last() + if (fileExtension(output).isBlank()) { + throw ErrorResponseException("Output filename must have an extension!") + } + val outputFilename = Path(output).filename + val outputPath = getTemporaryFile(outputFilename) + outputPath.toFile().deleteOnExit() + val ffmpegCommand = listOf(FFMPEG_PATH, "-i", inputPath.absolutePathString()) + + ffmpegArguments.subList(0, ffmpegArguments.lastIndex) + + outputPath.absolutePathString() + val processBuilder = ProcessBuilder(ffmpegCommand) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + withContext(Dispatchers.IO) { + val process = processBuilder.start() + val exitCode = process.waitFor() + if (exitCode != 0) { + throw ErrorResponseException("FFmpeg command failed!") + } + } + DataSource.fromFile(outputPath, outputFilename) + } +} diff --git a/core/src/main/kotlin/io/github/shaksternano/borgar/core/media/MediaProcessing.kt b/core/src/main/kotlin/io/github/shaksternano/borgar/core/media/MediaProcessing.kt index ed717062..401600e7 100644 --- a/core/src/main/kotlin/io/github/shaksternano/borgar/core/media/MediaProcessing.kt +++ b/core/src/main/kotlin/io/github/shaksternano/borgar/core/media/MediaProcessing.kt @@ -83,41 +83,32 @@ suspend fun processMedia( input: DataSource, config: MediaProcessingConfig, maxFileSize: Long, -): FileDataSource { - val isTempFile = input.path == null - val fileInput = input.getOrWriteFile() - val path = fileInput.path - return try { - val inputFormat = input.fileFormat().ifBlank { "mp4" } - val imageReader = createImageReader(fileInput, inputFormat) - val audioReader = createAudioReader(fileInput, inputFormat) - val supportedInputFormat = - if (isReaderFormatSupported(inputFormat) && !isWriterFormatSupported(inputFormat)) - if (imageReader.frameCount == 1) STATIC_FORMAT_MAPPING[inputFormat] ?: "png" - else ANIMATED_FORMAT_MAPPING[inputFormat] ?: "mp4" - else - inputFormat - val outputFormat = config.transformOutputFormat(supportedInputFormat) - val outputName = config.outputName.ifBlank { - fileInput.filenameWithoutExtension - } - val output = processMedia( - config.transformImageReader(imageReader, outputFormat), - config.transformAudioReader(audioReader, outputFormat), - createTemporaryFile(outputName, outputFormat), - outputFormat, - maxFileSize, - ) - val filename = filename(outputName, outputFormat) - DataSource.fromFile( - output, - filename, - ) - } finally { - if (isTempFile) { - path.deleteSilently() - } +): FileDataSource = input.useFile { fileInput -> + val inputFormat = input.fileFormat().ifBlank { "mp4" } + val imageReader = createImageReader(fileInput, inputFormat) + val audioReader = createAudioReader(fileInput, inputFormat) + val supportedInputFormat = + if (isReaderFormatSupported(inputFormat) && !isWriterFormatSupported(inputFormat)) + if (imageReader.frameCount == 1) STATIC_FORMAT_MAPPING[inputFormat] ?: "png" + else ANIMATED_FORMAT_MAPPING[inputFormat] ?: "mp4" + else + inputFormat + val outputFormat = config.transformOutputFormat(supportedInputFormat) + val outputName = config.outputName.ifBlank { + fileInput.filenameWithoutExtension } + val output = processMedia( + config.transformImageReader(imageReader, outputFormat), + config.transformAudioReader(audioReader, outputFormat), + createTemporaryFile(outputName, outputFormat), + outputFormat, + maxFileSize, + ) + val filename = filename(outputName, outputFormat) + DataSource.fromFile( + output, + filename, + ) } private suspend fun processMedia( diff --git a/core/src/main/kotlin/io/github/shaksternano/borgar/core/util/StringUtil.kt b/core/src/main/kotlin/io/github/shaksternano/borgar/core/util/StringUtil.kt index f01446c6..9ebc0783 100644 --- a/core/src/main/kotlin/io/github/shaksternano/borgar/core/util/StringUtil.kt +++ b/core/src/main/kotlin/io/github/shaksternano/borgar/core/util/StringUtil.kt @@ -50,7 +50,7 @@ fun CharSequence.endOfWord(startIndex: Int): Int { } fun CharSequence.splitWords(limit: Int = 0): List = - split(WHITE_SPACE_REGEX, limit) + split(WHITE_SPACE_REGEX, limit).filter { it.isNotBlank() } fun String.splitChunks(limit: Int): List = SplitUtil.split( this, diff --git a/messaging/src/main/kotlin/io/github/shaksternano/borgar/messaging/command/Commands.kt b/messaging/src/main/kotlin/io/github/shaksternano/borgar/messaging/command/Commands.kt index 9187b803..c5724f1b 100644 --- a/messaging/src/main/kotlin/io/github/shaksternano/borgar/messaging/command/Commands.kt +++ b/messaging/src/main/kotlin/io/github/shaksternano/borgar/messaging/command/Commands.kt @@ -57,6 +57,7 @@ val COMMANDS: Map = registerCommands( GuildBannerCommand, GuildSplashCommand, DownloadCommand, + FFmpegCommand, CatCommand.CAT, CatCommand.CAT_BOMB, DerpibooruCommand.DERPIBOORU, diff --git a/messaging/src/main/kotlin/io/github/shaksternano/borgar/messaging/command/FFmpegCommand.kt b/messaging/src/main/kotlin/io/github/shaksternano/borgar/messaging/command/FFmpegCommand.kt new file mode 100644 index 00000000..592ee131 --- /dev/null +++ b/messaging/src/main/kotlin/io/github/shaksternano/borgar/messaging/command/FFmpegCommand.kt @@ -0,0 +1,23 @@ +package io.github.shaksternano.borgar.messaging.command + +import io.github.shaksternano.borgar.core.io.task.FFmpegTask +import io.github.shaksternano.borgar.core.io.task.FileTask +import io.github.shaksternano.borgar.messaging.event.CommandEvent + +object FFmpegCommand : FileCommand( + CommandArgumentInfo( + key = "arguments", + description = "The FFmpeg arguments.", + type = CommandArgumentType.String, + required = false, + ), +) { + + override val name: String = "ffmpeg" + override val description: String = "Runs an FFmpeg command. The input is specified automatically." + + override suspend fun createTask(arguments: CommandArguments, event: CommandEvent, maxFileSize: Long): FileTask { + val ffmpegArguments = arguments.getDefaultStringOrEmpty() + return FFmpegTask(ffmpegArguments) + } +}