Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions app/desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
5 changes: 5 additions & 0 deletions app/shared/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
66 changes: 12 additions & 54 deletions utils/http-downloader/src/commonMain/kotlin/KtorHttpDownloader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,10 +124,6 @@ open class KtorHttpDownloader(
override val downloadStatesFlow: Flow<List<DownloadState>> =
_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." }
Expand Down Expand Up @@ -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"
}
Comment on lines 1005 to 1007

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)
Expand All @@ -1041,45 +1029,15 @@ open class KtorHttpDownloader(
}
}

protected open suspend fun executeFfmpeg(args: List<String>): 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -72,7 +73,7 @@ class KtorHttpDownloaderTest {
private lateinit var tempDir: String
private lateinit var downloader: KtorHttpDownloader
private val fileSystem = SystemFileSystem
private var lastFfmpegArgs: List<String>? = null
private var lastMediaOperation: MediaOperation? = null
private var lastInputPlaylistContent: String? = null
private var forceFfmpegFailure = false

Expand All @@ -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

Expand Down Expand Up @@ -291,23 +292,21 @@ class KtorHttpDownloaderTest {
parentScope = CoroutineScope(SupervisorJob() + testDispatcher),
ioDispatcher = testDispatcher,
) {
override suspend fun executeFfmpeg(args: List<String>): 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()
}
}

if (forceFfmpegFailure) {
return FFmpegResult(exitCode = 1)
}

val outputPath = Path(args.last())
val outputPath = Path(remux.output)
if (lastInputPlaylistContent.orEmpty().contains("#EXT-X-KEY:")) {
writeBytes(
outputPath,
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Comment on lines +443 to +445
assertTrue(lastInputPlaylistContent?.contains("0.ts") == true)
}

Expand Down Expand Up @@ -703,7 +702,8 @@ class KtorHttpDownloaderTest {
parentScope = CoroutineScope(SupervisorJob() + testDispatcher),
ioDispatcher = testDispatcher,
) {
override suspend fun executeFfmpeg(args: List<String>): FFmpegResult = FFmpegResult(exitCode = 0)
override suspend fun executeMediaOperation(operation: MediaOperation): FFmpegResult =
FFmpegResult(exitCode = 0)

suspend fun seedState(downloadId: DownloadId, status: DownloadStatus) {
val state = DownloadState(
Expand Down
Loading