fix(media_downloader): story voice note reply

- refactor media author and download source
- optimize download section
This commit is contained in:
rhunk
2023-09-01 11:50:42 +02:00
parent ea6260463c
commit 5776d44111
15 changed files with 170 additions and 139 deletions

View File

@ -388,11 +388,12 @@
"conversation_info": "\uD83D\uDC64 Conversation Info"
},
"path_format": {
"create_user_folder": "Create folder for each user",
"create_author_folder": "Create folder for each author",
"create_source_folder": "Create folder for each media source type",
"append_hash": "Add a unique hash to the file name",
"append_source": "Add the media source to the file name",
"append_username": "Add the username to the file name",
"append_date_time": "Add the date and time to the file name",
"append_type": "Add the media type to the file name"
"append_date_time": "Add the date and time to the file name"
},
"auto_download_sources": {
"friend_snaps": "Friend Snaps",

View File

@ -14,11 +14,12 @@ class DownloaderConfig : ConfigContainer() {
)
val preventSelfAutoDownload = boolean("prevent_self_auto_download")
val pathFormat = multiple("path_format",
"create_user_folder",
"create_author_folder",
"create_source_folder",
"append_hash",
"append_source",
"append_username",
"append_date_time",
"append_type",
"append_username"
).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) }
val allowDuplicate = boolean("allow_duplicate")
val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) }

View File

@ -1,182 +0,0 @@
package me.rhunk.snapenhance.core.download
import android.annotation.SuppressLint
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.core.download.data.DownloadMetadata
import me.rhunk.snapenhance.core.download.data.DownloadObject
import me.rhunk.snapenhance.core.download.data.DownloadStage
import me.rhunk.snapenhance.core.download.data.MediaFilter
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.ktx.getIntOrNull
import me.rhunk.snapenhance.util.ktx.getStringOrNull
class DownloadTaskManager {
private lateinit var taskDatabase: SQLiteDatabase
private val pendingTasks = mutableMapOf<Int, DownloadObject>()
private val cachedTasks = mutableMapOf<Int, DownloadObject>()
@SuppressLint("Range")
fun init(context: Context) {
if (this::taskDatabase.isInitialized) return
taskDatabase = context.openOrCreateDatabase("download_tasks", Context.MODE_PRIVATE, null).apply {
SQLiteDatabaseHelper.createTablesFromSchema(this, mapOf(
"tasks" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"hash VARCHAR UNIQUE",
"outputPath TEXT",
"outputFile TEXT",
"mediaDisplayType TEXT",
"mediaDisplaySource TEXT",
"iconUrl TEXT",
"downloadStage TEXT"
)
))
}
}
fun addTask(task: DownloadObject): Int {
taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)",
arrayOf(
task.metadata.mediaIdentifier,
task.metadata.outputPath,
task.outputFile,
task.metadata.mediaDisplayType,
task.metadata.mediaDisplaySource,
task.metadata.iconUrl,
task.downloadStage.name
)
)
task.downloadId = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use {
it.moveToFirst()
it.getInt(0)
}
pendingTasks[task.downloadId] = task
return task.downloadId
}
fun updateTask(task: DownloadObject) {
taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?",
arrayOf(
task.metadata.mediaIdentifier,
task.metadata.outputPath,
task.outputFile,
task.metadata.mediaDisplayType,
task.metadata.mediaDisplaySource,
task.metadata.iconUrl,
task.downloadStage.name,
task.downloadId
)
)
//if the task is no longer active, move it to the cached tasks
if (task.isJobActive()) {
pendingTasks[task.downloadId] = task
} else {
pendingTasks.remove(task.downloadId)
cachedTasks[task.downloadId] = task
}
}
@SuppressLint("Range")
fun canDownloadMedia(mediaIdentifier: String?): DownloadStage? {
if (mediaIdentifier == null) return null
val cursor = taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(mediaIdentifier))
if (cursor.count > 0) {
cursor.moveToFirst()
val downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage")))
cursor.close()
//if the stage has reached a final stage and is not in a saved state, remove the task
if (downloadStage.isFinalStage && downloadStage != DownloadStage.SAVED) {
taskDatabase.execSQL("DELETE FROM tasks WHERE hash = ?", arrayOf(mediaIdentifier))
return null
}
return downloadStage
}
cursor.close()
return null
}
fun isEmpty(): Boolean {
return cachedTasks.isEmpty() && pendingTasks.isEmpty()
}
private fun removeTask(id: Int) {
taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(id))
cachedTasks.remove(id)
pendingTasks.remove(id)
}
fun removeTask(task: DownloadObject) {
removeTask(task.downloadId)
}
fun queryFirstTasks(filter: MediaFilter): Map<Int, DownloadObject> {
val isPendingFilter = filter == MediaFilter.PENDING
val tasks = mutableMapOf<Int, DownloadObject>()
tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) })
if (isPendingFilter) {
return tasks.toSortedMap(reverseOrder())
}
tasks.putAll(queryTasks(
from = tasks.values.lastOrNull()?.downloadId ?: Int.MAX_VALUE,
amount = 30,
filter = filter
))
return tasks.toSortedMap(reverseOrder())
}
@SuppressLint("Range")
fun queryTasks(from: Int, amount: Int = 30, filter: MediaFilter = MediaFilter.NONE): Map<Int, DownloadObject> {
if (filter == MediaFilter.PENDING) {
return emptyMap()
}
val cursor = taskDatabase.rawQuery(
"SELECT * FROM tasks WHERE id < ? AND mediaDisplayType LIKE ? ORDER BY id DESC LIMIT ?",
arrayOf(
from.toString(),
if (filter.shouldIgnoreFilter) "%" else "%${filter.key}",
amount.toString()
)
)
val result = sortedMapOf<Int, DownloadObject>()
while (cursor.moveToNext()) {
val task = DownloadObject(
downloadId = cursor.getIntOrNull("id")!!,
outputFile = cursor.getStringOrNull("outputFile"),
metadata = DownloadMetadata(
outputPath = cursor.getStringOrNull("outputPath")!!,
mediaIdentifier = cursor.getStringOrNull("hash"),
mediaDisplayType = cursor.getStringOrNull("mediaDisplayType"),
mediaDisplaySource = cursor.getStringOrNull("mediaDisplaySource"),
iconUrl = cursor.getStringOrNull("iconUrl")
)
).apply {
downloadTaskManager = this@DownloadTaskManager
downloadStage = DownloadStage.valueOf(cursor.getStringOrNull("downloadStage")!!)
//if downloadStage is not saved, it means the app was killed before the download was finished
if (downloadStage != DownloadStage.SAVED) {
downloadStage = DownloadStage.FAILED
}
}
result[task.downloadId] = task
}
cursor.close()
return result.toSortedMap(reverseOrder())
}
fun removeAllTasks() {
taskDatabase.execSQL("DELETE FROM tasks")
cachedTasks.clear()
pendingTasks.clear()
}
}

