diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9e1bc9ac978..56622aab916 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -216,6 +216,12 @@
android:foregroundServiceType="dataSync"
android:exported="false" />
+
+
{
+ in listOf(R.id.navigation_downloads, R.id.navigation_download_child, R.id.navigation_download_queue) -> {
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
}
@@ -1164,13 +1174,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
app.initClient(this)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val errorFile = filesDir.resolve("last_error")
- if (errorFile.exists() && errorFile.isFile) {
- lastError = errorFile.readText(Charset.defaultCharset())
- errorFile.delete()
- } else {
- lastError = null
- }
+ setLastError(this)
val settingsForProvider = SettingsJson()
settingsForProvider.enableAdult =
@@ -2032,6 +2036,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
updateLocale()
runDefault()
}
+
+ // Start the download queue
+ DownloadQueueManager.init(this)
}
/** Biometric stuff **/
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
index 1b5d2909c3f..ba3357102c7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
@@ -29,6 +29,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
+import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
import com.lagradost.cloudstream3.R
@@ -51,7 +52,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UiText
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
+import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader
@@ -572,6 +573,11 @@ object PluginManager {
afterPluginsLoadedEvent.invoke(forceReload)
}
+ /** @return true if safe mode is enabled in any possible way. */
+ fun isSafeMode(): Boolean {
+ return checkSafeModeFile() || lastError != null
+ }
+
/**
* This can be used to override any extension loading to fix crashes!
* @return true if safe mode file is present
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt
new file mode 100644
index 00000000000..37b9a100228
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt
@@ -0,0 +1,262 @@
+package com.lagradost.cloudstream3.services
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+import android.os.Build.VERSION.SDK_INT
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.PendingIntentCompat
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
+import com.lagradost.cloudstream3.MainActivity
+import com.lagradost.cloudstream3.MainActivity.Companion.lastError
+import com.lagradost.cloudstream3.MainActivity.Companion.setLastError
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.mvvm.debugAssert
+import com.lagradost.cloudstream3.mvvm.debugWarning
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.safe
+import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
+import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.updateAndGet
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.system.measureTimeMillis
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+class DownloadQueueService : Service() {
+ companion object {
+ const val TAG = "DownloadQueueService"
+ const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue"
+ const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service"
+ const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification."
+ const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique
+ @Volatile
+ var isRunning = false
+
+ fun getIntent(
+ context: Context,
+ ): Intent {
+ return Intent(context, DownloadQueueService::class.java)
+ }
+
+ private val _downloadInstances: MutableStateFlow> =
+ MutableStateFlow(emptyList())
+
+ /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances.
+ * Completed or failed instances are automatically removed by the download queue service.
+ *
+ */
+ val downloadInstances: StateFlow> =
+ _downloadInstances
+
+ private val totalDownloadFlow =
+ downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
+ instances to queue
+ }
+ .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads ->
+ Triple(instances, queue, currentDownloads)
+ }
+ }
+
+
+ private val baseNotification by lazy {
+ val intent = Intent(this, MainActivity::class.java)
+ val pendingIntent =
+ PendingIntentCompat.getActivity(this, 0, intent, 0, false)
+
+ val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0)
+ val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0)
+
+ NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID)
+ .setOngoing(true) // Make it persistent
+ .setAutoCancel(false)
+ .setColorized(false)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setShowWhen(false)
+ // If low priority then the notification might not show :(
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setColor(this.colorFromAttribute(R.attr.colorPrimary))
+ .setContentText(activeDownloads)
+ .setSubText(activeQueue)
+ .setContentIntent(pendingIntent)
+ .setSmallIcon(R.drawable.download_icon_load)
+ }
+
+
+ private fun updateNotification(context: Context, downloads: Int, queued: Int) {
+ val activeDownloads =
+ resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads)
+ val activeQueue =
+ resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued)
+
+ val newNotification = baseNotification
+ .setContentText(activeDownloads)
+ .setSubText(activeQueue)
+ .build()
+
+ safe {
+ NotificationManagerCompat.from(context)
+ .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification)
+ }
+ }
+
+ // We always need to listen to events, even before the download is launched.
+ // Stopping link loading is an event which can trigger before downloading.
+ val downloadEventListener = { event: Pair ->
+ when (event.second) {
+ VideoDownloadManager.DownloadActionType.Stop -> {
+ removeKey(KEY_RESUME_PACKAGES, event.first.toString())
+ removeKey(KEY_RESUME_IN_QUEUE, event.first.toString())
+ DownloadQueueManager.cancelDownload(event.first)
+ }
+
+ else -> {}
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+ override fun onCreate() {
+ isRunning = true
+ val context: Context = this // To make code more readable
+
+ Log.d(TAG, "Download queue service started.")
+ this.createNotificationChannel(
+ DOWNLOAD_QUEUE_CHANNEL_ID,
+ DOWNLOAD_QUEUE_CHANNEL_NAME,
+ DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION
+ )
+ if (SDK_INT >= 29) {
+ startForeground(
+ DOWNLOAD_QUEUE_NOTIFICATION_ID,
+ baseNotification.build(),
+ FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ } else {
+ startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build())
+ }
+
+ downloadEvent += downloadEventListener
+
+ val queueJob = ioSafe {
+ // Ensure this is up to date to prevent race conditions with MainActivity launches
+ setLastError(context)
+ // Early return, to prevent waiting for plugins in safe mode
+ if (lastError != null) return@ioSafe
+
+ // Try to ensure all plugins are loaded before starting the downloader.
+ // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough
+ val timeout = 15.seconds
+ val timeTaken = withTimeoutOrNull(timeout) {
+ measureTimeMillis {
+ while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) {
+ delay(100.milliseconds)
+ }
+ }
+ }
+
+ debugWarning({ timeTaken == null || timeTaken > 3_000 }, {
+ "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms"
+ })
+ debugAssert({ timeTaken == null }, { "Downloader startup should not time out" })
+
+ totalDownloadFlow
+ .takeWhile { (instances, queue) ->
+ // Stop if destroyed
+ isRunning
+ // Run as long as there is a queue to process
+ && (instances.isNotEmpty() || queue.isNotEmpty())
+ // Run as long as there are no app crashes
+ && lastError == null
+ }
+ .collect { (_, queue, currentDownloads) ->
+ // Remove completed or failed
+ val newInstances = _downloadInstances.updateAndGet { currentInstances ->
+ currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled }
+ }
+
+ val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context)
+ val currentInstanceCount = newInstances.size
+
+ val newDownloads = minOf(
+ // Cannot exceed the max downloads
+ maxOf(0, maxDownloads - currentInstanceCount),
+ // Cannot start more downloads than the queue size
+ queue.size
+ )
+
+ // Cant start multiple downloads at once. If this is rerun it may start too many downloads.
+ if (newDownloads > 0) {
+ _downloadInstances.update { instances ->
+ val downloadInstance = DownloadQueueManager.popQueue(context)
+ if (downloadInstance != null) {
+ downloadInstance.startDownload()
+ instances + downloadInstance
+ } else {
+ instances
+ }
+ }
+ }
+
+ // The downloads actually displayed to the user with a notification
+ val currentVisualDownloads =
+ currentDownloads.size + newInstances.count {
+ currentDownloads.contains(it.downloadQueueWrapper.id)
+ .not()
+ }
+ // Just the queue
+ val currentVisualQueue = queue.size
+
+ updateNotification(context, currentVisualDownloads, currentVisualQueue)
+ }
+ }
+
+ // Stop self regardless of job outcome
+ queueJob.invokeOnCompletion { throwable ->
+ if (throwable != null) {
+ logError(throwable)
+ }
+ safe {
+ stopSelf()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "Download queue service stopped.")
+ downloadEvent -= downloadEventListener
+ isRunning = false
+ super.onDestroy()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ return START_STICKY // We want the service restarted if its killed
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onTimeout(reason: Int) {
+ stopSelf()
+ Log.e(TAG, "Service stopped due to timeout: $reason")
+ }
+
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
index fc31c1f3e0d..242f0812964 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
@@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
+import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
index 6151a0edd20..d63b18cdc97 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
@@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services
import android.app.Service
import android.content.Intent
import android.os.IBinder
-import com.lagradost.cloudstream3.utils.VideoDownloadManager
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
+/** Handle notification actions such as pause/resume downloads */
class VideoDownloadService : Service() {
private val downloadScope = CoroutineScope(Dispatchers.Default)
@@ -42,19 +43,3 @@ class VideoDownloadService : Service() {
super.onDestroy()
}
}
-// 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.downloadEvent.invoke(Pair(id, state))
-// }
-// }
-// }
-//}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt
index d0740f66a81..1b48143a635 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt
@@ -19,7 +19,7 @@ import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
-import com.lagradost.cloudstream3.utils.VideoDownloadHelper
+import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1
@@ -27,6 +27,7 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
const val DOWNLOAD_ACTION_DOWNLOAD = 4
const val DOWNLOAD_ACTION_LONG_CLICK = 5
+const val DOWNLOAD_ACTION_CANCEL_PENDING = 6
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
@@ -34,22 +35,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
sealed class VisualDownloadCached {
abstract val currentBytes: Long
abstract val totalBytes: Long
- abstract val data: VideoDownloadHelper.DownloadCached
+ abstract val data: DownloadObjects.DownloadCached
abstract var isSelected: Boolean
data class Child(
override val currentBytes: Long,
override val totalBytes: Long,
- override val data: VideoDownloadHelper.DownloadEpisodeCached,
+ override val data: DownloadObjects.DownloadEpisodeCached,
override var isSelected: Boolean,
) : VisualDownloadCached()
data class Header(
override val currentBytes: Long,
override val totalBytes: Long,
- override val data: VideoDownloadHelper.DownloadHeaderCached,
+ override val data: DownloadObjects.DownloadHeaderCached,
override var isSelected: Boolean,
- val child: VideoDownloadHelper.DownloadEpisodeCached?,
+ val child: DownloadObjects.DownloadEpisodeCached?,
val currentOngoingDownloads: Int,
val totalDownloads: Int,
) : VisualDownloadCached()
@@ -57,12 +58,12 @@ sealed class VisualDownloadCached {
data class DownloadClickEvent(
val action: Int,
- val data: VideoDownloadHelper.DownloadEpisodeCached
+ val data: DownloadObjects.DownloadEpisodeCached
)
data class DownloadHeaderClickEvent(
val action: Int,
- val data: VideoDownloadHelper.DownloadHeaderCached
+ val data: DownloadObjects.DownloadHeaderCached
)
class DownloadAdapter(
@@ -170,6 +171,7 @@ class DownloadAdapter(
}
}
+ downloadButton.resetView()
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
@@ -187,7 +189,6 @@ class DownloadAdapter(
} else {
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
- downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
@@ -277,6 +278,7 @@ class DownloadAdapter(
}
}
+ downloadButton.resetView()
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading
@@ -295,7 +297,6 @@ class DownloadAdapter(
} else {
// We need to make sure we restore the correct progress
// when we refresh data in the adapter.
- downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
index 295feffe8ab..884eebd6292 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
@@ -18,8 +18,9 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate
-import com.lagradost.cloudstream3.utils.VideoDownloadHelper
-import com.lagradost.cloudstream3.utils.VideoDownloadManager
+import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
+import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.MainScope
object DownloadButtonSetup {
@@ -82,7 +83,7 @@ object DownloadButtonSetup {
} else {
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
if (pkg != null) {
- VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg)
+ DownloadQueueManager.addToQueue(pkg.toWrapper())
} else {
VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
@@ -95,7 +96,7 @@ object DownloadButtonSetup {
DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act ->
val length =
- VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
+ VideoDownloadManager.getDownloadFileInfo(
act,
click.data.id
)?.fileLength
@@ -110,24 +111,31 @@ object DownloadButtonSetup {
}
}
+ DOWNLOAD_ACTION_CANCEL_PENDING -> {
+ DownloadQueueManager.cancelDownload(id)
+ }
+
DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act ->
- val parent = getKey(
+ val parent = getKey(
DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString()
) ?: return
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
?.mapNotNull {
- getKey(it)
+ getKey(it)
}
?.filter { it.parentId == click.data.parentId }
val items = mutableListOf()
- val allRelevantEpisodes = episodes?.sortedWith(compareBy { it.season ?: 0 }.thenBy { it.episode })
+ val allRelevantEpisodes =
+ episodes?.sortedWith(compareBy {
+ it.season ?: 0
+ }.thenBy { it.episode })
allRelevantEpisodes?.forEach {
- val keyInfo = getKey(
+ val keyInfo = getKey(
VideoDownloadManager.KEY_DOWNLOAD_INFO,
it.id.toString()
) ?: return@forEach
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
index 3bd424640dd..be9f768a829 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
@@ -29,6 +29,7 @@ import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
+import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator
@@ -58,6 +59,7 @@ class DownloadFragment : BaseFragment(
) {
private val downloadViewModel: DownloadViewModel by activityViewModels()
+ private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels()
private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams(
@@ -142,6 +144,17 @@ class DownloadFragment : BaseFragment(
binding.downloadApp
)
}
+ observe(downloadQueueViewModel.childCards) { cards ->
+ val size = cards.currentDownloads.size + cards.queue.size
+ val context = binding.root.context
+ val baseText = context.getString(R.string.download_queue)
+ binding.downloadQueueText.text = if (size > 0) {
+ "$baseText (${cards.currentDownloads.size}/$size)"
+ } else {
+ baseText
+ }
+ }
+
observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
}
@@ -213,7 +226,7 @@ class DownloadFragment : BaseFragment(
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
- nextDown = FOCUS_SELF,
+ nextDown = R.id.download_queue_button,
)
}
@@ -227,6 +240,10 @@ class DownloadFragment : BaseFragment(
setOnClickListener { showStreamInputDialog(it.context) }
}
+ downloadQueueButton.setOnClickListener {
+ activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue)
+ }
+
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
downloadAppbar.isFocusableInTouchMode = isLayout(TV)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt
index ee69390ff2b..b7c8d98da27 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt
@@ -5,30 +5,44 @@ import android.content.DialogInterface
import android.os.Environment
import android.os.StatFs
import androidx.appcompat.app.AlertDialog
+import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.lagradost.api.Log
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.services.DownloadQueueService
+import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.ConsistentLiveData
+import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
+import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
+import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
+import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
import com.lagradost.cloudstream3.utils.ResourceLiveData
-import com.lagradost.cloudstream3.utils.VideoDownloadHelper
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
+import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
+import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings
+import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() {
+ companion object {
+ const val TAG = "DownloadViewModel"
+ }
+
private val _headerCards =
ResourceLiveData>(Resource.Loading())
val headerCards: LiveData>> = _headerCards
@@ -111,23 +125,103 @@ class DownloadViewModel : ViewModel() {
}
+ fun removeRedundantEpisodeKeys(context: Context, keys: List>) {
+ val settingsManager = context.getSharedPrefs()
+ ioSafe {
+ settingsManager.edit {
+ keys.forEach { (parentId, childId) ->
+ Log.i(TAG, "Removing download episode key: ${parentId}/${childId}")
+ val oldPath = getFolderName(
+ getFolderName(
+ DOWNLOAD_EPISODE_CACHE,
+ parentId.toString()
+ ),
+ childId.toString()
+ )
+ val newPath = getFolderName(
+ getFolderName(
+ DOWNLOAD_EPISODE_CACHE_BACKUP,
+ parentId.toString()
+ ),
+ childId.toString()
+ )
+
+ val oldPref = settingsManager.getString(oldPath, null)
+ // Cowardly future backup solution in case the key removal fails in some edge case.
+ // This and all backup keys may be removed in a future update if the key removal is proven to be robust.
+ this.putString(newPath, oldPref)
+ this.remove(oldPath)
+ }
+ }
+ }
+ }
+
+ fun removeRedundantHeaderKeys(
+ context: Context,
+ cached: List,
+ totalBytesUsedByChild: Map,
+ totalDownloads: Map
+ ) {
+ val settingsManager = context.getSharedPrefs()
+ ioSafe {
+ settingsManager.edit {
+ cached.forEach { header ->
+ val downloads = totalDownloads[header.id] ?: 0
+ val bytes = totalBytesUsedByChild[header.id] ?: 0
+
+ if (downloads <= 0 || bytes <= 0) {
+ Log.i(TAG, "Removing download header key: ${header.id}")
+ val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString())
+ val newPath =
+ getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString())
+ val oldPref = settingsManager.getString(oldPAth, null)
+ // Cowardly future backup solution in case the key removal fails in some edge case.
+ // This and all backup keys may be removed in a future update if the key removal is proven to be robust.
+ this.putString(newPath, oldPref)
+ this.remove(oldPAth)
+ }
+ }
+ }
+ }
+ }
+
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
// Do not push loading as it interrupts the UI
//_headerCards.postValue(Resource.Loading())
- val visual = withContext(Dispatchers.IO) {
+ val visual = ioWork {
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
- .mapNotNull { context.getKey(it) }
+ .mapNotNull { context.getKey(it) }
.distinctBy { it.id } // Remove duplicates
- val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
+ val isCurrentlyDownloading =
+ DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty()
+
+ val downloadStats =
calculateDownloadStats(context, children)
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
- .mapNotNull { context.getKey(it) }
+ .mapNotNull { context.getKey(it) }
+
+ // Download stats and header keys may change when downloading.
+ // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading.
+ if (!isCurrentlyDownloading) {
+ removeRedundantHeaderKeys(
+ context,
+ cached,
+ downloadStats.totalBytesUsedByChild,
+ downloadStats.totalDownloads
+ )
+ }
+ // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required
+ removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads)
createVisualDownloadList(
- context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
+ context,
+ cached,
+ downloadStats.totalBytesUsedByChild,
+ downloadStats.currentBytesUsedByChild,
+ downloadStats.totalDownloads
)
}
@@ -159,20 +253,38 @@ class DownloadViewModel : ViewModel() {
}))
}
+ private data class DownloadStats(
+ val totalBytesUsedByChild: Map,
+ val currentBytesUsedByChild: Map,
+ val totalDownloads: Map,
+ /** Parent ID to child ID. Keys to be removed. */
+ val redundantDownloads: List>
+ )
+
private fun calculateDownloadStats(
context: Context,
- children: List
- ): Triple