commit f3184c286377280cecbd6c222438d60d47a31e95
parent 09ab95199d6b4190d60df3fd0b112ac4b0393168
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sun, 21 May 2023 17:26:32 +0200

feat(mediadownloader): mpeg dash support

Diffstat:
Mapp/build.gradle | 2+-
Aapp/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt | 12++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt | 13+++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt | 10++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mapp/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt | 26++++++++++++++++++++++++++
6 files changed, 129 insertions(+), 10 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle @@ -140,5 +140,5 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1' compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar') implementation 'com.google.code.gson:gson:2.10.1' - implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1' + implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1.LTS' } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.data.wrapper.impl.media.dash + +import me.rhunk.snapenhance.data.wrapper.AbstractWrapper + +class LongformVideoPlaylistItem(obj: Any?) : AbstractWrapper(obj) { + private val chapterList by lazy { + instanceNonNull().javaClass.declaredFields.first { it.type == List::class.java } + } + val chapters: List<SnapChapter> + get() = (chapterList.get(instanceNonNull()) as List<*>).map { SnapChapter(it) } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt @@ -0,0 +1,12 @@ +package me.rhunk.snapenhance.data.wrapper.impl.media.dash + +import me.rhunk.snapenhance.data.wrapper.AbstractWrapper + +class SnapChapter (obj: Any?) : AbstractWrapper(obj) { + val snapId by lazy { + instanceNonNull().javaClass.declaredFields.first { it.type == Long::class.javaPrimitiveType }.get(instanceNonNull()) as Long + } + val startTimeMs by lazy { + instanceNonNull().javaClass.declaredFields.filter { it.type == Long::class.javaPrimitiveType }[1].get(instanceNonNull()) as Long + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.data.wrapper.impl.media.dash + +import me.rhunk.snapenhance.data.wrapper.AbstractWrapper + +class SnapPlaylistItem (obj: Any?) : AbstractWrapper(obj) { + val snapId by lazy { + instanceNonNull().javaClass.declaredFields.first { it.type == Long::class.javaPrimitiveType }.get(instanceNonNull()) as Long + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -14,6 +14,8 @@ import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo +import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistItem +import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap import me.rhunk.snapenhance.features.Feature @@ -27,32 +29,37 @@ import me.rhunk.snapenhance.util.EncryptionUtils import me.rhunk.snapenhance.util.MediaDownloaderHelper import me.rhunk.snapenhance.util.MediaType import me.rhunk.snapenhance.util.PreviewUtils +import me.rhunk.snapenhance.util.download.CdnDownloader import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.protobuf.ProtoReader -import java.io.* +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.nio.file.Paths -import java.util.* +import java.util.Arrays import java.util.concurrent.atomic.AtomicReference import javax.crypto.Cipher import javax.crypto.CipherInputStream +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult import kotlin.io.path.inputStream class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null private var lastSeenMapParams: ParamMap? = null - private var isFFmpegPresent: Boolean? = null + private val isFFmpegPresent by lazy { + runCatching { FFmpegKit.execute("-version") }.isSuccess + } private fun canMergeOverlay(): Boolean { if (!context.config.bool(ConfigProperty.OVERLAY_MERGE)) return false - if (isFFmpegPresent != null) { - return isFFmpegPresent!! - } - //check if ffmpeg is correctly installed - isFFmpegPresent = runCatching { FFmpegKit.execute("-version") }.isSuccess - return isFFmpegPresent!! + return isFFmpegPresent } private fun createNewFilePath(hash: Int, author: String, fileType: FileType): String? { @@ -229,11 +236,62 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam "[^\\x00-\\x7F]".toRegex(), "") downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName") + return } //spotlight if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || context.config.bool(ConfigProperty.AUTO_DOWNLOAD_SPOTLIGHT))) { downloadOperaMedia(mediaInfoMap, "Spotlight") + return + } + + //stories with mpeg dash media + //TODO: option to download multiple chapters + if (paramMap.containsKey("SNAP_PLAYLIST_ITEM") && forceDownload) { + if (!isFFmpegPresent) { + context.shortToast("Can't download media. ffmpeg was not found") + return + } + + val storyName = paramMap["STORY_NAME"].toString().replace( + "[^\\x00-\\x7F]".toRegex(), + "") + + //get the position of the media in the playlist and the duration + val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) + val snapChapterList = LongformVideoPlaylistItem(paramMap["LONGFORM_VIDEO_PLAYLIST_ITEM"]!!).chapters + if (snapChapterList.isEmpty()) { + context.shortToast("No chapters found") + return + } + val snapChapter = snapChapterList.first { it.snapId == snapItem.snapId } + val nextChapter = snapChapterList.getOrNull(snapChapterList.indexOf(snapChapter) + 1) + + //add 100ms to the start time to prevent the video from starting too early + val snapChapterTimestamp = snapChapter.startTimeMs.plus(100) + val duration = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) ?: 0 + + //get the mpd playlist and append the cdn url to baseurl nodes + val playlistUrl = paramMap["MEDIA_ID"].toString().let { it.substring(it.indexOf("https://cf-st.sc-cdn.net")) } + val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(URL(playlistUrl).openStream()) + val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") + for (i in 0 until baseUrlNodeList.length) { + val baseUrlNode = baseUrlNodeList.item(i) + val baseUrl = baseUrlNode.textContent + baseUrlNode.textContent = "${CdnDownloader.CF_ST_CDN_D}$baseUrl" + } + + val xmlData = ByteArrayOutputStream() + TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) + runCatching { + context.shortToast("Downloading dash media. This might take a while...") + val downloadedMedia = MediaDownloaderHelper.downloadDashChapter(xmlData.toByteArray().toString(Charsets.UTF_8), snapChapterTimestamp, duration) + downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), "Pro-Stories/${storyName}", FileType.fromByteArray(downloadedMedia)) + }.onFailure { + context.longToast("Failed to download media: ${it.message}") + xposedLog(it) + } + return } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt @@ -49,6 +49,32 @@ object MediaDownloaderHelper { return mapOf(MediaType.ORIGINAL to content) } + fun downloadDashChapter(playlistXmlData: String, startTime: Long, duration: Long?): ByteArray { + val outputFile = File.createTempFile("output", ".mp4") + val playlistFile = File.createTempFile("playlist", ".mpd").also { + with(FileOutputStream(it)) { + write(playlistXmlData.toByteArray(Charsets.UTF_8)) + close() + } + } + + val ffmpegSession = FFmpegKit.execute( + "-y -i " + + playlistFile.absolutePath + + " -ss '${startTime}ms'" + + (if (duration != null) " -t '${duration}ms'" else "") + + " -c:v libx264 -threads 6 -q:v 13 " + outputFile.absolutePath + ) + + playlistFile.delete() + if (!ffmpegSession.returnCode.isValueSuccess) { + throw Exception(ffmpegSession.output) + } + val outputData = FileInputStream(outputFile).readBytes() + outputFile.delete() + return outputData + } + fun mergeOverlay(original: ByteArray, overlay: ByteArray, isPreviewMode: Boolean): ByteArray { val originalFileType = FileType.fromByteArray(original) val overlayFileType = FileType.fromByteArray(overlay)