View File

@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.download.data
data class DownloadMetadata(
val mediaIdentifier: String?,
val outputPath: String,
val mediaDisplaySource: String?,
val mediaDisplayType: String?,
val mediaAuthor: String?,
val downloadSource: String,
val iconUrl: String?
)

View File

@ -1,32 +0,0 @@
package me.rhunk.snapenhance.core.download.data
import kotlinx.coroutines.Job
import me.rhunk.snapenhance.core.download.DownloadTaskManager
data class DownloadObject(
var downloadId: Int = 0,
var outputFile: String? = null,
val metadata : DownloadMetadata
) {
lateinit var downloadTaskManager: DownloadTaskManager
var job: Job? = null
var changeListener = { _: DownloadStage, _: DownloadStage -> }
private var _stage: DownloadStage = DownloadStage.PENDING
var downloadStage: DownloadStage
get() = synchronized(this) {
_stage
}
set(value) = synchronized(this) {
changeListener(_stage, value)
_stage = value
downloadTaskManager.updateTask(this)
}
fun isJobActive() = job?.isActive == true
fun cancel() {
downloadStage = DownloadStage.CANCELLED
job?.cancel()
}
}

View File

@ -0,0 +1,28 @@
package me.rhunk.snapenhance.core.download.data
enum class MediaDownloadSource(
val key: String,
val displayName: String = key,
val pathName: String = key,
val ignoreFilter: Boolean = false
) {
NONE("none", "None", ignoreFilter = true),
PENDING("pending", "Pending", ignoreFilter = true),
CHAT_MEDIA("chat_media", "Chat Media", "chat_media"),
STORY("story", "Story", "story"),
PUBLIC_STORY("public_story", "Public Story", "public_story"),
SPOTLIGHT("spotlight", "Spotlight", "spotlight"),
PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture");
fun matches(source: String?): Boolean {
if (source == null) return false
return source.contains(key, ignoreCase = true)
}
companion object {
fun fromKey(key: String?): MediaDownloadSource {
if (key == null) return NONE
return values().find { it.key == key } ?: NONE
}
}
}

View File

@ -1,18 +0,0 @@
package me.rhunk.snapenhance.core.download.data
enum class MediaFilter(
val key: String,
val shouldIgnoreFilter: Boolean = false
) {
NONE("none", true),
PENDING("pending", true),
CHAT_MEDIA("chat_media"),
STORY("story"),
SPOTLIGHT("spotlight"),
PROFILE_PICTURE("profile_picture");
fun matches(source: String?): Boolean {
if (source == null) return false
return source.contains(key, ignoreCase = true)
}
}

View File

