cloudstream/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt

2040 lines
77 KiB
Kotlin
Raw Normal View History

2021-06-29 23:14:48 +00:00
package com.lagradost.cloudstream3.utils
2021-09-19 22:46:05 +00:00
import android.app.Notification
2021-09-01 21:30:21 +00:00
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
2021-07-05 18:09:37 +00:00
import android.content.*
2021-06-29 23:14:48 +00:00
import android.graphics.Bitmap
2021-07-05 18:09:37 +00:00
import android.net.Uri
2021-06-29 23:14:48 +00:00
import android.os.Build
2021-07-04 17:00:04 +00:00
import android.os.Environment
2021-07-05 00:55:07 +00:00
import android.provider.MediaStore
2021-06-29 23:14:48 +00:00
import androidx.annotation.DrawableRes
2021-07-05 18:09:37 +00:00
import androidx.annotation.RequiresApi
2021-06-29 23:14:48 +00:00
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
2021-11-01 15:33:46 +00:00
import androidx.preference.PreferenceManager
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty
2021-11-01 15:33:46 +00:00
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.BuildConfig
2021-06-29 23:14:48 +00:00
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
2021-07-04 17:00:04 +00:00
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
2021-07-04 17:00:04 +00:00
import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
2021-07-04 00:59:51 +00:00
import com.lagradost.cloudstream3.utils.Coroutines.main
2021-07-05 20:28:50 +00:00
import com.lagradost.cloudstream3.utils.DataStore.getKey
2021-07-05 18:09:37 +00:00
import com.lagradost.cloudstream3.utils.DataStore.removeKey
2021-08-19 20:05:18 +00:00
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
2021-07-04 00:59:51 +00:00
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
2021-07-04 17:00:04 +00:00
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
2021-07-04 00:59:51 +00:00
import kotlinx.coroutines.withContext
2021-11-02 14:25:12 +00:00
import okhttp3.internal.closeQuietly
import java.io.Closeable
2021-11-06 21:06:13 +00:00
import java.io.File
import java.io.IOException
2021-11-06 21:06:13 +00:00
import java.io.OutputStream
2021-07-03 20:59:46 +00:00
import java.net.URL
2021-07-04 17:00:04 +00:00
import java.util.*
2021-07-08 17:46:47 +00:00
2021-07-06 00:18:56 +00:00
const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general"
const val DOWNLOAD_CHANNEL_NAME = "Downloads"
const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel"
2021-06-29 23:14:48 +00:00
object VideoDownloadManager {
2021-07-03 20:59:46 +00:00
var maxConcurrentDownloads = 3
2021-07-05 20:28:50 +00:00
private var currentDownloads = mutableListOf<Int>()
2021-07-03 20:59:46 +00:00
private const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
2021-07-03 20:59:46 +00:00
@get:DrawableRes
val imgDone get() = R.drawable.rddone
2021-06-29 23:14:48 +00:00
@get:DrawableRes
val imgDownloading get() = R.drawable.rdload
2021-06-29 23:14:48 +00:00
@get:DrawableRes
val imgPaused get() = R.drawable.rdpause
2021-06-29 23:14:48 +00:00
@get:DrawableRes
val imgStopped get() = R.drawable.rderror
2021-06-29 23:14:48 +00:00
@get:DrawableRes
val imgError get() = R.drawable.rderror
2021-06-29 23:14:48 +00:00
@get:DrawableRes
val pressToPauseIcon get() = R.drawable.ic_baseline_pause_24
2021-06-29 23:14:48 +00:00
@get:DrawableRes
val pressToResumeIcon get() = R.drawable.ic_baseline_play_arrow_24
2021-06-29 23:14:48 +00:00
@get:DrawableRes
val pressToStopIcon get() = R.drawable.baseline_stop_24
2023-07-15 21:43:09 +00:00
2021-06-29 23:14:48 +00:00
enum class DownloadType {
IsPaused,
IsDownloading,
IsDone,
IsFailed,
IsStopped,
2023-07-15 21:43:09 +00:00
IsPending
2021-06-29 23:14:48 +00:00
}
enum class DownloadActionType {
Pause,
Resume,
Stop,
}
interface IDownloadableMinimum {
val url: String
val referer: String
val headers: Map<String, String>
}
2021-08-29 18:42:44 +00:00
fun IDownloadableMinimum.getId(): Int {
return url.hashCode()
}
2021-07-03 20:59:46 +00:00
data class DownloadEpisodeMetadata(
@JsonProperty("id") val id: Int,
@JsonProperty("mainName") val mainName: String,
@JsonProperty("sourceApiName") val sourceApiName: String?,
@JsonProperty("poster") val poster: String?,
@JsonProperty("name") val name: String?,
@JsonProperty("season") val season: Int?,
@JsonProperty("episode") val episode: Int?,
@JsonProperty("type") val type: TvType?,
2021-07-03 20:59:46 +00:00
)
2021-07-04 17:00:04 +00:00
data class DownloadItem(
@JsonProperty("source") val source: String?,
@JsonProperty("folder") val folder: String?,
@JsonProperty("ep") val ep: DownloadEpisodeMetadata,
@JsonProperty("links") val links: List<ExtractorLink>,
2021-07-04 17:00:04 +00:00
)
2021-07-05 18:09:37 +00:00
data class DownloadResumePackage(
@JsonProperty("item") val item: DownloadItem,
@JsonProperty("linkIndex") val linkIndex: Int?,
2021-07-05 18:09:37 +00:00
)
2021-07-05 20:28:50 +00:00
data class DownloadedFileInfo(
@JsonProperty("totalBytes") val totalBytes: Long,
@JsonProperty("relativePath") val relativePath: String,
@JsonProperty("displayName") val displayName: String,
@JsonProperty("extraInfo") val extraInfo: String? = null,
@JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getDefaultPath()
2021-07-05 20:28:50 +00:00
)
data class DownloadedFileInfoResult(
@JsonProperty("fileLength") val fileLength: Long,
@JsonProperty("totalBytes") val totalBytes: Long,
@JsonProperty("path") val path: Uri,
2021-07-05 20:28:50 +00:00
)
2021-07-08 17:46:47 +00:00
data class DownloadQueueResumePackage(
@JsonProperty("index") val index: Int,
@JsonProperty("pkg") val pkg: DownloadResumePackage,
2021-07-08 17:46:47 +00:00
)
2021-07-04 17:00:04 +00:00
private const val SUCCESS_DOWNLOAD_DONE = 1
2021-09-01 21:30:21 +00:00
private const val SUCCESS_STREAM = 3
2021-07-04 17:00:04 +00:00
private const val SUCCESS_STOPPED = 2
2021-12-24 16:09:01 +00:00
// will not download the next one, but is still classified as an error
private const val ERROR_DELETING_FILE = 3
2021-07-05 18:09:37 +00:00
private const val ERROR_CREATE_FILE = -2
2021-09-01 12:02:32 +00:00
private const val ERROR_UNKNOWN = -10
2021-12-24 16:09:01 +00:00
//private const val ERROR_OPEN_FILE = -3
2021-07-04 17:00:04 +00:00
private const val ERROR_TOO_SMALL_CONNECTION = -4
2021-12-24 16:09:01 +00:00
//private const val ERROR_WRONG_CONTENT = -5
2021-07-04 17:00:04 +00:00
private const val ERROR_CONNECTION_ERROR = -6
2021-12-24 16:09:01 +00:00
//private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7
//private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8
2021-07-05 18:09:37 +00:00
private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9
2021-07-04 17:00:04 +00:00
2021-08-29 18:42:44 +00:00
private const val KEY_RESUME_PACKAGES = "download_resume"
2021-07-08 17:46:47 +00:00
const val KEY_DOWNLOAD_INFO = "download_info"
2021-08-29 18:42:44 +00:00
private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume"
2021-07-04 17:00:04 +00:00
2021-07-06 00:18:56 +00:00
val downloadStatus = HashMap<Int, DownloadType>()
val downloadStatusEvent = Event<Pair<Int, DownloadType>>()
2021-07-25 14:25:09 +00:00
val downloadDeleteEvent = Event<Int>()
2021-07-05 20:28:50 +00:00
val downloadEvent = Event<Pair<Int, DownloadActionType>>()
2021-07-24 20:50:57 +00:00
val downloadProgressEvent = Event<Triple<Int, Long, Long>>()
2021-07-08 19:39:49 +00:00
val downloadQueue = LinkedList<DownloadResumePackage>()
2021-07-04 17:00:04 +00:00
2021-06-29 23:14:48 +00:00
private var hasCreatedNotChanel = false
private fun Context.createNotificationChannel() {
hasCreatedNotChanel = true
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
2021-07-06 00:18:56 +00:00
val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name)
val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description)
2021-06-29 23:14:48 +00:00
val importance = NotificationManager.IMPORTANCE_DEFAULT
2021-07-06 00:18:56 +00:00
val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply {
2021-06-29 23:14:48 +00:00
description = descriptionText
}
// Register the channel with the system
val notificationManager: NotificationManager =
this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
2021-07-24 15:13:21 +00:00
/** Will return IsDone if not found or error */
2021-07-24 15:35:53 +00:00
fun getDownloadState(id: Int): DownloadType {
2021-07-24 15:13:21 +00:00
return try {
downloadStatus[id] ?: DownloadType.IsDone
2021-07-24 15:35:53 +00:00
} catch (e: Exception) {
2021-09-01 13:18:41 +00:00
logError(e)
2021-07-24 15:13:21 +00:00
DownloadType.IsDone
}
}
2021-06-29 23:14:48 +00:00
private val cachedBitmaps = hashMapOf<String, Bitmap>()
fun Context.getImageBitmapFromUrl(url: String, headers: Map<String, String>? = null): Bitmap? {
2021-08-19 20:05:18 +00:00
try {
if (cachedBitmaps.containsKey(url)) {
return cachedBitmaps[url]
}
2021-06-29 23:14:48 +00:00
val bitmap = GlideApp.with(this)
2021-08-19 20:05:18 +00:00
.asBitmap()
.load(GlideUrl(url) { headers ?: emptyMap() })
.into(720, 720)
2021-08-19 20:05:18 +00:00
.get()
2021-08-19 20:05:18 +00:00
if (bitmap != null) {
cachedBitmaps[url] = bitmap
}
return bitmap
} catch (e: Exception) {
2021-11-04 15:11:28 +00:00
logError(e)
2021-08-19 20:05:18 +00:00
return null
2021-06-29 23:14:48 +00:00
}
}
2021-12-25 18:04:40 +00:00
/**
* @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size.
* */
private suspend fun createNotification(
2021-06-29 23:14:48 +00:00
context: Context,
2021-07-03 20:59:46 +00:00
source: String?,
linkName: String?,
ep: DownloadEpisodeMetadata,
2021-06-29 23:14:48 +00:00
state: DownloadType,
progress: Long,
total: Long,
2021-12-25 18:04:40 +00:00
notificationCallback: (Int, Notification) -> Unit,
hlsProgress: Long? = null,
hlsTotal: Long? = null
): Notification? {
2021-12-24 16:09:01 +00:00
try {
if (total <= 0) return null// crash, invalid data
// main { // DON'T WANT TO SLOW IT DOWN
2021-12-24 16:09:01 +00:00
val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
.setAutoCancel(true)
.setColorized(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
.setContentTitle(ep.mainName)
.setSmallIcon(
when (state) {
DownloadType.IsDone -> imgDone
DownloadType.IsDownloading -> imgDownloading
DownloadType.IsPaused -> imgPaused
DownloadType.IsFailed -> imgError
DownloadType.IsStopped -> imgStopped
2023-07-15 21:43:09 +00:00
DownloadType.IsPending -> imgDownloading
2021-12-24 16:09:01 +00:00
}
)
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
if (ep.sourceApiName != null) {
builder.setSubText(ep.sourceApiName)
2021-07-04 17:00:04 +00:00
}
2021-12-24 16:09:01 +00:00
if (source != null) {
val intent = Intent(context, MainActivity::class.java).apply {
data = source.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
2021-12-25 18:04:40 +00:00
val pendingIntent: PendingIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
} else {
PendingIntent.getActivity(context, 0, intent, 0)
}
2021-12-24 16:09:01 +00:00
builder.setContentIntent(pendingIntent)
}
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false)
}
2021-07-03 20:59:46 +00:00
2021-12-24 16:09:01 +00:00
val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else ""
val rowTwo = if (ep.season != null && ep.episode != null) {
"${context.getString(R.string.season_short)}${ep.season}:${context.getString(R.string.episode_short)}${ep.episode}" + rowTwoExtra
} else if (ep.episode != null) {
"${context.getString(R.string.episode)} ${ep.episode}" + rowTwoExtra
} else {
(ep.name ?: "") + ""
}
val downloadFormat = context.getString(R.string.download_format)
2021-07-03 20:59:46 +00:00
2021-12-24 16:09:01 +00:00
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (ep.poster != null) {
val poster = withContext(Dispatchers.IO) {
context.getImageBitmapFromUrl(ep.poster)
}
if (poster != null)
builder.setLargeIcon(poster)
2021-07-04 17:00:04 +00:00
}
2021-07-03 20:59:46 +00:00
2021-12-25 18:04:40 +00:00
val progressPercentage: Long
val progressMbString: String
val totalMbString: String
val suffix: String
if (hlsProgress != null && hlsTotal != null) {
progressPercentage = hlsProgress.toLong() * 100 / hlsTotal
progressMbString = hlsProgress.toString()
totalMbString = hlsTotal.toString()
suffix = " - %.1f MB".format(progress / 1000000f)
} else {
progressPercentage = progress * 100 / total
progressMbString = "%.1f MB".format(progress / 1000000f)
totalMbString = "%.1f MB".format(total / 1000000f)
suffix = ""
}
2021-12-24 16:09:01 +00:00
val bigText =
when (state) {
DownloadType.IsDownloading, DownloadType.IsPaused -> {
(if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix"
}
DownloadType.IsFailed -> {
downloadFormat.format(
context.getString(R.string.download_failed),
rowTwo
)
}
DownloadType.IsDone -> {
downloadFormat.format(context.getString(R.string.download_done), rowTwo)
}
else -> {
downloadFormat.format(
context.getString(R.string.download_canceled),
rowTwo
)
}
2021-12-24 16:09:01 +00:00
}
val bodyStyle = NotificationCompat.BigTextStyle()
bodyStyle.bigText(bigText)
builder.setStyle(bodyStyle)
} else {
2021-12-25 18:04:40 +00:00
val txt =
when (state) {
DownloadType.IsDownloading, DownloadType.IsPaused -> {
rowTwo
}
DownloadType.IsFailed -> {
downloadFormat.format(
context.getString(R.string.download_failed),
rowTwo
)
}
DownloadType.IsDone -> {
downloadFormat.format(context.getString(R.string.download_done), rowTwo)
}
else -> {
downloadFormat.format(
context.getString(R.string.download_canceled),
rowTwo
)
}
2021-12-25 18:04:40 +00:00
}
2021-07-03 20:59:46 +00:00
2021-12-24 16:09:01 +00:00
builder.setContentText(txt)
2021-07-03 20:59:46 +00:00
}
2021-12-24 16:09:01 +00:00
if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val actionTypes: MutableList<DownloadActionType> = ArrayList()
// INIT
if (state == DownloadType.IsDownloading) {
actionTypes.add(DownloadActionType.Pause)
actionTypes.add(DownloadActionType.Stop)
}
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
if (state == DownloadType.IsPaused) {
actionTypes.add(DownloadActionType.Resume)
actionTypes.add(DownloadActionType.Stop)
}
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
// ADD ACTIONS
for ((index, i) in actionTypes.withIndex()) {
val actionResultIntent = Intent(context, VideoDownloadService::class.java)
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
actionResultIntent.putExtra(
"type", when (i) {
DownloadActionType.Resume -> "resume"
DownloadActionType.Pause -> "pause"
DownloadActionType.Stop -> "stop"
}
)
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
actionResultIntent.putExtra("id", ep.id)
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
val pending: PendingIntent = PendingIntent.getService(
// BECAUSE episodes lying near will have the same id +1, index will give the same requested as the previous episode, *100000 fixes this
context, (4337 + index * 1000000 + ep.id),
actionResultIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
2021-12-24 16:09:01 +00:00
builder.addAction(
NotificationCompat.Action(
when (i) {
DownloadActionType.Resume -> pressToResumeIcon
DownloadActionType.Pause -> pressToPauseIcon
DownloadActionType.Stop -> pressToStopIcon
}, when (i) {
DownloadActionType.Resume -> context.getString(R.string.resume)
DownloadActionType.Pause -> context.getString(R.string.pause)
DownloadActionType.Stop -> context.getString(R.string.cancel)
}, pending
)
2021-06-29 23:14:48 +00:00
)
2021-12-24 16:09:01 +00:00
}
2021-06-29 23:14:48 +00:00
}
2021-12-24 16:09:01 +00:00
if (!hasCreatedNotChanel) {
context.createNotificationChannel()
}
2021-06-29 23:14:48 +00:00
2021-12-24 16:09:01 +00:00
val notification = builder.build()
notificationCallback(ep.id, notification)
with(NotificationManagerCompat.from(context)) {
// notificationId is a unique int for each notification that you must define
notify(ep.id, notification)
}
return notification
} catch (e: Exception) {
logError(e)
return null
2021-06-29 23:14:48 +00:00
}
}
2021-07-04 17:00:04 +00:00
private const val reservedChars = "|\\?*<\":>+[]/\'"
fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String {
2021-07-04 00:59:51 +00:00
var tempName = name
for (c in reservedChars) {
tempName = tempName.replace(c, ' ')
}
if (removeSpaces) tempName = tempName.replace(" ", "")
2021-07-04 17:00:04 +00:00
return tempName.replace(" ", " ").trim(' ')
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun ContentResolver.getExistingFolderStartName(relativePath: String): List<Pair<String, Uri>>? {
try {
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only)
//MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only)
)
val selection =
"${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath'"
val result = this.query(
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
projection, selection, null, null
)
val list = ArrayList<Pair<String, Uri>>()
result.use { c ->
if (c != null && c.count >= 1) {
c.moveToFirst()
while (true) {
val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
2021-12-03 22:48:30 +00:00
val name =
c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
val uri = ContentUris.withAppendedId(
MediaStore.Downloads.EXTERNAL_CONTENT_URI, id
)
list.add(Pair(name, uri))
if (c.isLast) {
break
}
c.moveToNext()
}
/*
val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/
}
}
return list
} catch (e: Exception) {
2021-11-04 15:11:28 +00:00
logError(e)
return null
}
}
2021-11-01 15:33:46 +00:00
/**
* Used for getting video player subs.
* @return List of pairs for the files in this format: <Name, Uri>
* */
2021-12-03 22:48:30 +00:00
fun getFolder(
context: Context,
relativePath: String,
basePath: String?
): List<Pair<String, Uri>>? {
2021-11-06 21:06:13 +00:00
val base = basePathToFile(context, basePath)
2021-11-01 15:33:46 +00:00
val folder = base?.gotoDir(relativePath, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) {
return context.contentResolver?.getExistingFolderStartName(relativePath)
} else {
2021-11-01 15:33:46 +00:00
// val normalPath =
// "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace(
// '/',
// File.separatorChar
// )
// val folder = File(normalPath)
if (folder?.isDirectory == true) {
return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) }
}
}
2021-11-01 15:33:46 +00:00
return null
// }
}
2021-07-05 18:09:37 +00:00
@RequiresApi(Build.VERSION_CODES.Q)
2021-12-03 22:48:30 +00:00
private fun ContentResolver.getExistingDownloadUriOrNullQ(
relativePath: String,
displayName: String
): Uri? {
2021-08-09 14:40:20 +00:00
try {
val projection = arrayOf(
MediaStore.MediaColumns._ID,
//MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only)
//MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only)
)
val selection =
"${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath' AND " + "${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'"
val result = this.query(
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
projection, selection, null, null
)
result.use { c ->
if (c != null && c.count >= 1) {
c.moveToFirst().let {
val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
/*
val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))
val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/
return ContentUris.withAppendedId(
MediaStore.Downloads.EXTERNAL_CONTENT_URI, id
)
}
2021-07-05 18:09:37 +00:00
}
}
2021-08-09 14:40:20 +00:00
return null
} catch (e: Exception) {
2021-11-04 15:11:28 +00:00
logError(e)
2021-08-09 14:40:20 +00:00
return null
2021-07-05 18:09:37 +00:00
}
}
2021-07-05 20:28:50 +00:00
@RequiresApi(Build.VERSION_CODES.Q)
2021-08-09 14:40:20 +00:00
fun ContentResolver.getFileLength(fileUri: Uri): Long? {
return try {
this.openFileDescriptor(fileUri, "r")
.use { it?.statSize ?: 0 }
} catch (e: Exception) {
2021-11-04 15:11:28 +00:00
logError(e)
2021-08-09 14:40:20 +00:00
null
}
2021-07-05 20:28:50 +00:00
}
data class CreateNotificationMetadata(
val type: DownloadType,
val bytesDownloaded: Long,
val bytesTotal: Long,
2021-12-25 18:04:40 +00:00
val hlsProgress: Long? = null,
val hlsTotal: Long? = null,
)
2021-09-01 21:30:21 +00:00
data class StreamData(
val errorCode: Int,
val resume: Boolean? = null,
val fileLength: Long? = null,
val fileStream: OutputStream? = null,
)
2021-11-01 15:33:46 +00:00
/**
* Sets up the appropriate file and creates a data stream from the file.
* Used for initializing downloads.
* */
2022-04-10 22:00:03 +00:00
fun setupStream(
2021-08-30 17:11:04 +00:00
context: Context,
name: String,
folder: String?,
extension: String,
2021-09-01 21:30:21 +00:00
tryResume: Boolean,
): StreamData {
val displayName = getDisplayName(name, extension)
2021-08-30 17:11:04 +00:00
val fileStream: OutputStream
val fileLength: Long
2021-09-01 21:30:21 +00:00
var resume = tryResume
2021-11-01 15:33:46 +00:00
val baseFile = context.getBasePath()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.first?.isDownloadDir() == true) {
2021-09-01 21:30:21 +00:00
val cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
2021-08-30 17:11:04 +00:00
val currentExistingFile =
2021-12-03 22:48:30 +00:00
cr.getExistingDownloadUriOrNullQ(
folder ?: "",
displayName
) // CURRENT FILE WITH THE SAME PATH
2021-08-30 17:11:04 +00:00
fileLength =
2021-12-03 22:48:30 +00:00
if (currentExistingFile == null || !resume) 0 else (cr.getFileLength(
currentExistingFile
)
2021-08-30 17:11:04 +00:00
?: 0)// IF NOT RESUME THEN 0, OTHERWISE THE CURRENT FILE SIZE
if (!resume && currentExistingFile != null) { // DELETE FILE IF FILE EXITS AND NOT RESUME
val rowsDeleted = context.contentResolver.delete(currentExistingFile, null, null)
if (rowsDeleted < 1) {
println("ERROR DELETING FILE!!!")
}
}
var appendFile = false
val newFileUri = if (resume && currentExistingFile != null) {
appendFile = true
currentExistingFile
} else {
val contentUri =
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI
//val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
val currentMimeType = when (extension) {
2021-12-03 22:48:30 +00:00
// Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents
// downloading to /Downloads yet it works with null
"vtt" -> null // "text/vtt"
2021-08-30 17:11:04 +00:00
"mp4" -> "video/mp4"
2021-12-03 22:48:30 +00:00
"srt" -> null // "application/x-subrip"//"text/plain"
2021-08-30 17:11:04 +00:00
else -> null
}
val newFile = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.TITLE, name)
if (currentMimeType != null)
put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType)
2021-11-02 14:25:12 +00:00
put(MediaStore.MediaColumns.RELATIVE_PATH, folder)
2021-08-30 17:11:04 +00:00
}
cr.insert(
contentUri,
newFile
2021-09-01 21:30:21 +00:00
) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
2021-08-30 17:11:04 +00:00
}
fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else ""))
2021-09-01 21:30:21 +00:00
?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
2021-08-30 17:11:04 +00:00
} else {
2021-11-01 15:33:46 +00:00
val subDir = baseFile.first?.gotoDir(folder)
val rFile = subDir?.findFile(displayName)
if (rFile?.exists() != true) {
2021-08-30 17:11:04 +00:00
fileLength = 0
2021-11-04 15:11:28 +00:00
if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE)
2021-08-30 17:11:04 +00:00
} else {
if (resume) {
2021-11-02 14:25:12 +00:00
fileLength = rFile.size()
2021-08-30 17:11:04 +00:00
} else {
fileLength = 0
2021-11-04 15:11:28 +00:00
if (!rFile.delete()) return StreamData(ERROR_DELETING_FILE)
if (subDir.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE)
2021-08-30 17:11:04 +00:00
}
}
2021-12-03 22:48:30 +00:00
fileStream = (subDir.findFile(displayName)
?: subDir.createFile(displayName))!!.openOutputStream()
2021-11-01 15:33:46 +00:00
// fileStream = FileOutputStream(rFile, false)
if (fileLength == 0L) resume = false
2021-08-30 17:11:04 +00:00
}
2021-09-01 21:30:21 +00:00
return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream)
}
/** This class handles the notifications, as well as the relevant key */
data class DownloadMetaData(
private val id: Int?,
var bytesDownloaded: Long = 0,
var bytesWritten: Long = 0,
var totalBytes: Long? = null,
// notification metadata
private var lastUpdatedMs: Long = 0,
private val createNotificationCallback: (CreateNotificationMetadata) -> Unit,
private var internalType: DownloadType = DownloadType.IsPending,
// how many segments that we have downloaded
var hlsProgress: Int = 0,
// how many segments that exist
var hlsTotal: Int? = null,
// this is how many segments that has been written to the file
// will always be <= hlsProgress as we may keep some in a buffer
var hlsWrittenProgress: Int = 0,
// this is used for copy with metadata on how much we have downloaded for setKey
private var downloadFileInfoTemplate: DownloadedFileInfo? = null
) : Closeable {
val approxTotalBytes: Long
get() = totalBytes ?: hlsTotal?.let { total ->
(bytesDownloaded * (total / hlsProgress.toFloat())).toLong()
} ?: bytesDownloaded
private val isHLS get() = hlsTotal != null
private var stopListener: (() -> Unit)? = null
/** on cancel button pressed or failed invoke this once and only once */
fun setOnStop(callback: (() -> Unit)) {
stopListener = callback
}
fun removeStopListener() {
stopListener = null
}
private val downloadEventListener = { event: Pair<Int, DownloadActionType> ->
if (event.first == id) {
when (event.second) {
DownloadActionType.Pause -> {
type = DownloadType.IsPaused
}
DownloadActionType.Stop -> {
type = DownloadType.IsStopped
removeKey(KEY_RESUME_PACKAGES, event.first.toString())
saveQueue()
stopListener?.invoke()
stopListener = null
}
DownloadActionType.Resume -> {
type = DownloadType.IsDownloading
}
}
}
2021-08-30 17:11:04 +00:00
}
private fun updateFileInfo() {
if (id == null) return
downloadFileInfoTemplate?.let { template ->
setKey(
KEY_DOWNLOAD_INFO,
id.toString(),
template.copy(
totalBytes = approxTotalBytes,
extraInfo = if (isHLS) hlsWrittenProgress.toString() else null
)
)
}
}
2021-11-01 15:33:46 +00:00
fun setDownloadFileInfoTemplate(template: DownloadedFileInfo) {
downloadFileInfoTemplate = template
updateFileInfo()
}
2021-07-05 00:55:07 +00:00
init {
if (id != null) {
downloadEvent += downloadEventListener
}
2021-07-03 20:59:46 +00:00
}
2021-07-04 00:59:51 +00:00
override fun close() {
// as we may need to resume hls downloads, we save the current written index
if (isHLS || totalBytes == null) {
updateFileInfo()
}
if (id != null) {
downloadEvent -= downloadEventListener
downloadStatus -= id
}
stopListener = null
}
2021-07-04 00:59:51 +00:00
var type
get() = internalType
set(value) {
internalType = value
notify()
}
2021-07-03 20:59:46 +00:00
fun onDelete() {
bytesDownloaded = 0
hlsWrittenProgress = 0
hlsProgress = 0
2023-08-19 15:03:27 +00:00
if (id != null)
downloadDeleteEvent(id)
//internalType = DownloadType.IsStopped
notify()
}
companion object {
const val UPDATE_RATE_MS: Long = 1000L
}
2021-07-03 20:59:46 +00:00
@JvmName("DownloadMetaDataNotify")
private fun notify() {
lastUpdatedMs = System.currentTimeMillis()
try {
val bytes = approxTotalBytes
2021-08-14 17:31:27 +00:00
// notification creation
if (isHLS) {
createNotificationCallback(
CreateNotificationMetadata(
internalType,
bytesDownloaded,
bytes,
hlsTotal = hlsTotal?.toLong(),
hlsProgress = hlsProgress.toLong()
)
)
} else {
createNotificationCallback(
CreateNotificationMetadata(
internalType,
bytesDownloaded,
bytes,
)
)
}
2021-08-29 19:46:25 +00:00
// as hls has an approx file size we want to update this metadata
if (isHLS) {
updateFileInfo()
}
if (internalType == DownloadType.IsStopped || internalType == DownloadType.IsFailed) {
stopListener?.invoke()
stopListener = null
}
// push all events, this *should* not crash, TODO MUTEX?
if (id != null) {
downloadStatus[id] = type
downloadProgressEvent(Triple(id, bytesDownloaded, bytes))
downloadStatusEvent(id to type)
}
} catch (t: Throwable) {
logError(t)
if (BuildConfig.DEBUG) {
throw t
}
}
}
2021-08-14 17:31:27 +00:00
private fun checkNotification() {
if (lastUpdatedMs + UPDATE_RATE_MS > System.currentTimeMillis()) return
notify()
}
2021-07-03 20:59:46 +00:00
2021-08-11 17:40:23 +00:00
/** adds the length and pushes a notification if necessary */
fun addBytes(length: Long) {
bytesDownloaded += length
// we don't want to update the notification after it is paused,
// download progress may not stop directly when we "pause" it
if (type == DownloadType.IsDownloading) checkNotification()
2021-08-11 17:40:23 +00:00
}
fun addBytesWritten(length: Long) {
bytesWritten += length
}
/** adds the length + hsl progress and pushes a notification if necessary */
fun addSegment(length: Long) {
hlsProgress += 1
addBytes(length)
}
2021-07-04 00:59:51 +00:00
fun setWrittenSegment(segmentIndex: Int) {
hlsWrittenProgress = segmentIndex + 1
}
}
2021-07-05 20:28:50 +00:00
/** bytes have the size end-start where the byte range is [start,end)
* note that ByteArray is a pointer and therefore cant be stored without cloning it */
data class LazyStreamDownloadResponse(
val bytes: ByteArray,
val startByte: Long,
val endByte: Long,
) {
val size get() = endByte - startByte
override fun toString(): String {
return "$startByte->$endByte"
}
override fun equals(other: Any?): Boolean {
if (other !is LazyStreamDownloadResponse) return false
return other.startByte == startByte && other.endByte == endByte
}
override fun hashCode(): Int {
return Objects.hash(startByte, endByte)
}
}
data class LazyStreamDownloadData(
private val url: String,
private val headers: Map<String, String>,
private val referer: String,
/** This specifies where chunck i starts and ends,
* bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1}
* where out of bounds => bytes=${chuckStartByte[ i ]}- */
private val chuckStartByte: LongArray,
val totalLength: Long?,
val downloadLength: Long?,
val chuckSize: Long,
val bufferSize: Int,
) {
val size get() = chuckStartByte.size
/** returns what byte it has downloaded,
* so start at 10 and download 4 bytes = return 14
*
* the range is [startByte, endByte) to be able to do [a, b) [b, c) ect
*
* [a, null) will return inclusive to eof = [a, eof]
*
* throws an error if initial get request fails, can be specified as return startByte
* */
@Throws
private suspend fun resolve(
startByte: Long,
endByte: Long?,
callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit)
): Long = withContext(Dispatchers.IO) {
var currentByte: Long = startByte
val stopAt = endByte ?: Long.MAX_VALUE
if (currentByte >= stopAt) return@withContext currentByte
val request = app.get(
url,
headers = headers + mapOf(
// range header is inclusive so [startByte, endByte-1] = [startByte, endByte)
// if nothing at end the server will continue until eof
"Range" to "bytes=$startByte-" // ${endByte?.minus(1)?.toString() ?: "" }
),
referer = referer,
verify = false
)
val requestStream = request.body.byteStream()
val buffer = ByteArray(bufferSize)
var read: Int
try {
while (requestStream.read(buffer, 0, bufferSize).also { read = it } >= 0) {
val start = currentByte
currentByte += read.toLong()
// this stops overflow
if (currentByte >= stopAt) {
callback(LazyStreamDownloadResponse(buffer, start, stopAt))
break
} else {
callback(LazyStreamDownloadResponse(buffer, start, currentByte))
}
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
logError(t)
} finally {
requestStream.closeQuietly()
}
return@withContext currentByte
}
/** retries the resolve n times and returns true if successful */
suspend fun resolveSafe(
index: Int,
retries: Int = 3,
callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit)
): Boolean {
var start = chuckStartByte.getOrNull(index) ?: return false
val end = chuckStartByte.getOrNull(index + 1)
for (i in 0 until retries) {
try {
// in case
start = resolve(start, end, callback)
// no end defined, so we don't care exactly where it ended
if (end == null) return true
// we have download more or exactly what we needed
if (start >= end) return true
} catch (e: IllegalStateException) {
return false
} catch (e: CancellationException) {
return false
} catch (t: Throwable) {
continue
}
}
return false
}
}
@Throws
suspend fun streamLazy(
url: String,
headers: Map<String, String>,
referer: String,
startByte: Long,
/** how many bytes every connection should be, by default it is 10 MiB */
chuckSize: Long = (1 shl 20) * 10,
/** maximum bytes in the buffer that responds */
bufferSize: Int = DEFAULT_BUFFER_SIZE
): LazyStreamDownloadData {
// we don't want to make a separate connection for every 1kb
require(chuckSize > 1000)
val contentLength =
app.head(url = url, headers = headers, referer = referer, verify = false).size
var downloadLength: Long? = null
var totalLength: Long? = null
val ranges = if (contentLength == null) {
LongArray(1) { startByte }
} else {
downloadLength = contentLength - startByte
totalLength = contentLength
LongArray((downloadLength / chuckSize).toInt()) { idx ->
startByte + idx * chuckSize
}
}
return LazyStreamDownloadData(
url = url,
headers = headers,
referer = referer,
chuckStartByte = ranges,
downloadLength = downloadLength,
totalLength = totalLength,
chuckSize = chuckSize,
bufferSize = bufferSize
)
}
@Throws
suspend fun downloadThing(
context: Context,
link: IDownloadableMinimum,
name: String,
folder: String?,
extension: String,
tryResume: Boolean,
parentId: Int?,
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
parallelConnections: Int = 3
): Int = withContext(Dispatchers.IO) {
// we cant download torrents with this implementation, aria2c might be used in the future
if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) {
return@withContext ERROR_UNKNOWN
}
2021-07-06 00:18:56 +00:00
var fileStream: OutputStream? = null
//var requestStream: InputStream? = null
val metadata = DownloadMetaData(
totalBytes = 0,
bytesDownloaded = 0,
createNotificationCallback = createNotificationCallback,
id = parentId,
)
try {
// get the file path
val (baseFile, basePath) = context.getBasePath()
val displayName = getDisplayName(name, extension)
val relativePath =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
folder
) else folder
// set up the download file
val stream = setupStream(context, name, relativePath, extension, tryResume)
if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode
fileStream = stream.fileStream ?: return@withContext ERROR_UNKNOWN
val resume = stream.resume ?: return@withContext ERROR_UNKNOWN
val fileLength = stream.fileLength ?: return@withContext ERROR_UNKNOWN
val resumeAt = (if (resume) fileLength else 0)
metadata.bytesDownloaded = resumeAt
metadata.bytesWritten = resumeAt
metadata.type = DownloadType.IsPending
val items = streamLazy(
url = link.url.replace(" ", "%20"),
referer = link.referer,
startByte = resumeAt,
headers = link.headers.appendAndDontOverride(
mapOf(
"Accept-Encoding" to "identity",
"accept" to "*/*",
"user-agent" to USER_AGENT,
"sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"",
"sec-fetch-mode" to "navigate",
"sec-fetch-dest" to "video",
"sec-fetch-user" to "?1",
"sec-ch-ua-mobile" to "?0",
)
)
)
metadata.totalBytes = items.totalLength
metadata.type = DownloadType.IsDownloading
metadata.setDownloadFileInfoTemplate(
DownloadedFileInfo(
totalBytes = metadata.approxTotalBytes,
relativePath = relativePath ?: "",
displayName = displayName,
basePath = basePath
2021-12-03 22:48:30 +00:00
)
)
2021-07-03 20:59:46 +00:00
val currentMutex = Mutex()
val current = (0 until items.size).iterator()
val fileMutex = Mutex()
// start to data
val pendingData: HashMap<Long, LazyStreamDownloadResponse> =
hashMapOf()
val jobs = (0 until parallelConnections).map {
launch {
// this may seem a bit complex but it more or less acts as a queue system
// imagine we do the downloading [0,3] and it response in the order 0,2,3,1
// file: [_,_,_,_] queue: [_,_,_,_] Initial condition
// file: [X,_,_,_] queue: [_,_,_,_] + added 0 directly to file
// file: [X,_,_,_] queue: [_,_,X,_] + added 2 to queue
// file: [X,_,_,_] queue: [_,_,X,X] + added 3 to queue
// file: [X,X,_,_] queue: [_,_,X,X] + added 1 directly to file
// file: [X,X,X,X] queue: [_,_,_,_] write the queue and remove from it
val callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) =
callback@{ response ->
if (!isActive) return@callback
fileMutex.withLock {
// wait until not paused
while (metadata.type == DownloadType.IsPaused) delay(100)
// if stopped then throw
if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed) {
this.cancel()
return@callback
}
val responseSize = response.size
metadata.addBytes(response.size)
if (response.startByte == metadata.bytesWritten) {
// if we are first in the queue then write it directly
fileStream.write(
response.bytes,
0,
responseSize.toInt()
)
metadata.addBytesWritten(responseSize)
} else {
// otherwise append to queue, we need to clone the bytes as they will be overridden otherwise
pendingData[response.startByte] =
response.copy(bytes = response.bytes.clone())
}
while (true) {
// remove the current queue start, so no possibility of
// while(true) { continue } in case size = 0, and removed extra
// garbage
val pending = pendingData.remove(metadata.bytesWritten) ?: break
val size = pending.size
fileStream.write(
pending.bytes,
0,
size.toInt()
)
metadata.addBytesWritten(size)
}
}
}
// this will take up the first available job and resolve
while (true) {
if (!isActive) return@launch
fileMutex.withLock {
if (metadata.type == DownloadType.IsStopped) return@launch
}
2021-07-04 17:00:04 +00:00
// just in case, we never want this to fail due to multithreading
val index = currentMutex.withLock {
if (!current.hasNext()) return@launch
current.nextInt()
}
// in case something has gone wrong set to failed if the fail is not caused by
// user cancellation
if (!items.resolveSafe(index, callback = callback)) {
fileMutex.withLock {
if (metadata.type != DownloadType.IsStopped) {
metadata.type = DownloadType.IsFailed
}
}
return@launch
}
}
}
}
// fast stop as the jobs may be in a slow request
metadata.setOnStop {
jobs.forEach { job ->
try {
job.cancel()
} catch (t: Throwable) {
logError(t)
}
}
}
jobs.forEach { it.join() }
// jobs are finished so we don't want to stop them anymore
metadata.removeStopListener()
// set up a connection
//val request = app.get(
// link.url.replace(" ", "%20"),
// headers = link.headers.appendAndDontOverride(
// mapOf(
// "Accept-Encoding" to "identity",
// "accept" to "*/*",
// "user-agent" to USER_AGENT,
// "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"",
// "sec-fetch-mode" to "navigate",
// "sec-fetch-dest" to "video",
// "sec-fetch-user" to "?1",
// "sec-ch-ua-mobile" to "?0",
// ) + if (resumeAt > 0) mapOf("Range" to "bytes=${resumeAt}-") else emptyMap()
// ),
// referer = link.referer,
// verify = false
//)
// init variables
//val contentLength = request.size ?: 0
//metadata.totalBytes = contentLength + resumeAt
//// save
//metadata.setDownloadFileInfoTemplate(
// DownloadedFileInfo(
// totalBytes = metadata.approxTotalBytes,
// relativePath = relativePath ?: "",
// displayName = displayName,
// basePath = basePath
// )
//)
//// total length is less than 5mb, that is too short and something has gone wrong
//if (extension == "mp4" && metadata.approxTotalBytes < 5000000) return@withContext ERROR_TOO_SMALL_CONNECTION
//// read the buffer into the filestream, this is equivalent of transferTo
//requestStream = request.body.byteStream()
//metadata.type = DownloadType.IsDownloading
//val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
//var read: Int
//while (requestStream.read(buffer, 0, DEFAULT_BUFFER_SIZE).also { read = it } >= 0) {
// fileStream.write(buffer, 0, read)
// // wait until not paused
// while (metadata.type == DownloadType.IsPaused) delay(100)
// // if stopped then break to delete
// if (metadata.type == DownloadType.IsStopped) break
// metadata.addBytes(read.toLong())
//}
if (metadata.type == DownloadType.IsFailed) {
return@withContext ERROR_CONNECTION_ERROR
2021-07-04 17:00:04 +00:00
}
2021-07-03 20:59:46 +00:00
if (metadata.type == DownloadType.IsStopped) {
// we need to close before delete
fileStream.closeQuietly()
metadata.onDelete()
if (deleteFile(context, baseFile, relativePath ?: "", displayName)) {
return@withContext SUCCESS_STOPPED
} else {
return@withContext ERROR_DELETING_FILE
}
2021-07-04 17:00:04 +00:00
}
metadata.type = DownloadType.IsDone
return@withContext SUCCESS_DOWNLOAD_DONE
} catch (e: IOException) {
// some sort of IO error, this should not happened
// we just rethrow it
2021-11-04 15:11:28 +00:00
logError(e)
throw e
} catch (t: Throwable) {
// some sort of network error, will error
// note that when failing we don't want to delete the file,
// only user interaction has that power
metadata.removeStopListener()
metadata.type = DownloadType.IsFailed
return@withContext ERROR_CONNECTION_ERROR
} finally {
fileStream?.closeQuietly()
//requestStream?.closeQuietly()
metadata.close()
2021-07-04 17:00:04 +00:00
}
}
2021-07-04 17:00:04 +00:00
/** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp
* example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4)
* */
private fun <V> Map<String, V>.appendAndDontOverride(rhs: Map<String, V>): Map<String, V> {
val out = this.toMutableMap()
val current = this.keys.map { it.lowercase() }
for ((key, value) in rhs) {
if (current.contains(key.lowercase())) continue
out[key] = value
}
return out
}
2021-07-03 20:59:46 +00:00
@Throws
private suspend fun downloadHLS(
context: Context,
link: ExtractorLink,
name: String,
folder: String?,
parentId: Int?,
startIndex: Int?,
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
parallelConnections: Int = 3
): Int = withContext(Dispatchers.IO) {
require(parallelConnections >= 1)
val metadata = DownloadMetaData(
createNotificationCallback = createNotificationCallback,
id = parentId
)
val extension = "mp4"
var fileStream: OutputStream? = null
2021-07-06 00:18:56 +00:00
try {
// the start .ts index
var startAt = startIndex ?: 0
// set up the file data
val (baseFile, basePath) = context.getBasePath()
val relativePath =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
folder
) else folder
val displayName = getDisplayName(name, extension)
val stream = setupStream(context, name, relativePath, extension, startAt > 0)
if (stream.errorCode != SUCCESS_STREAM) return@withContext stream.errorCode
if (stream.resume != true) startAt = 0
fileStream = stream.fileStream ?: return@withContext ERROR_UNKNOWN
// push the metadata
metadata.bytesDownloaded = stream.fileLength ?: 0
metadata.hlsProgress = startAt
metadata.type = DownloadType.IsPending
metadata.setDownloadFileInfoTemplate(
DownloadedFileInfo(
totalBytes = 0,
relativePath = relativePath ?: "",
displayName = displayName,
basePath = basePath
)
)
2021-07-06 00:18:56 +00:00
// do the initial get request to fetch the segments
val m3u8 = M3u8Helper.M3u8Stream(
link.url, link.quality, link.headers.appendAndDontOverride(
mapOf(
"Accept-Encoding" to "identity",
"accept" to "*/*",
"user-agent" to USER_AGENT,
) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap()
)
)
val items = M3u8Helper2.hslLazy(listOf(m3u8))
metadata.hlsTotal = items.size
metadata.type = DownloadType.IsDownloading
// does several connections in parallel instead of a regular for loop to improve
// download speed
(startAt until items.size).chunked(parallelConnections).forEach { subset ->
// wait until not paused
while (metadata.type == DownloadType.IsPaused) delay(100)
// if stopped then break to delete
if (metadata.type == DownloadType.IsStopped) return@forEach
subset.amap { idx ->
idx to items.resolveLinkSafe(idx)?.also { bytes ->
metadata.addSegment(bytes.size.toLong())
}
}.forEach { (idx, bytes) ->
if (bytes == null) {
metadata.type = DownloadType.IsFailed
return@withContext ERROR_CONNECTION_ERROR
}
fileStream.write(bytes)
metadata.setWrittenSegment(idx)
}
2021-07-04 17:00:04 +00:00
}
if (metadata.type == DownloadType.IsStopped) {
// we need to close before delete
fileStream.closeQuietly()
metadata.onDelete()
if (deleteFile(context, baseFile, relativePath ?: "", displayName)) {
return@withContext SUCCESS_STOPPED
} else {
return@withContext ERROR_DELETING_FILE
}
2021-07-04 17:00:04 +00:00
}
metadata.type = DownloadType.IsDone
return@withContext SUCCESS_DOWNLOAD_DONE
} catch (t: Throwable) {
logError(t)
metadata.type = DownloadType.IsFailed
return@withContext ERROR_UNKNOWN
} finally {
fileStream?.closeQuietly()
metadata.close()
2021-07-04 17:00:04 +00:00
}
2021-06-29 23:14:48 +00:00
}
2021-11-02 14:25:12 +00:00
/**
* Guarantees a directory is present with the dir name (if createMissingDirectories is true).
* Works recursively when '/' is present.
* Will remove any file with the dir name if present and add directory.
2021-11-04 15:11:28 +00:00
* Will not work if the parent directory does not exist.
2021-11-02 14:25:12 +00:00
*
* @param directoryName if null will use the current path.
* @return UniFile / null if createMissingDirectories = false and folder is not found.
* */
2021-12-03 22:48:30 +00:00
private fun UniFile.gotoDir(
directoryName: String?,
createMissingDirectories: Boolean = true
): UniFile? {
2021-11-02 14:25:12 +00:00
// May give this error on scoped storage.
// W/DocumentsContract: Failed to create document
// java.lang.IllegalArgumentException: Parent document isn't a directory
// Not present in latest testing.
2021-11-02 14:27:05 +00:00
// println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}")
2021-11-02 14:25:12 +00:00
try {
2021-11-04 15:11:28 +00:00
// Creates itself from parent if doesn't exist.
if (!this.exists() && createMissingDirectories && !this.name.isNullOrBlank()) {
if (this.parentFile != null) {
this.parentFile?.createDirectory(this.name)
} else if (this.filePath != null) {
UniFile.fromFile(File(this.filePath!!).parentFile)?.createDirectory(this.name)
}
}
2021-11-02 14:25:12 +00:00
val allDirectories = directoryName?.split("/")
return if (allDirectories?.size == 1 || allDirectories == null) {
val found = this.findFile(directoryName)
when {
directoryName.isNullOrBlank() -> this
found?.isDirectory == true -> found
!createMissingDirectories -> null
// Below creates directories
found?.isFile == true -> {
found.delete()
this.createDirectory(directoryName)
}
2021-11-02 14:25:12 +00:00
this.isDirectory -> this.createDirectory(directoryName)
else -> this.parentFile?.createDirectory(directoryName)
}
} else {
var currentDirectory = this
allDirectories.forEach {
// If the next directory is not found it returns the deepest directory possible.
val nextDir = currentDirectory.gotoDir(it, createMissingDirectories)
currentDirectory = nextDir ?: return null
}
currentDirectory
}
} catch (e: Exception) {
logError(e)
return null
}
}
2021-11-01 15:33:46 +00:00
private fun getDisplayName(name: String, extension: String): String {
return "$name.$extension"
}
/**
* Gets the default download path as an UniFile.
* Vital for legacy downloads, be careful about changing anything here.
*
* As of writing UniFile is used for everything but download directory on scoped storage.
* Special ContentResolver fuckery is needed for that as UniFile doesn't work.
* */
fun getDownloadDir(): UniFile? {
// See https://www.py4u.net/discuss/614761
return UniFile.fromFile(
File(
Environment.getExternalStorageDirectory().absolutePath + File.separatorChar +
Environment.DIRECTORY_DOWNLOADS
)
)
}
2021-11-02 14:25:12 +00:00
@Deprecated("TODO fix UniFile to work with download directory.")
2021-09-01 21:30:21 +00:00
private fun getRelativePath(folder: String?): String {
2021-12-03 22:48:30 +00:00
return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace(
'/',
File.separatorChar
).replace("${File.separatorChar}${File.separatorChar}", File.separatorChar.toString())
2021-09-01 21:30:21 +00:00
}
2021-11-01 15:33:46 +00:00
/**
* Turns a string to an UniFile. Used for stored string paths such as settings.
* Should only be used to get a download path.
* */
private fun basePathToFile(context: Context, path: String?): UniFile? {
return when {
2021-11-02 14:25:12 +00:00
path.isNullOrBlank() -> getDownloadDir()
2021-11-01 15:33:46 +00:00
path.startsWith("content://") -> UniFile.fromUri(context, path.toUri())
else -> UniFile.fromFile(File(path))
}
2021-09-01 21:30:21 +00:00
}
2021-11-01 15:33:46 +00:00
/**
* Base path where downloaded things should be stored, changes depending on settings.
* Returns the file and a string to be stored for future file retrieval.
* UniFile.filePath is not sufficient for storage.
* */
fun Context.getBasePath(): Pair<UniFile?, String?> {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null)
return basePathToFile(this, basePathSetting) to basePathSetting
}
2022-03-04 15:39:56 +00:00
fun UniFile?.isDownloadDir(): Boolean {
2021-11-01 15:33:46 +00:00
return this != null && this.filePath == getDownloadDir()?.filePath
2021-09-01 21:30:21 +00:00
}
/*private fun delete(
2021-09-01 21:30:21 +00:00
context: Context,
name: String,
folder: String?,
extension: String,
parentId: Int?,
2021-11-01 15:33:46 +00:00
basePath: UniFile?
2021-09-01 21:30:21 +00:00
): Int {
val displayName = getDisplayName(name, extension)
2022-02-07 00:05:21 +00:00
// delete all subtitle files
if (extension != "vtt" && extension != "srt") {
2022-02-07 00:05:21 +00:00
try {
delete(context, name, folder, "vtt", parentId, basePath)
delete(context, name, folder, "srt", parentId, basePath)
} catch (e: Exception) {
logError(e)
}
}
2021-11-01 15:33:46 +00:00
// If scoped storage and using download dir (not accessible with UniFile)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.isDownloadDir()) {
2021-11-01 15:33:46 +00:00
val relativePath = getRelativePath(folder)
2021-12-03 22:48:30 +00:00
val lastContent =
context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) ?: return ERROR_DELETING_FILE
if(context.contentResolver.delete(lastContent, null, null) <= 0) {
return ERROR_DELETING_FILE
2021-09-01 21:30:21 +00:00
}
} else {
2021-11-01 15:33:46 +00:00
val dir = basePath?.gotoDir(folder)
2021-11-02 14:25:12 +00:00
val file = dir?.findFile(displayName)
val success = file?.delete()
2021-11-01 15:33:46 +00:00
if (success != true) return ERROR_DELETING_FILE else {
// Cleans up empty directory
if (dir.listFiles()?.isEmpty() == true) dir.delete()
}
parentId?.let {
downloadDeleteEvent.invoke(parentId)
}
2021-09-01 21:30:21 +00:00
}
return SUCCESS_STOPPED
}*/
2021-09-01 21:30:21 +00:00
fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String {
return getFileName(context, metadata.name, metadata.episode, metadata.season)
}
2022-04-06 15:16:08 +00:00
private fun getFileName(
context: Context,
epName: String?,
episode: Int?,
season: Int?
): String {
// kinda ugly ik
return sanitizeFilename(
if (epName == null) {
if (season != null) {
"${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode"
} else {
"${context.getString(R.string.episode)} $episode"
}
} else {
if (episode != null) {
if (season != null) {
"${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName"
} else {
"${context.getString(R.string.episode)} $episode - $epName"
}
} else {
epName
}
}
)
}
private suspend fun downloadSingleEpisode(
context: Context,
source: String?,
folder: String?,
ep: DownloadEpisodeMetadata,
link: ExtractorLink,
notificationCallback: (Int, Notification) -> Unit,
tryResume: Boolean = false,
): Int {
val name = getFileName(context, ep)
// Make sure this is cancelled when download is done or cancelled.
val extractorJob = ioSafe {
if (link.extractorData != null) {
getApiFromNameNull(link.source)?.extractorVerifierJob(link.extractorData)
}
}
2023-04-21 11:56:17 +00:00
if (link.isM3u8 || URL(link.url).path.endsWith(".m3u8")) {
2021-09-01 21:30:21 +00:00
val startIndex = if (tryResume) {
context.getKey<DownloadedFileInfo>(
KEY_DOWNLOAD_INFO,
ep.id.toString(),
null
)?.extraInfo?.toIntOrNull()
2021-09-01 21:30:21 +00:00
} else null
return suspendSafeApiCall {
downloadHLS(
context,
link,
name,
folder,
ep.id,
startIndex,
createNotificationCallback = { meta ->
main {
createNotification(
context,
source,
link.name,
ep,
meta.type,
meta.bytesDownloaded,
meta.bytesTotal,
notificationCallback,
meta.hlsProgress,
meta.hlsTotal
)
}
}
)
}.also {
extractorJob.cancel()
} ?: ERROR_UNKNOWN
2021-09-01 12:21:03 +00:00
}
return suspendSafeApiCall {
downloadThing(
context,
link,
name,
folder,
"mp4",
tryResume,
ep.id,
createNotificationCallback = { meta ->
main {
createNotification(
context,
source,
link.name,
ep,
meta.type,
meta.bytesDownloaded,
meta.bytesTotal,
notificationCallback
)
}
})
}.also { extractorJob.cancel() } ?: ERROR_UNKNOWN
}
suspend fun downloadCheck(
context: Context, notificationCallback: (Int, Notification) -> Unit,
) {
if (!(currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0)) return
val pkg = downloadQueue.removeFirst()
val item = pkg.item
val id = item.ep.id
if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT
downloadEvent.invoke(Pair(id, DownloadActionType.Resume))
/** ID needs to be returned to the work-manager to properly await notification */
// return id
}
2021-07-08 17:46:47 +00:00
currentDownloads.add(id)
try {
for (index in (pkg.linkIndex ?: 0) until item.links.size) {
val link = item.links[index]
val resume = pkg.linkIndex == index
2021-07-05 18:43:28 +00:00
setKey(
KEY_RESUME_PACKAGES,
id.toString(),
DownloadResumePackage(item, index)
)
var connectionResult =
downloadSingleEpisode(
context,
item.source,
item.folder,
item.ep,
link,
notificationCallback,
resume
)
//.also { println("Single episode finished with return code: $it") }
// retry every link at least once
if (connectionResult <= 0) {
connectionResult = downloadSingleEpisode(
context,
item.source,
item.folder,
item.ep,
link,
notificationCallback,
true
)
}
if (connectionResult > 0) { // SUCCESS
removeKey(KEY_RESUME_PACKAGES, id.toString())
break
} else if (index == item.links.lastIndex) {
downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed))
2021-07-04 17:00:04 +00:00
}
}
} catch (e: Exception) {
logError(e)
} finally {
currentDownloads.remove(id)
// Because otherwise notifications will not get caught by the work manager
downloadCheckUsingWorker(context)
2021-07-04 17:00:04 +00:00
}
// return id
2021-07-04 17:00:04 +00:00
}
2021-07-05 20:28:50 +00:00
fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
val res = getDownloadFileInfo(context, id)
if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return res
}
private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? {
2022-04-06 15:16:08 +00:00
try {
val info =
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return null
val base = basePathToFile(context, info.basePath)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) {
val cr = context.contentResolver ?: return null
val fileUri =
cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName)
?: return null
val fileLength = cr.getFileLength(fileUri) ?: return null
if (fileLength == 0L) return null
return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri)
} else {
2021-11-04 16:07:29 +00:00
2022-04-06 15:16:08 +00:00
val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName)
2021-11-04 16:07:29 +00:00
2021-11-01 15:33:46 +00:00
// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName)
// val dFile = File(normalPath)
2021-11-02 14:25:12 +00:00
2022-04-06 15:16:08 +00:00
if (file?.exists() != true) return null
2021-11-01 15:33:46 +00:00
2022-04-06 15:16:08 +00:00
return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri)
}
} catch (e: Exception) {
logError(e)
return null
2021-11-02 14:25:12 +00:00
}
}
/**
* Gets the true download size as Scoped Storage sometimes wrongly returns 0.
* */
fun UniFile.size(): Long {
val len = length()
return if (len <= 1) {
val inputStream = this.openInputStream()
return inputStream.available().toLong().also { inputStream.closeQuietly() }
} else {
len
2021-07-05 20:28:50 +00:00
}
}
fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
val success = deleteFile(context, id)
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return success
}
private fun deleteFile(
context: Context,
folder: UniFile?,
relativePath: String,
displayName: String
): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && folder.isDownloadDir()) {
2021-07-05 20:28:50 +00:00
val cr = context.contentResolver ?: return false
val fileUri =
cr.getExistingDownloadUriOrNullQ(relativePath, displayName)
2021-07-05 20:28:50 +00:00
?: return true // FILE NOT FOUND, ALREADY DELETED
return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0
} else {
val file = folder?.gotoDir(relativePath)?.findFile(displayName)
2021-11-01 15:33:46 +00:00
// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName)
// val dFile = File(normalPath)
if (file?.exists() != true) return true
2021-11-02 14:25:12 +00:00
return try {
file.delete()
} catch (e: Exception) {
2021-11-04 15:11:28 +00:00
logError(e)
2021-11-02 14:25:12 +00:00
val cr = context.contentResolver
cr.delete(file.uri, null, null) > 0
}
2021-07-05 20:28:50 +00:00
}
}
private fun deleteFile(context: Context, id: Int): Boolean {
val info =
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return false
downloadEvent.invoke(Pair(id, DownloadActionType.Stop))
downloadProgressEvent.invoke(Triple(id, 0, 0))
downloadStatusEvent.invoke(id to DownloadType.IsStopped)
downloadDeleteEvent.invoke(id)
val base = basePathToFile(context, info.basePath)
return deleteFile(context, base, info.relativePath, info.displayName)
}
2021-07-05 20:28:50 +00:00
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {
return context.getKey(KEY_RESUME_PACKAGES, id.toString())
}
suspend fun downloadFromResume(
context: Context,
pkg: DownloadResumePackage,
notificationCallback: (Int, Notification) -> Unit,
setKey: Boolean = true
) {
2021-07-17 15:56:26 +00:00
if (!currentDownloads.any { it == pkg.item.ep.id }) {
downloadQueue.addLast(pkg)
downloadCheck(context, notificationCallback)
if (setKey) saveQueue()
//ret
2021-07-24 15:13:21 +00:00
} else {
downloadEvent(
2021-07-24 15:13:21 +00:00
Pair(pkg.item.ep.id, DownloadActionType.Resume)
)
//null
2021-07-17 15:56:26 +00:00
}
}
private fun saveQueue() {
2022-07-20 23:48:40 +00:00
try {
val dQueue =
downloadQueue.toList()
.mapIndexed { index, any -> DownloadQueueResumePackage(index, any) }
.toTypedArray()
setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue)
} catch (t: Throwable) {
2022-08-25 01:59:20 +00:00
logError(t)
2022-07-20 23:48:40 +00:00
}
2021-07-05 18:09:37 +00:00
}
2021-08-29 18:42:44 +00:00
/*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean {
2021-07-08 17:46:47 +00:00
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager?
for (service in manager!!.getRunningServices(Int.MAX_VALUE)) {
if (serviceClass.name == service.service.className) {
return true
}
}
return false
2021-08-29 18:42:44 +00:00
}*/
2021-07-08 17:46:47 +00:00
suspend fun downloadEpisode(
context: Context?,
2021-07-06 00:18:56 +00:00
source: String?,
2021-07-04 00:59:51 +00:00
folder: String?,
ep: DownloadEpisodeMetadata,
links: List<ExtractorLink>,
notificationCallback: (Int, Notification) -> Unit,
2021-07-04 00:59:51 +00:00
) {
2021-08-29 19:46:25 +00:00
if (context == null) return
if (links.isEmpty()) return
downloadFromResume(
context,
DownloadResumePackage(DownloadItem(source, folder, ep, links), null),
notificationCallback
)
2021-06-29 23:14:48 +00:00
}
/** Worker stuff */
private fun startWork(context: Context, key: String) {
val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java)
.setInputData(
Data.Builder()
.putString("key", key)
.build()
)
.build()
(WorkManager.getInstance(context)).enqueueUniqueWork(
key,
ExistingWorkPolicy.KEEP,
req
)
}
fun downloadCheckUsingWorker(
context: Context,
) {
startWork(context, DOWNLOAD_CHECK)
}
fun downloadFromResumeUsingWorker(
context: Context,
pkg: DownloadResumePackage,
) {
val key = pkg.item.ep.id.toString()
setKey(WORK_KEY_PACKAGE, key, pkg)
startWork(context, key)
}
// Keys are needed to transfer the data to the worker reliably and without exceeding the data limit
const val WORK_KEY_PACKAGE = "work_key_package"
const val WORK_KEY_INFO = "work_key_info"
fun downloadEpisodeUsingWorker(
context: Context,
source: String?,
folder: String?,
ep: DownloadEpisodeMetadata,
links: List<ExtractorLink>,
) {
val info = DownloadInfo(
source, folder, ep, links
)
val key = info.ep.id.toString()
setKey(WORK_KEY_INFO, key, info)
startWork(context, key)
}
data class DownloadInfo(
@JsonProperty("source") val source: String?,
@JsonProperty("folder") val folder: String?,
@JsonProperty("ep") val ep: DownloadEpisodeMetadata,
@JsonProperty("links") val links: List<ExtractorLink>
)
2021-09-01 12:21:03 +00:00
}