From a3feec702ec2a497a8ded856f788454f5b908a8a Mon Sep 17 00:00:00 2001 From: General_K1ng Date: Sat, 9 May 2026 02:33:28 +0800 Subject: [PATCH 1/3] feat: Updated mediamp dependency from 0.1.8 to 0.1.9 --- app/android/build.gradle.kts | 3 +- gradle/libs.versions.toml | 2 +- .../session/AnitorrentDownloadSession.kt | 1 + .../commonMain/kotlin/KtorHttpDownloader.kt | 66 ++++--------------- .../kotlin/KtorHttpDownloaderTest.kt | 32 ++++----- 5 files changed, 32 insertions(+), 72 deletions(-) diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index 16be31ca9a..03540fcfe9 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -95,7 +95,8 @@ android { merges.add("META-INF/DEPENDENCIES") // log4j pickFirsts.add("META-INF/LICENSE.md") pickFirsts.add("META-INF/LICENSE-notice.md") - + // ffmpeg & javacpp android jars both ship native-image configs at the same paths + pickFirsts.add("META-INF/native-image/**") } } buildTypes { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dcf9db4cba..1b84d2b4d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,7 +87,7 @@ google-firebase = "22.0.1" posthog-android = "3.11.3" posthog-java = "1.2.0" openapi-generator = "7.13.0" -mediamp = "0.1.8" +mediamp = "0.1.9" compose-native-tray = "1.3.0" [plugins] diff --git a/torrent/anitorrent/src/commonMain/kotlin/session/AnitorrentDownloadSession.kt b/torrent/anitorrent/src/commonMain/kotlin/session/AnitorrentDownloadSession.kt index d6720f4edc..c96609fb00 100644 --- a/torrent/anitorrent/src/commonMain/kotlin/session/AnitorrentDownloadSession.kt +++ b/torrent/anitorrent/src/commonMain/kotlin/session/AnitorrentDownloadSession.kt @@ -69,6 +69,7 @@ import me.him188.ani.utils.platform.currentTimeMillis import org.openani.mediamp.io.SeekableInput import kotlin.concurrent.Volatile import kotlin.coroutines.CoroutineContext +import kotlin.jvm.JvmField import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds diff --git a/utils/http-downloader/src/commonMain/kotlin/KtorHttpDownloader.kt b/utils/http-downloader/src/commonMain/kotlin/KtorHttpDownloader.kt index b55287cd84..11216df58c 100644 --- a/utils/http-downloader/src/commonMain/kotlin/KtorHttpDownloader.kt +++ b/utils/http-downloader/src/commonMain/kotlin/KtorHttpDownloader.kt @@ -73,15 +73,13 @@ import me.him188.ani.utils.io.length import me.him188.ani.utils.io.resolve import me.him188.ani.utils.io.writeText import me.him188.ani.utils.ktor.ScopedHttpClient -import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger -import me.him188.ani.utils.logging.trace -import me.him188.ani.utils.logging.warn import me.him188.ani.utils.platform.Uuid -import org.openani.mediamp.ffmpeg.FFmpegKit import org.openani.mediamp.ffmpeg.FFmpegResult +import org.openani.mediamp.ffmpeg.MediaOperation +import org.openani.mediamp.ffmpeg.MediaTranscoder import kotlin.concurrent.atomics.AtomicLong import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.CoroutineContext @@ -126,10 +124,6 @@ open class KtorHttpDownloader( override val downloadStatesFlow: Flow> = _downloadStatesFlow.map { it.values.map { entry -> entry.state } } - private val ffmpegKit by lazy { FFmpegKit() } - private var ffmpegLogHandlerSet = false - private val ffmpegLogHandlerLock = Mutex() - override suspend fun init() { // No initialization needed, but place for potential future logic logger.info { "KtorHttpDownloader initialized." } @@ -1000,29 +994,23 @@ open class KtorHttpDownloader( // mark open for tests, don't override open suspend fun mergeM3u8Segments(st: DownloadState, cacheDir: Path, finalOutput: Path) { - setFFmpegKitLogHandler() val localPlaylistFile = createLocalHlsPlaylist(st, cacheDir).inSystem - val ffmpegArgs = listOf( - "-y", "-nostdin", - "-allowed_extensions", "ALL", - "-protocol_whitelist", "file,crypto,data", - "-i", localPlaylistFile.absolutePath, - "-map", "0", - "-c", "copy", - "-bsf:a", "aac_adtstoasc", - "-movflags", - "+faststart", - finalOutput.inSystem.absolutePath, + val operation = MediaOperation.Remux( + input = localPlaylistFile.absolutePath, + output = finalOutput.inSystem.absolutePath, + allowedExtensions = "ALL", + protocolWhitelist = "file,crypto,data", + movflags = listOf("faststart"), ) logger.info { - "Running FFmpeg merge for ${st.downloadId}: ffmpeg ${ffmpegArgs.joinToString(" ") { it.quoteForLog() }}" + "Running FFmpeg merge for ${st.downloadId}: $operation" } if (finalOutput.inSystem.exists()) { fileSystem.delete(finalOutput) } - val result = executeFfmpeg(ffmpegArgs) + val result = executeMediaOperation(operation) if (!result.isSuccess) { if (finalOutput.inSystem.exists()) { fileSystem.delete(finalOutput) @@ -1041,45 +1029,15 @@ open class KtorHttpDownloader( } } - protected open suspend fun executeFfmpeg(args: List): FFmpegResult { - return ffmpegKit.execute(args) + protected open suspend fun executeMediaOperation(operation: MediaOperation): FFmpegResult { + return MediaTranscoder().execute(operation) } - private fun String.quoteForLog(): String = - if (isEmpty() || any { it.isWhitespace() || it == '"' || it == '\'' }) { - "\"" + replace("\\", "\\\\").replace("\"", "\\\"") + "\"" - } else { - this - } - private fun DownloadState.finalOutputRelativePath(): String { if (mediaType != MediaType.M3U8) return relativeOutputPath return relativeOutputPath.replaceAfterLast('.', "mp4", missingDelimiterValue = "$relativeOutputPath.mp4") } - private suspend fun setFFmpegKitLogHandler() { - if (ffmpegLogHandlerSet) return - ffmpegLogHandlerLock.withLock { - if (ffmpegLogHandlerSet) return - FFmpegKit.setLogHandler { msg -> - // See -loglevel argument in https://ffmpeg.org/ffmpeg.html - if (msg.isError) { - logger.error { "[FFmpeg:${msg.level}] ${msg.line}" } - } else if (msg.level >= 48) { - logger.trace { "[FFmpeg:${msg.level}] ${msg.line}" } - } else if (msg.level >= 40) { - logger.debug { "[FFmpeg:${msg.level}] ${msg.line}" } - } else if (msg.level >= 32) { - logger.info { "[FFmpeg:${msg.level}] ${msg.line}" } - } else if (msg.level >= 24) { - logger.warn { "[FFmpeg:${msg.level}] ${msg.line}" } - } else { - logger.error { "[FFmpeg:${msg.level}] ${msg.line}" } - } - } - } - } - protected suspend fun emitProgress(downloadId: DownloadId) { val st = getState(downloadId) ?: return val progress = createProgress(st) diff --git a/utils/http-downloader/src/commonTest/kotlin/KtorHttpDownloaderTest.kt b/utils/http-downloader/src/commonTest/kotlin/KtorHttpDownloaderTest.kt index d5828f1869..e3082a0866 100644 --- a/utils/http-downloader/src/commonTest/kotlin/KtorHttpDownloaderTest.kt +++ b/utils/http-downloader/src/commonTest/kotlin/KtorHttpDownloaderTest.kt @@ -47,6 +47,7 @@ import me.him188.ani.utils.io.deleteRecursively import me.him188.ani.utils.io.resolve import me.him188.ani.utils.ktor.asScopedHttpClient import org.openani.mediamp.ffmpeg.FFmpegResult +import org.openani.mediamp.ffmpeg.MediaOperation import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.test.AfterTest @@ -72,7 +73,7 @@ class KtorHttpDownloaderTest { private lateinit var tempDir: String private lateinit var downloader: KtorHttpDownloader private val fileSystem = SystemFileSystem - private var lastFfmpegArgs: List? = null + private var lastMediaOperation: MediaOperation? = null private var lastInputPlaylistContent: String? = null private var forceFfmpegFailure = false @@ -97,7 +98,7 @@ class KtorHttpDownloaderTest { if (!fileSystem.exists(Path("$tempDir/persistence"))) { fileSystem.createDirectories(Path("$tempDir/persistence")) } - lastFfmpegArgs = null + lastMediaOperation = null lastInputPlaylistContent = null forceFfmpegFailure = false @@ -291,15 +292,13 @@ class KtorHttpDownloaderTest { parentScope = CoroutineScope(SupervisorJob() + testDispatcher), ioDispatcher = testDispatcher, ) { - override suspend fun executeFfmpeg(args: List): FFmpegResult { - lastFfmpegArgs = args - val inputIndex = args.indexOf("-i") - if (inputIndex >= 0 && inputIndex + 1 < args.size) { - val inputPath = Path(args[inputIndex + 1]) - if (fileSystem.exists(inputPath)) { - lastInputPlaylistContent = fileSystem.read(inputPath) { - readByteArray().decodeToString() - } + override suspend fun executeMediaOperation(operation: MediaOperation): FFmpegResult { + lastMediaOperation = operation + val remux = operation as? MediaOperation.Remux ?: return FFmpegResult(exitCode = 1) + val inputPath = Path(remux.input) + if (fileSystem.exists(inputPath)) { + lastInputPlaylistContent = fileSystem.read(inputPath) { + readByteArray().decodeToString() } } @@ -307,7 +306,7 @@ class KtorHttpDownloaderTest { return FFmpegResult(exitCode = 1) } - val outputPath = Path(args.last()) + val outputPath = Path(remux.output) if (lastInputPlaylistContent.orEmpty().contains("#EXT-X-KEY:")) { writeBytes( outputPath, @@ -318,7 +317,6 @@ class KtorHttpDownloaderTest { ) + ByteArray(16), ) } else { - val inputPath = Path(args[inputIndex + 1]) val inputDir = inputPath.parent ?: error("Missing parent dir for $inputPath") fileSystem.sink(outputPath).buffered().use { out -> lastInputPlaylistContent.orEmpty().lines() @@ -442,8 +440,9 @@ class KtorHttpDownloaderTest { // New: check final file size (segment1 + segment2 + segment3 => 1024 + 2048 + 3072 = 6144) val outputFileSize = fileSystem.metadata(Path("$tempDir/output.mp4")).size assertEquals(1024 + 2048 + 3072, outputFileSize, "M3U8 final output file size mismatch.") - assertTrue(lastFfmpegArgs?.contains("-allowed_extensions") == true) - assertTrue(lastFfmpegArgs?.contains("-protocol_whitelist") == true) + val remux = lastMediaOperation as? MediaOperation.Remux + assertEquals("ALL", remux?.allowedExtensions) + assertEquals("file,crypto,data", remux?.protocolWhitelist) assertTrue(lastInputPlaylistContent?.contains("0.ts") == true) } @@ -703,7 +702,8 @@ class KtorHttpDownloaderTest { parentScope = CoroutineScope(SupervisorJob() + testDispatcher), ioDispatcher = testDispatcher, ) { - override suspend fun executeFfmpeg(args: List): FFmpegResult = FFmpegResult(exitCode = 0) + override suspend fun executeMediaOperation(operation: MediaOperation): FFmpegResult = + FFmpegResult(exitCode = 0) suspend fun seedState(downloadId: DownloadId, status: DownloadStatus) { val state = DownloadState( From e8b609faefdf5113a2604efabe5d32a72ef8b104 Mon Sep 17 00:00:00 2001 From: General_K1ng Date: Sat, 9 May 2026 03:06:19 +0800 Subject: [PATCH 2/3] chore(proguard): add dontwarn rules for JVM-only classes on Android --- app/shared/proguard-rules.pro | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/shared/proguard-rules.pro b/app/shared/proguard-rules.pro index 36bfc85d6f..abbe9d1004 100644 --- a/app/shared/proguard-rules.pro +++ b/app/shared/proguard-rules.pro @@ -46,3 +46,8 @@ -renamesourcefileattribute SourceFile -keepnames class me.him188.ani.** { *; } -keepnames class ** { *; } # Keep all names as this only increases pacakge size by a few MBs, but significantly helps with debugging. + +# JavaCPP references JVM-only management/OSGi classes not present on Android +-dontwarn java.lang.management.BufferPoolMXBean +-dontwarn javax.management.** +-dontwarn org.osgi.annotation.versioning.ConsumerType From f7e1bc55078c2d9707da73164d5efb3469170ef0 Mon Sep 17 00:00:00 2001 From: General_K1ng Date: Sat, 9 May 2026 22:58:52 +0800 Subject: [PATCH 3/3] build(desktop): add native library dependencies for JNI and media processing --- app/desktop/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index 5655b2efc0..775b8e22b6 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -311,6 +311,9 @@ tasks.withType(AbstractJPackageTask::class) { val jarsToUnpack = listOf( "anitorrent-native", "anitorrent-native-desktop", + "javacpp", // JavaCPP JNI bridge DLLs (Windows/Linux) + "ffmpeg", // FFmpeg native DLLs (.so/.dylib/.dll) from bytedeco classifier jars + "mediamp-ffmpeg-runtime", ) destinationDir.get().asFile.walk().filter { file ->