@ -12,7 +12,7 @@ import me.rhunk.snapenhance.core.download.DownloadManagerClient
import me.rhunk.snapenhance.core.download.data.DownloadMediaType
import me.rhunk.snapenhance.core.download.data.DownloadMetadata
import me.rhunk.snapenhance.core.download.data.InputMedia
import me.rhunk.snapenhance.core.download.data.MediaFilter
import me.rhunk.snapenhance.core.download.data.MediaDownloadSource
import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType
import me.rhunk.snapenhance.core.download.data.toKeyPair
import me.rhunk.snapenhance.core.messaging.MessagingRuleType
@ -45,16 +45,20 @@ import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
private fun String.sanitizeForPath(): String {
return this.replace(" ", "_")
.replace(Regex("\\p{Cntrl}"), "")
}
@OptIn(ExperimentalEncodingApi::class)
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
private var lastSeenMapParams: ParamMap? = null
private fun provideDownloadManagerClient(
pathSuffix: String,
mediaIdentifier: String,
mediaDisplaySource: String? = null,
mediaDisplayType: String? = null,
mediaAuthor: String,
downloadSource: MediaDownloadSource,
friendInfo: FriendInfo? = null
): DownloadManagerClient {
val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "")
@ -66,7 +70,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
context.shortToast(context.translation["download_processor.download_started_toast"])
}
val outputPath = createNewFilePath(generatedHash, mediaDisplayType, pathSuffix)
val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor)
return DownloadManagerClient(
context = context,
@ -74,8 +78,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) {
generatedHash
} else null,
mediaDisplaySource = mediaDisplaySource,
mediaDisplayType = mediaDisplayType,
mediaAuthor = mediaAuthor,
downloadSource = downloadSource.key,
iconUrl = iconUrl,
outputPath = outputPath
),
@ -106,13 +110,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
//TODO: implement subfolder argument
private fun createNewFilePath(hexHash: String, mediaDisplayType: String?, pathPrefix: String): String {
private fun createNewFilePath(hexHash: String, downloadSource: MediaDownloadSource, mediaAuthor: String): String {
val pathFormat by context.config.downloader.pathFormat
val sanitizedPathPrefix = pathPrefix
.replace(" ", "_")
.replace(Regex("[\\p{Cntrl}]"), "")
.ifEmpty { hexHash }
val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash }
val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis())
@ -126,19 +126,20 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
if (pathFormat.contains("create_user_folder")) {
finalPath.append(sanitizedPathPrefix).append("/")
if (pathFormat.contains("create_author_folder")) {
finalPath.append(sanitizedMediaAuthor).append("/")
}
if (pathFormat.contains("create_source_folder")) {
finalPath.append(downloadSource.pathName).append("/")
}
if (pathFormat.contains("append_hash")) {
appendFileName(hexHash)
}
mediaDisplayType?.let {
if (pathFormat.contains("append_type")) {
appendFileName(it.lowercase().replace(" ", "-"))
}
if (pathFormat.contains("append_source")) {
appendFileName(downloadSource.pathName)
}
if (pathFormat.contains("append_username")) {
appendFileName(sanitizedPathPrefix)
appendFileName(sanitizedMediaAuthor)
}
if (pathFormat.contains("append_date_time")) {
appendFileName(currentDateTime)
@ -235,10 +236,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val authorUsername = author.usernameForSorting!!
downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = authorUsername,
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}",
mediaDisplaySource = authorUsername,
mediaDisplayType = MediaFilter.CHAT_MEDIA.key,
mediaAuthor = authorUsername,
downloadSource = MediaDownloadSource.CHAT_MEDIA,
friendInfo = author
), mediaInfoMap)
@ -278,10 +278,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
if (!forceDownload && !canUseRule(author.userId!!)) return
downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = authorName,
mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaDisplaySource = authorName,
mediaDisplayType = MediaFilter.STORY.key,
mediaAuthor = authorName,
downloadSource = MediaDownloadSource.STORY,
friendInfo = author
), mediaInfoMap)
return
@ -292,15 +291,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
//public stories
if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") &&
(forceDownload || canAutoDownload("public_stories"))) {
val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace(
"[\\p{Cntrl}]".toRegex(),
"")
val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").sanitizeForPath()
downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = "Public-Stories/$userDisplayName",
mediaIdentifier = paramMap["SNAP_ID"].toString(),
mediaDisplayType = userDisplayName,
mediaDisplaySource = "Public Story"
mediaAuthor = userDisplayName,
downloadSource = MediaDownloadSource.PUBLIC_STORY,
), mediaInfoMap)
return
}
@ -308,10 +304,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
//spotlight
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) {
downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = "Spotlight",
mediaIdentifier = paramMap["SNAP_ID"].toString(),
mediaDisplayType = MediaFilter.SPOTLIGHT.key,
mediaDisplaySource = paramMap["TIME_STAMP"].toString()
downloadSource = MediaDownloadSource.SPOTLIGHT,
mediaAuthor = paramMap["TIME_STAMP"].toString()
), mediaInfoMap)
return
}
@ -319,9 +314,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
//stories with mpeg dash media
//TODO: option to download multiple chapters
if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) {
val storyName = paramMap["STORY_NAME"].toString().replace(
"[\\p{Cntrl}]".toRegex(),
"")
val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath()
//get the position of the media in the playlist and the duration
val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!)
@ -338,20 +331,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val duration: Long? = nextChapter?.startTimeMs?.minus(snapChapterTimestamp)
//get the mpd playlist and append the cdn url to baseurl nodes
context.log.verbose("Downloading dash media ${paramMap["MEDIA_ID"].toString()}", featureKey)
val playlistUrl = paramMap["MEDIA_ID"].toString().let {
val urlIndex = it.indexOf("https://cf-st.sc-cdn.net")
if (urlIndex == -1) {
"${RemoteMediaResolver.CF_ST_CDN_D}$it"
} else {
it.substring(urlIndex)
}
val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net"))
urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex ->
it.substring(validIndex)
} ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it"
}
provideDownloadManagerClient(
pathSuffix = "Pro-Stories/${storyName}",
mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}",
mediaDisplaySource = storyName,
mediaDisplayType = "Pro Story"
downloadSource = MediaDownloadSource.PUBLIC_STORY,
mediaAuthor = storyName
).downloadDashMedia(
playlistUrl,
snapChapterTimestamp,
@ -476,10 +468,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
if (!isPreview) {
val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage)
provideDownloadManagerClient(
pathSuffix = authorName,
mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}",
mediaDisplaySource = authorName,
mediaDisplayType = MediaFilter.CHAT_MEDIA.key,
downloadSource = MediaDownloadSource.CHAT_MEDIA,
mediaAuthor = authorName,
friendInfo = friendInfo
).downloadSingleMedia(
Base64.UrlSafe.encode(urlProto),
@ -532,10 +523,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
fun downloadProfilePicture(url: String, author: String) {
provideDownloadManagerClient(
pathSuffix = "Profile Pictures",
mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""),
mediaDisplaySource = author,
mediaDisplayType = MediaFilter.PROFILE_PICTURE.key
mediaAuthor = author,
downloadSource = MediaDownloadSource.PROFILE_PICTURE
).downloadSingleMedia(
url,
DownloadMediaType.REMOTE_MEDIA

View File

@ -12,13 +12,13 @@ import javax.crypto.spec.SecretKeySpec
object EncryptionHelper {
fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair<ByteArray, ByteArray>? {
val messageMediaInfo = MediaDownloaderHelper.getMessageMediaInfo(messageProto, contentType, isArroyo) ?: return null
val encryptionProtoIndex = if (messageMediaInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) {
val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo(messageProto, contentType, isArroyo) ?: return null
val encryptionProtoIndex = if (mediaEncryptionInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) {
Constants.ENCRYPTION_PROTO_INDEX_V2
} else {
Constants.ENCRYPTION_PROTO_INDEX
}
val encryptionProto = messageMediaInfo.followPath(encryptionProtoIndex) ?: return null
val encryptionProto = mediaEncryptionInfo.followPath(encryptionProtoIndex) ?: return null
var key: ByteArray = encryptionProto.getByteArray(1)!!
var iv: ByteArray = encryptionProto.getByteArray(2)!!

View File

@ -18,7 +18,7 @@ import java.util.zip.ZipInputStream
object MediaDownloaderHelper {
fun getMessageMediaInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? {
fun getMessageMediaEncryptionInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? {
val messageContainerPath = if (isArroyo) protoReader.followPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH)!! else protoReader
val mediaContainerPath = if (contentType == ContentType.NOTE) intArrayOf(6, 1, 1) else intArrayOf(5, 1, 1)
@ -27,12 +27,13 @@ object MediaDownloaderHelper {
ContentType.SNAP -> messageContainerPath.followPath(*(intArrayOf(11) + mediaContainerPath))
ContentType.EXTERNAL_MEDIA -> {
val externalMediaTypes = arrayOf(
intArrayOf(3, 3), //normal external media
intArrayOf(7, 12, 3), //attached story reply
intArrayOf(7, 3) //original story reply
intArrayOf(3, 3, *mediaContainerPath), //normal external media
intArrayOf(7, 15, 1, 1), //attached audio note
intArrayOf(7, 12, 3, *mediaContainerPath), //attached story reply
intArrayOf(7, 3, *mediaContainerPath), //original story reply
)
externalMediaTypes.forEach { path ->
messageContainerPath.followPath(*(path + mediaContainerPath))?.also { return it }
messageContainerPath.followPath(*path)?.also { return it }
}
null
}