forked from recloudstream/cloudstream
download manager finished
This commit is contained in:
parent
98185c0763
commit
d4c0c17859
1 changed files with 146 additions and 55 deletions
|
@ -3,21 +3,18 @@ package com.lagradost.cloudstream3.utils
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.ContentValues
|
import android.content.*
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.anggrayudi.storage.extension.closeStream
|
import com.anggrayudi.storage.extension.closeStream
|
||||||
import com.anggrayudi.storage.file.DocumentFileCompat
|
|
||||||
import com.anggrayudi.storage.file.forceDelete
|
|
||||||
import com.anggrayudi.storage.file.openOutputStream
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
@ -26,11 +23,12 @@ import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.services.VideoDownloadService
|
import com.lagradost.cloudstream3.services.VideoDownloadService
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.removeKey
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.BufferedInputStream
|
import java.io.*
|
||||||
import java.io.InputStream
|
|
||||||
import java.lang.Thread.sleep
|
import java.lang.Thread.sleep
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.URLConnection
|
import java.net.URLConnection
|
||||||
|
@ -103,18 +101,27 @@ object VideoDownloadManager {
|
||||||
val links: List<ExtractorLink>
|
val links: List<ExtractorLink>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class DownloadResumePackage(
|
||||||
|
val item: DownloadItem,
|
||||||
|
val linkIndex: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
private const val SUCCESS_DOWNLOAD_DONE = 1
|
private const val SUCCESS_DOWNLOAD_DONE = 1
|
||||||
private const val SUCCESS_STOPPED = 2
|
private const val SUCCESS_STOPPED = 2
|
||||||
private const val ERROR_DELETING_FILE = -1
|
private const val ERROR_DELETING_FILE = -1
|
||||||
private const val ERROR_FILE_NOT_FOUND = -2
|
private const val ERROR_CREATE_FILE = -2
|
||||||
private const val ERROR_OPEN_FILE = -3
|
private const val ERROR_OPEN_FILE = -3
|
||||||
private const val ERROR_TOO_SMALL_CONNECTION = -4
|
private const val ERROR_TOO_SMALL_CONNECTION = -4
|
||||||
private const val ERROR_WRONG_CONTENT = -5
|
private const val ERROR_WRONG_CONTENT = -5
|
||||||
private const val ERROR_CONNECTION_ERROR = -6
|
private const val ERROR_CONNECTION_ERROR = -6
|
||||||
|
private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7
|
||||||
|
private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8
|
||||||
|
private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9
|
||||||
|
|
||||||
|
private const val KEY_RESUME_STORAGE = "download_resume"
|
||||||
|
|
||||||
val events = Event<Pair<Int, DownloadActionType>>()
|
val events = Event<Pair<Int, DownloadActionType>>()
|
||||||
private val downloadQueue = LinkedList<DownloadItem>()
|
private val downloadQueue = LinkedList<DownloadResumePackage>()
|
||||||
|
|
||||||
|
|
||||||
private var hasCreatedNotChanel = false
|
private var hasCreatedNotChanel = false
|
||||||
private fun Context.createNotificationChannel() {
|
private fun Context.createNotificationChannel() {
|
||||||
|
@ -323,68 +330,143 @@ object VideoDownloadManager {
|
||||||
return tempName.replace(" ", " ").trim(' ')
|
return tempName.replace(" ", " ").trim(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private fun ContentResolver.getExistingDownloadUriOrNullQ(relativePath: String, displayName: String): Uri? {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isScopedStorage(): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||||
|
}
|
||||||
|
|
||||||
private fun downloadSingleEpisode(
|
private fun downloadSingleEpisode(
|
||||||
context: Context,
|
context: Context,
|
||||||
source: String?,
|
source: String?,
|
||||||
folder: String?,
|
folder: String?,
|
||||||
ep: DownloadEpisodeMetadata,
|
ep: DownloadEpisodeMetadata,
|
||||||
link: ExtractorLink
|
link: ExtractorLink,
|
||||||
|
tryResume: Boolean = false,
|
||||||
): Int {
|
): Int {
|
||||||
val name = sanitizeFilename(ep.name ?: "Episode ${ep.episode}")
|
val name = sanitizeFilename(ep.name ?: "Episode ${ep.episode}")
|
||||||
val path = "${if (folder == null) "" else "$folder/"}$name.mp4" //${Environment.DIRECTORY_DOWNLOADS}/
|
|
||||||
var resume = false
|
|
||||||
|
|
||||||
|
val relativePath = (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace('/', File.separatorChar)
|
||||||
|
val displayName = "$name.mp4"
|
||||||
|
|
||||||
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val normalPath = "${Environment.getExternalStorageDirectory()}${File.separatorChar}$relativePath$displayName"
|
||||||
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
var resume = tryResume
|
||||||
|
|
||||||
} else {
|
val fileStream: OutputStream
|
||||||
TODO("VERSION.SDK_INT < Q")
|
val fileLength: Long
|
||||||
|
|
||||||
|
fun deleteFile(): Int {
|
||||||
|
if (isScopedStorage()) {
|
||||||
|
val lastContent = context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName)
|
||||||
|
if (lastContent != null) {
|
||||||
|
context.contentResolver.delete(lastContent, null, null)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (!File(normalPath).delete()) return ERROR_DELETING_FILE
|
||||||
|
}
|
||||||
|
return SUCCESS_STOPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScopedStorage()) {
|
||||||
|
val cr = context.contentResolver ?: return ERROR_CONTENT_RESOLVER_NOT_FOUND
|
||||||
|
|
||||||
|
val currentExistingFile = cr.getExistingDownloadUriOrNullQ(relativePath, displayName) // CURRENT FILE WITH THE SAME PATH
|
||||||
|
|
||||||
|
fileLength =
|
||||||
|
if (currentExistingFile == null || !resume) 0 else cr.openFileDescriptor(currentExistingFile, "r")
|
||||||
|
.use { it?.statSize ?: 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!!!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newFileUri = if (resume && currentExistingFile != null) currentExistingFile else {
|
||||||
|
val contentUri =
|
||||||
|
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||||
|
|
||||||
val newFile = ContentValues().apply {
|
val newFile = ContentValues().apply {
|
||||||
put(MediaStore.Downloads.DISPLAY_NAME, "$name.mp4")
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
// put(MediaStore.Downloads.RELATIVE_PATH, if (folder == null) "" else "$folder")
|
put(MediaStore.MediaColumns.TITLE, name)
|
||||||
}
|
put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
|
||||||
val newFileUri = context.contentResolver.insert(collection, newFile) ?: throw Exception("FUCK YOU")
|
put(
|
||||||
val outputStream = context.contentResolver.openOutputStream(newFileUri, "w")
|
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||||
?: throw Exception("ContentResolver couldn't open $newFileUri outputStream")
|
relativePath
|
||||||
return 0
|
)
|
||||||
// IF RESUME, DON'T DELETE FILE, CONTINUE, RECREATE IF NOT FOUND
|
|
||||||
// IF NOT RESUME CREATE FILE
|
|
||||||
val tempFile = DocumentFileCompat.fromSimplePath(context, basePath = path)
|
|
||||||
val fileExists = tempFile?.exists() ?: false
|
|
||||||
|
|
||||||
if (!fileExists) resume = false
|
|
||||||
if (fileExists && !resume) {
|
|
||||||
if (tempFile?.delete() == false) { // DELETE FAILED ON RESUME FILE
|
|
||||||
return ERROR_DELETING_FILE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val dFile =
|
cr.insert(
|
||||||
if (resume) tempFile
|
contentUri,
|
||||||
else DocumentFileCompat.createFile(context, basePath = path, mimeType = "video/mp4")
|
newFile
|
||||||
|
) ?: return ERROR_MEDIA_STORE_URI_CANT_BE_CREATED
|
||||||
// END OF FILE CREATION
|
|
||||||
|
|
||||||
if (dFile == null || !dFile.exists()) {
|
|
||||||
return ERROR_FILE_NOT_FOUND
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPEN FILE
|
fileStream = cr.openOutputStream(newFileUri, "w")
|
||||||
val fileStream = dFile.openOutputStream(context, resume) ?: return ERROR_OPEN_FILE
|
?: return ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM
|
||||||
|
} else {
|
||||||
|
// NORMAL NON SCOPED STORAGE FILE CREATION
|
||||||
|
val rFile = File(normalPath)
|
||||||
|
if (!rFile.exists()) {
|
||||||
|
fileLength = 0
|
||||||
|
rFile.parentFile?.mkdirs()
|
||||||
|
if (!rFile.createNewFile()) return ERROR_CREATE_FILE
|
||||||
|
} else {
|
||||||
|
if (resume) {
|
||||||
|
fileLength = rFile.length()
|
||||||
|
} else {
|
||||||
|
fileLength = 0
|
||||||
|
rFile.parentFile?.mkdirs()
|
||||||
|
if (!rFile.delete()) return ERROR_DELETING_FILE
|
||||||
|
if (!rFile.createNewFile()) return ERROR_CREATE_FILE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileStream = FileOutputStream(rFile, false)
|
||||||
|
}
|
||||||
|
if (fileLength == 0L) resume = false
|
||||||
|
|
||||||
// CONNECT
|
// CONNECT
|
||||||
val connection: URLConnection = URL(link.url).openConnection()
|
val connection: URLConnection = URL(link.url.replace(" ", "%20")).openConnection() // IDK OLD PHONES BE WACK
|
||||||
|
|
||||||
// SET CONNECTION SETTINGS
|
// SET CONNECTION SETTINGS
|
||||||
connection.connectTimeout = 10000
|
connection.connectTimeout = 10000
|
||||||
connection.setRequestProperty("Accept-Encoding", "identity")
|
connection.setRequestProperty("Accept-Encoding", "identity")
|
||||||
connection.setRequestProperty("User-Agent", USER_AGENT)
|
connection.setRequestProperty("User-Agent", USER_AGENT)
|
||||||
if (link.referer.isNotEmpty()) connection.setRequestProperty("Referer", link.referer)
|
if (link.referer.isNotEmpty()) connection.setRequestProperty("Referer", link.referer)
|
||||||
if (resume) connection.setRequestProperty("Range", "bytes=${dFile.length()}-")
|
if (resume) connection.setRequestProperty("Range", "bytes=${fileLength}-")
|
||||||
val resumeLength = (if (resume) dFile.length() else 0)
|
val resumeLength = (if (resume) fileLength else 0)
|
||||||
|
|
||||||
// ON CONNECTION
|
// ON CONNECTION
|
||||||
connection.connect()
|
connection.connect()
|
||||||
|
@ -497,8 +579,7 @@ object VideoDownloadManager {
|
||||||
ERROR_CONNECTION_ERROR
|
ERROR_CONNECTION_ERROR
|
||||||
}
|
}
|
||||||
isStopped -> {
|
isStopped -> {
|
||||||
dFile.delete()
|
deleteFile()
|
||||||
SUCCESS_STOPPED
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
isDone = true
|
isDone = true
|
||||||
|
@ -510,17 +591,23 @@ object VideoDownloadManager {
|
||||||
|
|
||||||
private fun downloadCheck(context: Context) {
|
private fun downloadCheck(context: Context) {
|
||||||
if (currentDownloads < maxConcurrentDownloads && downloadQueue.size > 0) {
|
if (currentDownloads < maxConcurrentDownloads && downloadQueue.size > 0) {
|
||||||
val item = downloadQueue.removeFirst()
|
val pkg = downloadQueue.removeFirst()
|
||||||
|
val item = pkg.item
|
||||||
currentDownloads++
|
currentDownloads++
|
||||||
try {
|
try {
|
||||||
main {
|
main {
|
||||||
for (link in item.links) {
|
for (index in (pkg.linkIndex ?: 0) until item.links.size) {
|
||||||
|
val link = item.links[index]
|
||||||
|
val resume = pkg.linkIndex == index
|
||||||
|
|
||||||
|
context.setKey(KEY_RESUME_STORAGE, item.ep.id.toString(), DownloadResumePackage(item, index))
|
||||||
val connectionResult = withContext(Dispatchers.IO) {
|
val connectionResult = withContext(Dispatchers.IO) {
|
||||||
normalSafeApiCall {
|
normalSafeApiCall {
|
||||||
downloadSingleEpisode(context, item.source, item.folder, item.ep, link)
|
downloadSingleEpisode(context, item.source, item.folder, item.ep, link, resume)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (connectionResult != null && connectionResult > 0) { // SUCCESS
|
if (connectionResult != null && connectionResult > 0) { // SUCCESS
|
||||||
|
context.removeKey(KEY_RESUME_STORAGE, item.ep.id.toString())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -534,6 +621,11 @@ object VideoDownloadManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun downloadFromResume(context: Context, pkg: DownloadResumePackage) {
|
||||||
|
downloadQueue.addLast(pkg)
|
||||||
|
downloadCheck(context)
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadEpisode(
|
fun downloadEpisode(
|
||||||
context: Context,
|
context: Context,
|
||||||
source: String,
|
source: String,
|
||||||
|
@ -543,8 +635,7 @@ object VideoDownloadManager {
|
||||||
) {
|
) {
|
||||||
val validLinks = links.filter { !it.isM3u8 }
|
val validLinks = links.filter { !it.isM3u8 }
|
||||||
if (validLinks.isNotEmpty()) {
|
if (validLinks.isNotEmpty()) {
|
||||||
downloadQueue.addLast(DownloadItem(source, folder, ep, validLinks))
|
downloadFromResume(context, DownloadResumePackage(DownloadItem(source, folder, ep, validLinks), null))
|
||||||
downloadCheck(context)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue