mirror of
				https://github.com/recloudstream/cloudstream.git
				synced 2024-08-15 01:53:11 +00:00 
			
		
		
		
	downloads not working!!
This commit is contained in:
		
							parent
							
								
									3210e71eca
								
							
						
					
					
						commit
						728874fc03
					
				
					 8 changed files with 340 additions and 179 deletions
				
			
		|  | @ -33,6 +33,12 @@ | |||
|             </intent-filter> | ||||
|         </activity> | ||||
| 
 | ||||
|         <service | ||||
|                 android:name=".services.VideoDownloadService" | ||||
|                 android:enabled="true" | ||||
|                 android:exported="false"> | ||||
|         </service> | ||||
| 
 | ||||
|         <activity android:name=".ui.ControllerActivity"> | ||||
|         </activity> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,16 +1,24 @@ | |||
| package com.lagradost.cloudstream3 | ||||
| 
 | ||||
| import android.Manifest | ||||
| import android.app.PictureInPictureParams | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.content.pm.PackageManager | ||||
| import android.net.Uri | ||||
| import android.os.Build | ||||
| import android.os.Bundle | ||||
| import android.widget.Toast | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.documentfile.provider.DocumentFile | ||||
| import androidx.navigation.findNavController | ||||
| import androidx.navigation.ui.AppBarConfiguration | ||||
| import androidx.navigation.ui.setupWithNavController | ||||
| 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.Cast | ||||
| 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.SessionManagerListener | ||||
| 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.hasPIPPermission | ||||
| import com.lagradost.cloudstream3.UIHelper.requestRW | ||||
|  | @ -118,7 +129,10 @@ class MainActivity : AppCompatActivity() { | |||
|         super.onCreate(savedInstanceState) | ||||
|         mainContext = this | ||||
|         setupSimpleStorage() | ||||
| 
 | ||||
|         if(!storage.isStorageAccessGranted(StorageId.PRIMARY)) { | ||||
|             storage.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS) | ||||
|         } | ||||
| 
 | ||||
|         setContentView(R.layout.activity_main) | ||||
|         val navView: BottomNavigationView = findViewById(R.id.nav_view) | ||||
|  |  | |||
|  | @ -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)) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -48,6 +48,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos | |||
| 
 | ||||
| import com.lagradost.cloudstream3.utils.ExtractorLink | ||||
| import com.lagradost.cloudstream3.utils.VideoDownloadManager | ||||
| import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename | ||||
| import jp.wasabeef.glide.transformations.BlurTransformation | ||||
| import kotlinx.android.synthetic.main.fragment_result.* | ||||
| 
 | ||||
