downloads not working!!

This commit is contained in:
LagradOst 2021-07-04 19:00:04 +02:00
parent 3210e71eca
commit 728874fc03
8 changed files with 340 additions and 179 deletions

View file

@ -33,6 +33,12 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".services.VideoDownloadService"
android:enabled="true"
android:exported="false">
</service>
<activity android:name=".ui.ControllerActivity"> <activity android:name=".ui.ControllerActivity">
</activity> </activity>

View file

@ -1,16 +1,24 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
import android.Manifest
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.documentfile.provider.DocumentFile
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.StorageId
import com.anggrayudi.storage.file.StorageType
import com.anggrayudi.storage.file.getStorageId
import com.google.android.gms.cast.ApplicationMetadata import com.google.android.gms.cast.ApplicationMetadata
import com.google.android.gms.cast.Cast import com.google.android.gms.cast.Cast
import com.google.android.gms.cast.LaunchOptions import com.google.android.gms.cast.LaunchOptions
@ -19,6 +27,9 @@ import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomnavigation.BottomNavigationView
import com.karumi.dexter.Dexter
import com.karumi.dexter.MultiplePermissionsReport
import com.karumi.dexter.listener.multi.BaseMultiplePermissionsListener
import com.lagradost.cloudstream3.UIHelper.checkWrite import com.lagradost.cloudstream3.UIHelper.checkWrite
import com.lagradost.cloudstream3.UIHelper.hasPIPPermission import com.lagradost.cloudstream3.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.UIHelper.requestRW import com.lagradost.cloudstream3.UIHelper.requestRW
@ -118,7 +129,10 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
mainContext = this mainContext = this
setupSimpleStorage() setupSimpleStorage()
if(!storage.isStorageAccessGranted(StorageId.PRIMARY)) {
storage.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS) storage.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS)
}
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
val navView: BottomNavigationView = findViewById(R.id.nav_view) val navView: BottomNavigationView = findViewById(R.id.nav_view)

View file

@ -0,0 +1,23 @@
package com.lagradost.cloudstream3.services
import android.app.IntentService
import android.content.Intent
import com.lagradost.cloudstream3.utils.VideoDownloadManager
class VideoDownloadService : IntentService("VideoDownloadService") {
override fun onHandleIntent(intent: Intent?) {
if (intent != null) {
val id = intent.getIntExtra("id", -1)
val type = intent.getStringExtra("type")
if (id != -1 && type != null) {
val state = when (type) {
"resume" -> VideoDownloadManager.DownloadActionType.Resume
"pause" -> VideoDownloadManager.DownloadActionType.Pause
"stop" -> VideoDownloadManager.DownloadActionType.Stop
else -> return
}
VideoDownloadManager.events.invoke(Pair(id, state))
}
}
}
}

View file

@ -48,6 +48,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import jp.wasabeef.glide.transformations.BlurTransformation import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.android.synthetic.main.fragment_result.* import kotlinx.android.synthetic.main.fragment_result.*
@ -343,10 +344,10 @@ class ResultFragment : Fragment() {
viewModel.loadEpisode(episodeClick.data, true) { data -> viewModel.loadEpisode(episodeClick.data, true) { data ->
if (data is Resource.Success) { if (data is Resource.Success) {
val isMovie = currentIsMovie ?: return@loadEpisode val isMovie = currentIsMovie ?: return@loadEpisode
val titleName = currentHeaderName?: return@loadEpisode val titleName = sanitizeFilename(currentHeaderName ?: return@loadEpisode)
val meta = VideoDownloadManager.DownloadEpisodeMetadata( val meta = VideoDownloadManager.DownloadEpisodeMetadata(
episodeClick.data.id, episodeClick.data.id,
titleName , titleName,
apiName ?: return@loadEpisode, apiName ?: return@loadEpisode,
episodeClick.data.poster ?: currentPoster, episodeClick.data.poster ?: currentPoster,
episodeClick.data.name, episodeClick.data.name,
@ -362,7 +363,7 @@ class ResultFragment : Fragment() {
else -> null else -> null
} }
VideoDownloadManager.DownloadEpisode( VideoDownloadManager.downloadEpisode(
requireContext(), requireContext(),
tempUrl, tempUrl,
folder, folder,

View file

@ -2,11 +2,12 @@ package com.lagradost.cloudstream3.utils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
object Coroutines { object Coroutines {
fun main(work: suspend (() -> Unit)) { fun main(work: suspend (() -> Unit)) : Job {
CoroutineScope(Dispatchers.Main).launch { return CoroutineScope(Dispatchers.Main).launch {
work() work()
} }
} }

View file

@ -7,25 +7,33 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.Environment
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
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.DocumentFileCompat
import com.anggrayudi.storage.file.forceDelete
import com.anggrayudi.storage.file.openOutputStream import com.anggrayudi.storage.file.openOutputStream
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.exoplayer2.offline.DownloadService
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.InputStream import java.io.InputStream
import java.lang.Thread.sleep
import java.net.URL import java.net.URL
import java.net.URLConnection import java.net.URLConnection
import java.util.*
import kotlin.collections.ArrayList
const val CHANNEL_ID = "cloudstream3.general" const val CHANNEL_ID = "cloudstream3.general"
const val CHANNEL_NAME = "Downloads" const val CHANNEL_NAME = "Downloads"
@ -33,6 +41,7 @@ const val CHANNEL_DESCRIPT = "The download notification channel"
object VideoDownloadManager { object VideoDownloadManager {
var maxConcurrentDownloads = 3 var maxConcurrentDownloads = 3
var currentDownloads = 0
private const val USER_AGENT = private const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
@ -85,6 +94,26 @@ object VideoDownloadManager {
val episode: Int? val episode: Int?
) )
data class DownloadItem(
val source: String,
val folder: String?,
val ep: DownloadEpisodeMetadata,
val links: List<ExtractorLink>
)
private const val SUCCESS_DOWNLOAD_DONE = 1
private const val SUCCESS_STOPPED = 2
private const val ERROR_DELETING_FILE = -1
private const val ERROR_FILE_NOT_FOUND = -2
private const val ERROR_OPEN_FILE = -3
private const val ERROR_TOO_SMALL_CONNECTION = -4
private const val ERROR_WRONG_CONTENT = -5
private const val ERROR_CONNECTION_ERROR = -6
val events = Event<Pair<Int, DownloadActionType>>()
private val downloadQueue = LinkedList<DownloadItem>()
private var hasCreatedNotChanel = false private var hasCreatedNotChanel = false
private fun Context.createNotificationChannel() { private fun Context.createNotificationChannel() {
hasCreatedNotChanel = true hasCreatedNotChanel = true
@ -129,6 +158,7 @@ object VideoDownloadManager {
progress: Long, progress: Long,
total: Long, total: Long,
) { ) {
main { // DON'T WANT TO SLOW IT DOWN
val builder = NotificationCompat.Builder(context, CHANNEL_ID) val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setAutoCancel(true) .setAutoCancel(true)
.setColorized(true) .setColorized(true)
@ -174,7 +204,9 @@ object VideoDownloadManager {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (ep.poster != null) { if (ep.poster != null) {
val poster = context.getImageBitmapFromUrl(ep.poster) val poster = withContext(Dispatchers.IO) {
context.getImageBitmapFromUrl(ep.poster)
}
if (poster != null) if (poster != null)
builder.setLargeIcon(poster) builder.setLargeIcon(poster)
} }
@ -226,7 +258,7 @@ object VideoDownloadManager {
// ADD ACTIONS // ADD ACTIONS
for ((index, i) in actionTypes.withIndex()) { for ((index, i) in actionTypes.withIndex()) {
val actionResultIntent = Intent(context, DownloadService::class.java) val actionResultIntent = Intent(context, VideoDownloadService::class.java)
actionResultIntent.putExtra( actionResultIntent.putExtra(
"type", when (i) { "type", when (i) {
@ -269,28 +301,38 @@ object VideoDownloadManager {
notify(ep.id, builder.build()) notify(ep.id, builder.build())
} }
} }
}
private const val reservedChars = "|\\?*<\":>+[]/'" private const val reservedChars = "|\\?*<\":>+[]/\'"
private fun sanitizeFilename(name: String): String { fun sanitizeFilename(name: String): String {
var tempName = name var tempName = name
for (c in reservedChars) { for (c in reservedChars) {
tempName = tempName.replace(c, ' ') tempName = tempName.replace(c, ' ')
} }
return tempName.replace(" ", " ") return tempName.replace(" ", " ").trim(' ')
} }
fun DownloadSingleEpisode( private const val reservedCharsPath = "|\\?*<\":>+[]\'"
fun sanitizePath(name: String): String {
var tempName = name
for (c in reservedCharsPath) {
tempName = tempName.replace(c, ' ')
}
return tempName.replace(" ", " ").trim(' ')
}
private fun downloadSingleEpisode(
context: Context, context: Context,
source: String?, source: String?,
folder: String?, folder: String?,
ep: DownloadEpisodeMetadata, ep: DownloadEpisodeMetadata,
link: ExtractorLink link: ExtractorLink
): Boolean { ): Int {
val name = (ep.name ?: "Episode ${ep.episode}") val name = sanitizeFilename(ep.name ?: "Episode ${ep.episode}")
val path = sanitizeFilename("Download/${if (folder == null) "" else "$folder/"}$name") val path = "${Environment.DIRECTORY_DOWNLOADS}/${if (folder == null) "" else "$folder/"}$name.mp4"
var resume = false var resume = false
// IF RESUME, DELETE FILE IF FOUND AND RECREATE // IF RESUME, DON'T DELETE FILE, CONTINUE, RECREATE IF NOT FOUND
// IF NOT RESUME CREATE FILE // IF NOT RESUME CREATE FILE
val tempFile = DocumentFileCompat.fromSimplePath(context, basePath = path) val tempFile = DocumentFileCompat.fromSimplePath(context, basePath = path)
val fileExists = tempFile?.exists() ?: false val fileExists = tempFile?.exists() ?: false
@ -298,7 +340,7 @@ object VideoDownloadManager {
if (!fileExists) resume = false if (!fileExists) resume = false
if (fileExists && !resume) { if (fileExists && !resume) {
if (tempFile?.delete() == false) { // DELETE FAILED ON RESUME FILE if (tempFile?.delete() == false) { // DELETE FAILED ON RESUME FILE
return false return ERROR_DELETING_FILE
} }
} }
@ -308,13 +350,12 @@ object VideoDownloadManager {
// END OF FILE CREATION // END OF FILE CREATION
if (dFile == null) { if (dFile == null || !dFile.exists()) {
println("FUCK YOU") return ERROR_FILE_NOT_FOUND
return false
} }
// OPEN FILE // OPEN FILE
val fileStream = dFile.openOutputStream(context, resume) ?: return false val fileStream = dFile.openOutputStream(context, resume) ?: return ERROR_OPEN_FILE
// CONNECT // CONNECT
val connection: URLConnection = URL(link.url).openConnection() val connection: URLConnection = URL(link.url).openConnection()
@ -331,15 +372,16 @@ object VideoDownloadManager {
connection.connect() connection.connect()
val contentLength = connection.contentLength val contentLength = connection.contentLength
val bytesTotal = contentLength + resumeLength val bytesTotal = contentLength + resumeLength
if (bytesTotal < 5000000) return false // DATA IS LESS THAN 5MB, SOMETHING IS WRONG if (bytesTotal < 5000000) return ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG
// Could use connection.contentType for mime types when creating the file, // Could use connection.contentType for mime types when creating the file,
// however file is already created and players don't go of file type // however file is already created and players don't go of file type
// https://stackoverflow.com/questions/23714383/what-are-all-the-possible-values-for-http-content-type-header // https://stackoverflow.com/questions/23714383/what-are-all-the-possible-values-for-http-content-type-header
if(!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) { // might receive application/octet-stream
return false // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE /*if (!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) {
} return ERROR_WRONG_CONTENT // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE
}*/
// READ DATA FROM CONNECTION // READ DATA FROM CONNECTION
val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream) val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream)
@ -347,8 +389,21 @@ object VideoDownloadManager {
var count: Int var count: Int
var bytesDownloaded = resumeLength var bytesDownloaded = resumeLength
var isPaused = false
var isStopped = false
var isDone = false
var isFailed = false
// TO NOT REUSE CODE // TO NOT REUSE CODE
fun updateNotification(type: DownloadType) { fun updateNotification() {
val type = when {
isDone -> DownloadType.IsDone
isStopped -> DownloadType.IsStopped
isFailed -> DownloadType.IsFailed
isPaused -> DownloadType.IsPaused
else -> DownloadType.IsDownloading
}
createNotification( createNotification(
context, context,
source, source,
@ -360,24 +415,108 @@ object VideoDownloadManager {
) )
} }
while (true) { // TODO PAUSE fun onEvent(event: Pair<Int, DownloadActionType>) {
if (event.first == ep.id) {
when (event.second) {
DownloadActionType.Pause -> {
isPaused = true; updateNotification()
}
DownloadActionType.Stop -> {
isStopped = true; updateNotification()
}
DownloadActionType.Resume -> {
isPaused = false; updateNotification()
}
}
}
}
events += ::onEvent
// UPDATE DOWNLOAD NOTIFICATION
val notificationCoroutine = main {
while (true) {
if (!isPaused) {
updateNotification()
}
for (i in 1..10) {
delay(100)
}
}
}
// THE REAL READ
try {
while (true) {
count = connectionInputStream.read(buffer) count = connectionInputStream.read(buffer)
if (count < 0) break if (count < 0) break
bytesDownloaded += count bytesDownloaded += count
while (isPaused) {
updateNotification(DownloadType.IsDownloading) sleep(100)
if (isStopped) {
break
}
}
if (isStopped) {
break
}
fileStream.write(buffer, 0, count) fileStream.write(buffer, 0, count)
} }
} catch (e: Exception) {
// DOWNLOAD EXITED CORRECTLY isFailed = true
updateNotification(DownloadType.IsDone) updateNotification()
fileStream.closeStream()
connectionInputStream.closeStream()
return true
} }
public fun DownloadEpisode( // REMOVE AND EXIT ALL
events -= ::onEvent
fileStream.closeStream()
connectionInputStream.closeStream()
notificationCoroutine.cancel()
// RETURN MESSAGE
return when {
isFailed -> {
ERROR_CONNECTION_ERROR
}
isStopped -> {
dFile.delete()
SUCCESS_STOPPED
}
else -> {
isDone = true
updateNotification()
SUCCESS_DOWNLOAD_DONE
}
}
}
private fun downloadCheck(context: Context) {
if (currentDownloads < maxConcurrentDownloads && downloadQueue.size > 0) {
val item = downloadQueue.removeFirst()
currentDownloads++
try {
main {
for (link in item.links) {
val connectionResult = withContext(Dispatchers.IO) {
normalSafeApiCall {
downloadSingleEpisode(context, item.source, item.folder, item.ep, link)
}
}
if (connectionResult != null && connectionResult > 0) { // SUCCESS
break
}
}
}
} catch (e: Exception) {
logError(e)
} finally {
currentDownloads--
downloadCheck(context)
}
}
}
fun downloadEpisode(
context: Context, context: Context,
source: String, source: String,
folder: String?, folder: String?,
@ -386,16 +525,8 @@ object VideoDownloadManager {
) { ) {
val validLinks = links.filter { !it.isM3u8 } val validLinks = links.filter { !it.isM3u8 }
if (validLinks.isNotEmpty()) { if (validLinks.isNotEmpty()) {
try { downloadQueue.addLast(DownloadItem(source, folder, ep, validLinks))
main { downloadCheck(context)
withContext(Dispatchers.IO) {
DownloadSingleEpisode(context, source, folder, ep, validLinks.first())
}
}
} catch (e: Exception) {
println(e)
e.printStackTrace()
}
} }
} }
} }

View file

@ -1,16 +0,0 @@
package com.lagradost.cloudstream3.utils
import android.app.IntentService
import android.content.Intent
class VideoDownloadService : IntentService("DownloadService") {
override fun onHandleIntent(intent: Intent?) {
if (intent != null) {
val id = intent.getIntExtra("id", -1)
val type = intent.getStringExtra("type")
if (id != -1 && type != null) {
}
}
}
}

View file

@ -37,4 +37,5 @@
<string name="result_go_back">Go Back</string> <string name="result_go_back">Go Back</string>
<string name="episode_poster">Episode Poster</string> <string name="episode_poster">Episode Poster</string>
<string name="play_episode">Play Episode</string> <string name="play_episode">Play Episode</string>
<string name="need_storage">Allow to download episodes</string>
</resources> </resources>