|  | @ -343,7 +344,7 @@ class ResultFragment : Fragment() { | |||
|                         viewModel.loadEpisode(episodeClick.data, true) { data -> | ||||
|                             if (data is Resource.Success) { | ||||
|                                 val isMovie = currentIsMovie ?: return@loadEpisode | ||||
|                                 val titleName = currentHeaderName?: return@loadEpisode | ||||
|                                 val titleName = sanitizeFilename(currentHeaderName ?: return@loadEpisode) | ||||
|                                 val meta = VideoDownloadManager.DownloadEpisodeMetadata( | ||||
|                                     episodeClick.data.id, | ||||
|                                     titleName, | ||||
|  | @ -362,7 +363,7 @@ class ResultFragment : Fragment() { | |||
|                                     else -> null | ||||
|                                 } | ||||
| 
 | ||||
|                                 VideoDownloadManager.DownloadEpisode( | ||||
|                                 VideoDownloadManager.downloadEpisode( | ||||
|                                     requireContext(), | ||||
|                                     tempUrl, | ||||
|                                     folder, | ||||
|  |  | |||
|  | @ -2,11 +2,12 @@ package com.lagradost.cloudstream3.utils | |||
| 
 | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.launch | ||||
| 
 | ||||
| object Coroutines { | ||||
|     fun main(work: suspend (() -> Unit)) { | ||||
|         CoroutineScope(Dispatchers.Main).launch { | ||||
|     fun main(work: suspend (() -> Unit)) : Job { | ||||
|         return CoroutineScope(Dispatchers.Main).launch { | ||||
|             work() | ||||
|         } | ||||
|     } | ||||
|  |  | |||
|  | @ -7,25 +7,33 @@ import android.content.Context | |||
| import android.content.Intent | ||||
| import android.graphics.Bitmap | ||||
| import android.os.Build | ||||
| import android.os.Environment | ||||
| import androidx.annotation.DrawableRes | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.core.net.toUri | ||||
| 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.google.android.exoplayer2.offline.DownloadService | ||||
| import com.lagradost.cloudstream3.MainActivity | ||||
| import com.lagradost.cloudstream3.R | ||||
| 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 kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.delay | ||||
| import kotlinx.coroutines.withContext | ||||
| import java.io.BufferedInputStream | ||||
| import java.io.InputStream | ||||
| import java.lang.Thread.sleep | ||||
| import java.net.URL | ||||
| import java.net.URLConnection | ||||
| import java.util.* | ||||
| import kotlin.collections.ArrayList | ||||
| 
 | ||||
| const val CHANNEL_ID = "cloudstream3.general" | ||||
| const val CHANNEL_NAME = "Downloads" | ||||
|  | @ -33,6 +41,7 @@ const val CHANNEL_DESCRIPT = "The download notification channel" | |||
| 
 | ||||
| object VideoDownloadManager { | ||||
|     var maxConcurrentDownloads = 3 | ||||
|     var currentDownloads = 0 | ||||
| 
 | ||||
|     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" | ||||
|  | @ -85,6 +94,26 @@ object VideoDownloadManager { | |||
|         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 fun Context.createNotificationChannel() { | ||||
|         hasCreatedNotChanel = true | ||||
|  | @ -129,6 +158,7 @@ object VideoDownloadManager { | |||
|         progress: Long, | ||||
|         total: Long, | ||||
|     ) { | ||||
|         main { // DON'T WANT TO SLOW IT DOWN | ||||
|             val builder = NotificationCompat.Builder(context, CHANNEL_ID) | ||||
|                 .setAutoCancel(true) | ||||
|                 .setColorized(true) | ||||
|  | @ -174,7 +204,9 @@ object VideoDownloadManager { | |||
| 
 | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||||
|                 if (ep.poster != null) { | ||||
|                 val poster = context.getImageBitmapFromUrl(ep.poster) | ||||
|                     val poster = withContext(Dispatchers.IO) { | ||||
|                         context.getImageBitmapFromUrl(ep.poster) | ||||
|                     } | ||||
|                     if (poster != null) | ||||
|                         builder.setLargeIcon(poster) | ||||
|                 } | ||||
|  | @ -226,7 +258,7 @@ object VideoDownloadManager { | |||
| 
 | ||||
|                 // ADD ACTIONS | ||||
|                 for ((index, i) in actionTypes.withIndex()) { | ||||
|                 val actionResultIntent = Intent(context, DownloadService::class.java) | ||||
|                     val actionResultIntent = Intent(context, VideoDownloadService::class.java) | ||||
| 
 | ||||
|                     actionResultIntent.putExtra( | ||||
|                         "type", when (i) { | ||||
|  | @ -269,28 +301,38 @@ object VideoDownloadManager { | |||
|                 notify(ep.id, builder.build()) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private const val reservedChars = "|\\?*<\":>+[]/'" | ||||
|     private fun sanitizeFilename(name: String): String { | ||||
|     private const val reservedChars = "|\\?*<\":>+[]/\'" | ||||
|     fun sanitizeFilename(name: String): String { | ||||
|         var tempName = name | ||||
|         for (c in reservedChars) { | ||||
|             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, | ||||
|         source: String?, | ||||
|         folder: String?, | ||||
|         ep: DownloadEpisodeMetadata, | ||||
|         link: ExtractorLink | ||||
|     ): Boolean { | ||||
|         val name = (ep.name ?: "Episode ${ep.episode}") | ||||
|         val path = sanitizeFilename("Download/${if (folder == null) "" else "$folder/"}$name") | ||||
|     ): Int { | ||||
|         val name = sanitizeFilename(ep.name ?: "Episode ${ep.episode}") | ||||
|         val path = "${Environment.DIRECTORY_DOWNLOADS}/${if (folder == null) "" else "$folder/"}$name.mp4" | ||||
|         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 | ||||
|         val tempFile = DocumentFileCompat.fromSimplePath(context, basePath = path) | ||||
|         val fileExists = tempFile?.exists() ?: false | ||||
|  | @ -298,7 +340,7 @@ object VideoDownloadManager { | |||
|         if (!fileExists) resume = false | ||||
|         if (fileExists && !resume) { | ||||
|             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 | ||||
| 
 | ||||
|         if (dFile == null) { | ||||
|             println("FUCK YOU") | ||||
|             return false | ||||
|         if (dFile == null || !dFile.exists()) { | ||||
|             return ERROR_FILE_NOT_FOUND | ||||
|         } | ||||
| 
 | ||||
|         // OPEN FILE | ||||
|         val fileStream = dFile.openOutputStream(context, resume) ?: return false | ||||
|         val fileStream = dFile.openOutputStream(context, resume) ?: return ERROR_OPEN_FILE | ||||
| 
 | ||||
|         // CONNECT | ||||
|         val connection: URLConnection = URL(link.url).openConnection() | ||||
|  | @ -331,15 +372,16 @@ object VideoDownloadManager { | |||
|         connection.connect() | ||||
|         val contentLength = connection.contentLength | ||||
|         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, | ||||
|         // 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 | ||||
|         if(!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) { | ||||
|             return false // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE | ||||
|         } | ||||
|         // might receive application/octet-stream | ||||
|         /*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 | ||||
|         val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream) | ||||
|  | @ -347,8 +389,21 @@ object VideoDownloadManager { | |||
|         var count: Int | ||||
|         var bytesDownloaded = resumeLength | ||||
| 
 | ||||
| 
 | ||||
|         var isPaused = false | ||||
|         var isStopped = false | ||||
|         var isDone = false | ||||
|         var isFailed = false | ||||
| 
 | ||||
|         // 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( | ||||
|                 context, | ||||
|                 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) | ||||
|                 if (count < 0) break | ||||
|                 bytesDownloaded += count | ||||
| 
 | ||||
|             updateNotification(DownloadType.IsDownloading) | ||||
|                 while (isPaused) { | ||||
|                     sleep(100) | ||||
|                     if (isStopped) { | ||||
|                         break | ||||
|                     } | ||||
|                 } | ||||
|                 if (isStopped) { | ||||
|                     break | ||||
|                 } | ||||
|                 fileStream.write(buffer, 0, count) | ||||
|             } | ||||
| 
 | ||||
|         // DOWNLOAD EXITED CORRECTLY | ||||
|         updateNotification(DownloadType.IsDone) | ||||
|         fileStream.closeStream() | ||||
|         connectionInputStream.closeStream() | ||||
| 
 | ||||
|         return true | ||||
|         } catch (e: Exception) { | ||||
|             isFailed = true | ||||
|             updateNotification() | ||||
|         } | ||||
| 
 | ||||
|     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, | ||||
|         source: String, | ||||
|         folder: String?, | ||||
|  | @ -386,16 +525,8 @@ object VideoDownloadManager { | |||
|     ) { | ||||
|         val validLinks = links.filter { !it.isM3u8 } | ||||
|         if (validLinks.isNotEmpty()) { | ||||
|             try { | ||||
|                 main { | ||||
|                     withContext(Dispatchers.IO) { | ||||
|                         DownloadSingleEpisode(context, source, folder, ep, validLinks.first()) | ||||
|                     } | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|                 println(e) | ||||
|                 e.printStackTrace() | ||||
|             } | ||||
|             downloadQueue.addLast(DownloadItem(source, folder, ep, validLinks)) | ||||
|             downloadCheck(context) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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) { | ||||
| 
 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -37,4 +37,5 @@ | |||
|     <string name="result_go_back">Go Back</string> | ||||
|     <string name="episode_poster">Episode Poster</string> | ||||
|     <string name="play_episode">Play Episode</string> | ||||
|     <string name="need_storage">Allow to download episodes</string> | ||||
| </resources> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue