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, Map, Map> { + children: List + ): DownloadStats { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() // parentId : bytes val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount val totalDownloads = mutableMapOf() + val redundantDownloads = mutableListOf>() children.forEach { child -> - val childFile = - getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach + val childFile = getDownloadFileInfo(context, child.id) + + if (childFile == null) { + // It may not be a redundant child if something is currently downloading. + // DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader + // leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE + if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) { + redundantDownloads.add(child.parentId to child.id) + } + return@forEach + } if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes @@ -182,12 +294,17 @@ class DownloadViewModel : ViewModel() { currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) totalDownloads.merge(child.parentId, 1, Int::plus) } - return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) + return DownloadStats( + totalBytesUsedByChild, + currentBytesUsedByChild, + totalDownloads, + redundantDownloads + ) } private fun createVisualDownloadList( context: Context, - cached: List, + cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map @@ -196,11 +313,14 @@ class DownloadViewModel : ViewModel() { val downloads = totalDownloads[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0 - if (bytes <= 0 || downloads <= 0) return@mapNotNull null + + if (bytes <= 0 || downloads <= 0) { + return@mapNotNull null + } val isSelected = selectedItemIds.value?.contains(it.id) ?: false val movieEpisode = - if (it.type.isEpisodeBased()) null else context.getKey( + if (it.type.isEpisodeBased()) null else context.getKey( DOWNLOAD_EPISODE_CACHE, getFolderName(it.id.toString(), it.id.toString()) ) @@ -233,11 +353,10 @@ class DownloadViewModel : ViewModel() { val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> - context.getKey(key) + context.getKey(key) }.mapNotNull { val isSelected = selectedItemIds.value?.contains(it.id) ?: false - val info = - getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null + val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null VisualDownloadCached.Child( currentBytes = info.fileLength, totalBytes = info.totalBytes, @@ -313,7 +432,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -337,7 +456,7 @@ class DownloadViewModel : ViewModel() { is VisualDownloadCached.Child -> { ids.add(item.data.id) - val parent = context.getKey( + val parent = context.getKey( DOWNLOAD_HEADER_CACHE, item.data.parentId.toString() ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt index 36a84d9f311..82c4dcc3bed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.mainWork -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType @@ -78,7 +78,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : if (!doSetProgress) return ioSafe { - val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) + val savedData = VideoDownloadManager.getDownloadFileInfo(context, id) mainWork { if (savedData != null) { @@ -87,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) - } else run { resetView() } + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index 20a4446114b..91c5dd72ce3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -8,7 +8,7 @@ import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { @@ -18,6 +18,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) mainText = findViewById(R.id.result_movie_download_text) + setStatus(null) } override fun setStatus(status: DownloadStatusTell?) { @@ -35,7 +36,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index 3181a1bcd94..2fdc94e9cb6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -15,6 +15,8 @@ import androidx.core.view.isVisible import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK @@ -23,9 +25,10 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { @@ -138,10 +141,18 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : recycle() } - resetView() + // resetView() onInflate() } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Re-run all animations when the view gets visible. + // Otherwise views may run without animations after recycled + setStatusInternal(currentStatus) + } + private var currentStatus: DownloadStatusTell? = null /*private fun getActivity(): Activity? { var context = context @@ -162,16 +173,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, + view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView this.setPersistentId(card.id) view.setOnClickListener { if (isZeroBytes) { - removeKey(KEY_RESUME_PACKAGES, card.id.toString()) - callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) - // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) + val localQueue = queue.value + val localInstances = downloadInstances.value + val id = card.id + + // If the download is already in queue or active downloads, provide an option to cancel it + if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { + it.popupMenuNoIcons( + arrayListOf( + Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), + ) + ) { + callback(DownloadClickEvent(itemId, card)) + } + } else { + // Otherwise just start a download instantly + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + } } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), @@ -212,7 +238,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } open fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt new file mode 100644 index 00000000000..877fcfea857 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt @@ -0,0 +1,274 @@ +package com.lagradost.cloudstream3.ui.download.queue + + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO + +/** An item in the adapter can either be a separator or a real item. + * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ +class DownloadAdapterItem(val item: DownloadQueueWrapper?) { + val isSeparator = item == null +} + + +class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.item?.id == b.item?.id }, + contentSame = { a, b -> + a.item == b.item + }) +) { + var currentDownloads = 0 + + companion object { + val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: DownloadAdapterItem, + position: Int + ) { + when (val binding = holder.view) { + is DownloadQueueItemBinding -> { + if (item.item == null) { + holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG + bindSeparator(binding) + } else { + holder.itemView.tag = null + bind(binding, item.item) + } + } + } + } + + fun submitQueue(newQueue: DownloadAdapterQueue) { + val index = newQueue.currentDownloads.size + val current = newQueue.currentDownloads + val queue = newQueue.queue + currentDownloads = current.size + + val newList = + (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() + .apply { + // Only add the separator if it actually separates something + if (index < this.size) { + add(index, DownloadAdapterItem(null)) + } + } + submitList(newList) + } + + fun bindSeparator(binding: DownloadQueueItemBinding) { + binding.apply { + separatorHolder.isGone = false + downloadChildEpisodeHolder.isGone = true + } + } + + fun bind( + binding: DownloadQueueItemBinding, + queueWrapper: DownloadQueueWrapper, + ) { + val context = binding.root.context + + binding.apply { + separatorHolder.isGone = true + downloadChildEpisodeHolder.isGone = false + + // Only set the child-text if child and parent are not the same + // This prevents setting movie titles twice + if (queueWrapper.id != queueWrapper.parentId) { + val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName + downloadChildEpisodeTextExtra.text = mainName + } else { + downloadChildEpisodeTextExtra.text = null + } + + downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() + + val status = VideoDownloadManager.downloadStatus[queueWrapper.id] + + downloadButton.setOnClickListener { view -> + val episodeCached = + getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) + ) + + val downloadInfo = context.getKey( + KEY_DOWNLOAD_INFO, + queueWrapper.id.toString() + ) + + val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() + + val actionList = arrayListOf>() + + if (isCurrentlyDownloading && episodeCached != null) { + // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything + if (downloadInfo != null) { + actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + } + + val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] + + when (currentStatus) { + VideoDownloadManager.DownloadType.IsDownloading -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_PAUSE_DOWNLOAD, + R.string.popup_pause_download + ) + ) + } + + VideoDownloadManager.DownloadType.IsPaused -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + + else -> {} + } + + view.popupMenuNoIcons( + actionList + ) { + handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) + } + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + + view.popupMenuNoIcons( + actionList + ) { + when (itemId) { + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(queueWrapper.id) + } + } + } + } + } + + downloadButton.resetView() + downloadButton.setStatus(status) + downloadButton.setPersistentId(queueWrapper.id) + + downloadChildEpisodeText.apply { + val name = queueWrapper.downloadItem?.episode?.name + ?: queueWrapper.resumePackage?.item?.ep?.name + val episode = + queueWrapper.downloadItem?.episode?.episode + ?: queueWrapper.resumePackage?.item?.ep?.episode + val season = + queueWrapper.downloadItem?.episode?.season + ?: queueWrapper.resumePackage?.item?.ep?.season + text = context.getNameFull(name, episode, season) + isSelected = true // Needed for text repeating + } + } + } +} + + +class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : + ItemTouchHelper( + DragAndDropTouchHelperCallback(adapter) + ) + +private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : + ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + val isDownloading = item.item?.isCurrentlyDownloading() == true + val dragFlags = if (item.isSeparator || isDownloading) { + 0 + } else { + ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down + } + + val swipeFlags = 0 // Disable swipe functionality + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove( + recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = source.absoluteAdapterPosition + val toPosition = target.absoluteAdapterPosition + val separatorPosition = adapter.currentDownloads + + val toPositionNoSeparator = + if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition + + if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { + return false + } else { + adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> + DownloadQueueManager.reorderItem( + downloadQueueInfo, + toPositionNoSeparator - 1 + ) + } + } + + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + + } + + override fun isLongPressDragEnabled(): Boolean { + return true // Enable drag with long press + } + + override fun isItemViewSwipeEnabled(): Boolean { + return false // Disable swipe by default + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt new file mode 100644 index 00000000000..071d8913d2a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,79 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.txt + + +class DownloadQueueFragment : + BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { + private val queueViewModel: DownloadQueueViewModel by activityViewModels() + + override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { + val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) + val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) + + observe(queueViewModel.childCards) { cards -> + val size = cards.queue.size + cards.currentDownloads.size + val isEmptyQueue = size == 0 + binding.downloadQueueList.isGone = isEmptyQueue + binding.textNoQueue.isGone = !isEmptyQueue + clearQueueItem?.isVisible = !isEmptyQueue + + adapter.submitQueue(cards) + } + + binding.apply { + downloadQueueToolbar.apply { + title = txt(R.string.download_queue).asString(context) + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() + } + } + setAppBarNoScrollFlagsOnTV() + clearQueueItem?.setOnMenuItemClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setTitle(R.string.cancel_all) + .setMessage(R.string.cancel_queue_message) + .setPositiveButton(R.string.yes) { _, _ -> + DownloadQueueManager.removeAllFromQueue() + } + .setNegativeButton(R.string.no) { _, _ -> + }.show() + + true + } + } + + downloadQueueList.adapter = adapter + + // Drag and drop + val helper = DragAndDropTouchHelper(adapter) + helper.attachToRecyclerView(downloadQueueList) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt new file mode 100644 index 00000000000..fc384cb4ebf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +data class DownloadAdapterQueue( + val currentDownloads: List, + val queue: List, +) + +class DownloadQueueViewModel : ViewModel() { + private val _childCards = MutableLiveData() + val childCards: LiveData = _childCards + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + val current = instances.map { it.downloadQueueWrapper } + DownloadAdapterQueue(current, queue.toList()) + }.combine(VideoDownloadManager.currentDownloads) { total, _ -> + // We want to update the flow when currentDownloads updates, but we do not care about its value + total + } + + init { + viewModelScope.launch { + totalDownloadFlow.collect { queue -> + updateChildList(queue) + } + } + } + + fun updateChildList(downloads: DownloadAdapterQueue) { + _childCards.postValue(downloads) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 6df5bbbef1d..e0b7f70246f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -49,7 +49,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext @@ -68,7 +68,7 @@ class HomeViewModel : ViewModel() { val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> - val data = getKey( + val data = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() ) ?: return@mapNotNull null @@ -523,7 +523,7 @@ class HomeViewModel : ViewModel() { } else if (api == null) { // API is not found aka not loaded or removed, post the loading // progress if waiting for plugins, otherwise nothing - if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) { + if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { loadAndCancel(noneApi) } else { _page.postValue(Resource.Loading()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt index 4c27dbc9772..eb1bcd00d42 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo class DownloadFileGenerator( episodes: List, @@ -35,7 +35,7 @@ class DownloadFileGenerator( // we actually need it as it can be more expensive. val info = meta.id?.let { id -> activity?.let { act -> - getDownloadFileInfoAndUpdateSettings(act, id) + getDownloadFileInfo(act, id) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 933d5de325d..e8d58563d62 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -116,7 +116,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 818e79d7456..7ff3904d8d7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -32,7 +32,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DateFormat @@ -160,7 +161,7 @@ class EpisodeAdapter( downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -199,6 +200,11 @@ class EpisodeAdapter( } } + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true @@ -376,7 +382,7 @@ class EpisodeAdapter( binding.apply { downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -415,6 +421,11 @@ class EpisodeAdapter( } } + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index b0fb9d037c1..af8d229a93f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -18,6 +18,7 @@ import android.view.animation.DecelerateInterpolator import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -72,6 +73,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog @@ -85,12 +87,14 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml +import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder import kotlin.math.roundToInt @@ -700,10 +704,60 @@ open class ResultFragmentPhone : FullScreenPlayer() { // no failure? resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodes.isVisible = episodes is Resource.Success + resultBatchDownloadButton.isVisible = + episodes is Resource.Success && episodes.value.isNotEmpty() + if (episodes is Resource.Success) { (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + resultBatchDownloadButton.setOnClickListener { view -> + val episodeStart = + episodes.value.firstOrNull()?.episode ?: return@setOnClickListener + val episodeEnd = + episodes.value.lastOrNull()?.episode ?: return@setOnClickListener + + val episodeRange = if (episodeStart == episodeEnd) { + episodeStart.toString() + } else { + txt( + R.string.episodes_range, + episodeStart, + episodeEnd + ).asString(view.context) + } + + val rangeMessage = txt( + R.string.download_episode_range, + episodeRange + ).asString(view.context) + + AlertDialog.Builder(view.context, R.style.AlertDialogCustom) + .setTitle(R.string.download_all) + .setMessage(rangeMessage) + .setPositiveButton(R.string.yes) { _, _ -> + ioSafe { + episodes.value.forEach { episode -> + viewModel.handleAction( + EpisodeClickEvent( + ACTION_DOWNLOAD_EPISODE, + episode + ) + ) + // Join to make the episodes ordered + .join() + } + } + } + .setNegativeButton(R.string.cancel) { _, _ -> + + }.show() + + } + } + + } + } observeNullable(viewModel.movie) { data -> @@ -731,8 +785,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { ) return@setOnLongClickListener true } + + val status = VideoDownloadManager.downloadStatus[ep.id] + downloadButton.setStatus(status) downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = ep.name, poster = ep.poster, episode = 0, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 4b4d0b5fadd..6eab987fc6e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -29,13 +29,22 @@ import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.metaproviders.SyncRedirector -import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugException +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.runAllAsync +import com.lagradost.cloudstream3.sortUrls import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable -import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.APIRepository -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.IGenerator import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL @@ -45,9 +54,6 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus @@ -57,9 +63,12 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.DataStore.editor import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData @@ -86,10 +95,31 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.Editor +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.FillerEpisodeCheck +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit -import kotlinx.coroutines.* /** This starts at 1 */ data class EpisodeRange( @@ -668,228 +698,6 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } - - private fun downloadSubtitle( - context: Context?, - link: ExtractorSubtitleLink, - fileName: String, - folder: String - ) { - ioSafe { - VideoDownloadManager.downloadThing( - context ?: return@ioSafe, - link, - "$fileName ${link.name}", - folder, - if (link.url.contains(".srt")) "srt" else "vtt", - false, - null, createNotificationCallback = {} - ) - } - } - - private fun getFolder(currentType: TvType, titleName: String): String { - return if (currentType.isEpisodeBased()) { - val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) - "${currentType.getFolderPrefix()}/$sanitizedFileName" - } else currentType.getFolderPrefix() - } - - private fun downloadSubtitle( - context: Context?, - link: SubtitleData, - meta: VideoDownloadManager.DownloadEpisodeMetadata, - ) { - context?.let { ctx -> - val fileName = VideoDownloadManager.getFileName(ctx, meta) - val folder = getFolder(meta.type ?: return, meta.mainName) - downloadSubtitle( - ctx, - ExtractorSubtitleLink(link.name, link.url, "", link.headers), - fileName, - folder - ) - } - } - - fun startDownload( - context: Context?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - links: List, - subs: List? - ) { - try { - if (context == null) return - - val meta = - getMeta( - episode, - currentHeaderName, - apiName, - currentPoster, - currentIsMovie, - currentType - ) - - val folder = getFolder(currentType, currentHeaderName) - - val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let - - // SET VISUAL KEYS - setKey( - DOWNLOAD_HEADER_CACHE, - parentId.toString(), - VideoDownloadHelper.DownloadHeaderCached( - apiName = apiName, - url = url, - type = currentType, - name = currentHeaderName, - poster = currentPoster, - id = parentId, - cacheTime = System.currentTimeMillis(), - ) - ) - - setKey( - DataStore.getFolderName( - DOWNLOAD_EPISODE_CACHE, - parentId.toString() - ), // 3 deep folder for faster acess - episode.id.toString(), - VideoDownloadHelper.DownloadEpisodeCached( - name = episode.name, - poster = episode.poster, - episode = episode.episode, - season = episode.season, - id = episode.id, - parentId = parentId, - score = episode.score, - description = episode.description, - cacheTime = System.currentTimeMillis(), - ) - ) - - // DOWNLOAD VIDEO - VideoDownloadManager.downloadEpisodeUsingWorker( - context, - src,//url ?: return, - folder, - meta, - links - ) - - // 1. Checks if the lang should be downloaded - // 2. Makes it into the download format - // 3. Downloads it as a .vtt file - val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() - - subs?.filter { subtitle -> - downloadList.any { langTagIETF -> - subtitle.languageCode == langTagIETF || - subtitle.originalName.contains( - fromTagToEnglishLanguageName( - langTagIETF - ) ?: langTagIETF - ) - } - } - ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } - ?.take(3) // max subtitles download hardcoded (?_?) - ?.forEach { link -> - val fileName = VideoDownloadManager.getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } - } catch (e: Exception) { - logError(e) - } - } - - suspend fun downloadEpisode( - activity: Activity?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - ) { - ioSafe { - val generator = RepoLinkGenerator(listOf(episode)) - val currentLinks = mutableSetOf() - val currentSubs = mutableSetOf() - generator.generateLinks( - clearCache = false, - sourceTypes = LOADTYPE_INAPP_DOWNLOAD, - callback = { - it.first?.let { link -> - currentLinks.add(link) - } - }, - subtitleCallback = { sub -> - currentSubs.add(sub) - }) - - if (currentLinks.isEmpty()) { - main { - showToast( - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - return@ioSafe - } else { - main { - showToast( - R.string.download_started, - Toast.LENGTH_SHORT - ) - } - } - - startDownload( - activity, - episode, - currentIsMovie, - currentHeaderName, - currentType, - currentPoster, - apiName, - parentId, - url, - sortUrls(currentLinks), - sortSubs(currentSubs), - ) - } - } - - private fun getMeta( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): VideoDownloadManager.DownloadEpisodeMetadata { - return VideoDownloadManager.DownloadEpisodeMetadata( - episode.id, - VideoDownloadManager.sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, - ) - } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) @@ -1068,6 +876,28 @@ class ResultViewModel2 : ViewModel() { } } + private fun getMeta( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadObjects.DownloadEpisodeMetadata { + return DownloadObjects.DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + /** * Toggles the favorite status of an item. * @@ -1613,16 +1443,17 @@ class ResultViewModel2 : ViewModel() { ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return - downloadEpisode( - activity, - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url, + ).toWrapper() ) } @@ -1633,9 +1464,8 @@ class ResultViewModel2 : ViewModel() { LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> - ioSafe { - startDownload( - activity, + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( click.data, response.isMovie(), response.name, @@ -1646,8 +1476,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ) - } + ).toWrapper() + ) showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -2816,7 +2646,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - VideoDownloadHelper.DownloadHeaderCached( + DownloadObjects.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt index e176d6c9b6d..449a04bf81f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt @@ -11,7 +11,7 @@ import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -31,7 +31,7 @@ object SearchHelper { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = card.name, poster = card.posterUrl, episode = card.episode ?: 0, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 4c64b175b76..b13987f2827 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -44,8 +44,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.USER_PROVIDER_API -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import java.util.Locale // Change local language settings in the app. @@ -328,7 +329,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { fun getDownloadDirs(): List { return safe { context?.let { ctx -> - val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() + val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -350,12 +351,18 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { return@setOnPreferenceChangeListener true } + getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> + // Notify that the queue logic has been changed + DownloadQueueManager.forceRefreshQueue() + return@setOnPreferenceChangeListener true + } + getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = settingsManager.getString(getString(R.string.download_path_key_visual), null) - ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } + ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 148f0ee5bab..2b74eab4cda 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -23,7 +23,6 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.services.BackupWorkManager import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -40,7 +39,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 65f928cc476..6a9ab28d819 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -40,7 +40,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.PreviewChannelHelper @@ -86,6 +85,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache @@ -152,7 +152,7 @@ object AppContextUtils { private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: VideoDownloadHelper.ResumeWatching? + resumeWatching: DownloadObjects.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -319,7 +319,7 @@ object AppContextUtils { val context = this continueWatchingLock.withLock { // A way to get all last watched timestamps - val timeStampHashMap = HashMap() + val timeStampHashMap = HashMap() getAllResumeStateIds()?.forEach { id -> val lastWatched = getLastWatched(id) ?: return@forEach timeStampHashMap[lastWatched.parentId] = lastWatched diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 96171aa90b9..a444ef3d078 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -28,9 +28,12 @@ import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.VideoDownloadManager.StreamData -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly @@ -79,6 +82,24 @@ object BackupUtils { "open_subtitles_user", "subdl_user", "simkl_token", + + + // Downloads can not be restored from backups. + // The download path URI can not be transferred. + // In the future we may potentially write metadata to files in the download directory + // and make it possible to restore download folders using that metadata. + DOWNLOAD_HEADER_CACHE_BACKUP, + DOWNLOAD_HEADER_CACHE, + DOWNLOAD_EPISODE_CACHE_BACKUP, + DOWNLOAD_EPISODE_CACHE, + + // This may overwrite valid local data with invalid data + KEY_DOWNLOAD_INFO, + + // Prevent backups from automatically starting downloads + KEY_RESUME_IN_QUEUE, + KEY_RESUME_PACKAGES, + QUEUE_KEY ) /** false if key should not be contained in backup */ @@ -208,7 +229,7 @@ object BackupUtils { } @Throws(IOException::class) - private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): StreamData { + private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), @@ -290,7 +311,7 @@ object BackupUtils { } /** - * Copy of [VideoDownloadManager.basePathToFile], [VideoDownloadManager.getDefaultDir] and [VideoDownloadManager.getBasePath] + * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] * modded for backup specific paths * */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 20d33c11218..58d77bfe8a7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -15,9 +15,11 @@ import kotlin.reflect.KProperty import androidx.core.content.edit const val DOWNLOAD_HEADER_CACHE = "download_header_cache" +const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" +const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 217dc2a5205..19caead21ee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -30,6 +30,7 @@ import com.lagradost.cloudstream3.ui.result.EpisodeSortType import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import java.util.Calendar import java.util.Date import java.util.GregorianCalendar @@ -529,7 +530,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - VideoDownloadHelper.ResumeWatching( + DownloadObjects.ResumeWatching( parentId, episodeId, episode, @@ -550,7 +551,7 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } - fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { + fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", @@ -558,7 +559,7 @@ object DataStoreHelper { ) } - private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index 0b9b81e4024..e69de29bb2d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -1,104 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.app.Notification -import android.content.Context -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.WorkerParameters -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage -import kotlinx.coroutines.delay - -const val DOWNLOAD_CHECK = "DownloadCheck" - -class DownloadFileWorkManager(val context: Context, private val workerParams: WorkerParameters) : - CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result { - val key = workerParams.inputData.getString("key") - try { - if (key == DOWNLOAD_CHECK) { - downloadCheck(applicationContext, ::handleNotification) - } else if (key != null) { - val info = - applicationContext.getKey(WORK_KEY_INFO, key) - val pkg = - applicationContext.getKey( - WORK_KEY_PACKAGE, - key - ) - - if (info != null) { - getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg -> - downloadFromResume(applicationContext, dpkg, ::handleNotification) - } ?: run { - downloadEpisode( - applicationContext, - info.source, - info.folder, - info.ep, - info.links, - ::handleNotification - ) - } - } else if (pkg != null) { - downloadFromResume(applicationContext, pkg, ::handleNotification) - } - removeKeys(key) - } - return Result.success() - } catch (e: Exception) { - logError(e) - if (key != null) { - removeKeys(key) - } - return Result.failure() - } - } - - private fun removeKeys(key: String) { - removeKey(WORK_KEY_INFO, key) - removeKey(WORK_KEY_PACKAGE, key) - } - - private suspend fun awaitDownload(id: Int) { - var isDone = false - val listener = { (localId, localType): Pair -> - if (id == localId) { - when (localType) { - VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { - isDone = true - } - - else -> Unit - } - } - } - downloadStatusEvent += listener - while (!isDone) { - println("AWAITING $id") - delay(1000) - } - downloadStatusEvent -= listener - } - - private fun handleNotification(id: Int, notification: Notification) { - main { - if (SDK_INT >= 29) - setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) - else setForegroundAsync(ForegroundInfo(id, notification)) - - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index 97be98aea87..18d465e3c5a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -2,7 +2,8 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.lagradost.api.Log -import com.lagradost.cloudstream3.utils.VideoDownloadManager.basePathToFile +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SubtitleUtils { @@ -12,7 +13,7 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { val cleanDisplay = cleanDisplayName(info.displayName) val base = basePathToFile(context, info.basePath) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt index fcee1e45ac8..e69de29bb2d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -1,55 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.TvType -object VideoDownloadHelper { - abstract class DownloadCached( - @JsonProperty("id") open val id: Int, - ) - - data class DownloadEpisodeCached( - @JsonProperty("name") val name: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("episode") val episode: Int, - @JsonProperty("season") val season: Int?, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("score") var score: Score? = null, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ): DownloadCached(id) { - @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) - @Deprecated( - "`rating` is the old scoring system, use score instead", - replaceWith = ReplaceWith("score"), - level = DeprecationLevel.ERROR - ) - var rating: Int? = null - set(value) { - if (value != null) { - @Suppress("DEPRECATION_ERROR") - score = Score.fromOld(value) - } - } - } - - data class DownloadHeaderCached( - @JsonProperty("apiName") val apiName: String, - @JsonProperty("url") val url: String, - @JsonProperty("type") val type: TvType, - @JsonProperty("name") val name: String, - @JsonProperty("poster") val poster: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ): DownloadCached(id) - - data class ResumeWatching( - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("episodeId") val episodeId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt new file mode 100644 index 00000000000..898c30a1ca9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile + +object DownloadFileManagement { + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + internal fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + internal fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * 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. + * */ + internal fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + internal fun getFileName( + context: Context, + metadata: DownloadObjects.DownloadEpisodeMetadata + ): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + internal 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 + } + } + ) + } + + + internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory( + relativePath, + createMissingDirectories = false + ) + ?.findFile(displayName) + } + + internal fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + /** + * 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 getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt similarity index 69% rename from app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index cdda1186818..01dc5301706 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -1,38 +1,27 @@ -package com.lagradost.cloudstream3.utils +package com.lagradost.cloudstream3.utils.downloader + import android.Manifest import android.annotation.SuppressLint import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent -import android.content.* +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.net.Uri import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log +import android.widget.Toast import androidx.annotation.DrawableRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat -import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.preference.PreferenceManager -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import coil3.Extras -import coil3.SingletonImageLoader -import coil3.asDrawable -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.IDownloadableMinimum @@ -42,14 +31,56 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.VideoDownloadService +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD +import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.M3u8Helper2 +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.safefile.MediaFileContentType +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join +import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException @@ -60,23 +91,24 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.Closeable import java.io.IOException import java.io.OutputStream -import java.util.* const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { - private fun maxConcurrentDownloads(context: Context): Int = + fun maxConcurrentDownloads(context: Context): Int = PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 @@ -84,8 +116,11 @@ object VideoDownloadManager { PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 - private var currentDownloads = mutableListOf() + private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) + val currentDownloads: StateFlow> = _currentDownloads + const val TAG = "VDM" + private const val DOWNLOAD_NOTIFICATION_TAG = "FROM_DOWNLOADER" 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" @@ -129,56 +164,6 @@ object VideoDownloadManager { Stop, } - 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?, - ) - - data class DownloadItem( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List, - ) - - data class DownloadResumePackage( - @JsonProperty("item") val item: DownloadItem, - @JsonProperty("linkIndex") val linkIndex: Int?, - ) - - 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() - ) - - data class DownloadedFileInfoResult( - @JsonProperty("fileLength") val fileLength: Long, - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("path") val path: Uri, - ) - - data class DownloadQueueResumePackage( - @JsonProperty("index") val index: Int, - @JsonProperty("pkg") val pkg: DownloadResumePackage, - ) - - data class DownloadStatus( - /** if you should retry with the same args and hope for a better result */ - val retrySame: Boolean, - /** if you should try the next mirror */ - val tryNext: Boolean, - /** if the result is what the user intended */ - val success: Boolean, - ) /** Invalid input, just skip to the next one as the same args will give the same error */ private val DOWNLOAD_INVALID_INPUT = @@ -199,113 +184,49 @@ object VideoDownloadManager { private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) - const val KEY_RESUME_PACKAGES = "download_resume" + const val KEY_RESUME_PACKAGES = "download_resume_2" const val KEY_DOWNLOAD_INFO = "download_info" - private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + + /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] + * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. + */ + const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" +// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() - val downloadQueue = LinkedList() +// val downloadQueue = LinkedList() - private var hasCreatedNotChanel = false + private var hasCreatedNotChannel = 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) { - val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name) - val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description) - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply { - description = descriptionText - } - // Register the channel with the system - val notificationManager: NotificationManager = - this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - } - - ///** Will return IsDone if not found or error */ - //fun getDownloadState(id: Int): DownloadType { - // return try { - // downloadStatus[id] ?: DownloadType.IsDone - // } catch (e: Exception) { - // logError(e) - // DownloadType.IsDone - // } - //} - - private val cachedBitmaps = hashMapOf() - fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { - try { - if (cachedBitmaps.containsKey(url)) { - return cachedBitmaps[url] - } - - val imageLoader = SingletonImageLoader.get(this) + hasCreatedNotChannel = true - val request = ImageRequest.Builder(this) - .data(url) - .apply { - headers?.forEach { (key, value) -> - extras[Extras.Key(key)] = value - } - } - .build() - - val bitmap = runBlocking { - val result = imageLoader.execute(request) - (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) - ?.toBitmap() - } + this.createNotificationChannel( + DOWNLOAD_CHANNEL_ID, + DOWNLOAD_CHANNEL_NAME, + DOWNLOAD_CHANNEL_DESCRIPT + ) + } - bitmap?.let { - cachedBitmaps[url] = it + fun cancelAllDownloadNotifications(context: Context) { + val manager = NotificationManagerCompat.from(context) + manager.activeNotifications.forEach { notification -> + if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { + manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) } - - return bitmap - } catch (e: Exception) { - logError(e) - return null - } - } - //calculate the time - private fun getEstimatedTimeLeft(context:Context,bytesPerSecond: Long, progress: Long, total: Long):String{ - if(bytesPerSecond <= 0 ) return "" - val timeInSec = (total - progress)/bytesPerSecond - val hrs = timeInSec/3600 - val mins = (timeInSec%3600)/ 60 - val secs = timeInSec % 60 - val timeFormated:UiText? = when{ - hrs>0 -> txt( - R.string.download_time_left_hour_min_sec_format, - hrs, - mins, - secs - ) - mins>0 -> txt( - R.string.download_time_left_min_sec_format, - mins, - secs - ) - secs>0 -> txt( - R.string.download_time_left_sec_format, - secs - ) - else -> null } - return timeFormated?.asString(context) ?: "" } + + /** * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. * */ @SuppressLint("StringFormatInvalid") - private suspend fun createNotification( + private suspend fun createDownloadNotification( context: Context, source: String?, linkName: String?, @@ -321,7 +242,6 @@ object VideoDownloadManager { try { if (total <= 0) return null// crash, invalid data -// main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) @@ -405,9 +325,9 @@ object VideoDownloadManager { } else "" val remainingTime = - if(state == DownloadType.IsDownloading){ - getEstimatedTimeLeft(context,bytesPerSecond, progress, total) - }else "" + if (state == DownloadType.IsDownloading) { + getEstimatedTimeLeft(context, bytesPerSecond, progress, total) + } else "" val bigText = when (state) { @@ -470,7 +390,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -482,6 +402,9 @@ object VideoDownloadManager { actionTypes.add(DownloadActionType.Resume) actionTypes.add(DownloadActionType.Stop) } + if (state == DownloadType.IsPending) { + actionTypes.add(DownloadActionType.Stop) + } // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { @@ -520,7 +443,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChanel) { + if (!hasCreatedNotChannel) { context.createNotificationChannel() } @@ -535,7 +458,7 @@ object VideoDownloadManager { ) { return null } - notify(ep.id, notification) + notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) } return notification } catch (e: Exception) { @@ -544,69 +467,6 @@ object VideoDownloadManager { } } - private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" - fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { - var tempName = name - for (c in RESERVED_CHARS) { - tempName = tempName.replace(c, ' ') - } - if (removeSpaces) tempName = tempName.replace(" ", "") - return tempName.replace(" ", " ").trim(' ') - } - - /** - * Used for getting video player subs. - * @return List of pairs for the files in this format: - * */ - fun getFolder( - context: Context, - relativePath: String, - basePath: String? - ): List>? { - val base = basePathToFile(context, basePath) - val folder = - base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null - - //if (folder.isDirectory() != false) return null - - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } - } - - - data class CreateNotificationMetadata( - val type: DownloadType, - val bytesDownloaded: Long, - val bytesTotal: Long, - val hlsProgress: Long? = null, - val hlsTotal: Long? = null, - val bytesPerSecond: Long - ) - - data class StreamData( - private val fileLength: Long, - val file: SafeFile, - //val fileStream: OutputStream, - ) { - @Throws(IOException::class) - fun open(): OutputStream { - return file.openOutputStreamOrThrow(resume) - } - - @Throws(IOException::class) - fun openNew(): OutputStream { - return file.openOutputStreamOrThrow(false) - } - - fun delete(): Boolean { - return file.delete() == true - } - - val resume: Boolean get() = fileLength > 0L - val startAt: Long get() = if (resume) fileLength else 0L - val exists: Boolean get() = file.exists() == true - } - @Throws(IOException::class) fun setupStream( @@ -628,7 +488,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. + * Used for initializing downloads and backups. * */ @Throws(IOException::class) fun setupStream( @@ -717,8 +577,6 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() stopListener?.invoke() stopListener = null } @@ -880,34 +738,12 @@ object VideoDownloadManager { } } - /** 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, private val referer: String, - /** This specifies where chunck i starts and ends, + /** This specifies where chunk i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, @@ -994,11 +830,11 @@ object VideoDownloadManager { if (end == null) return true // we have download more or exactly what we needed if (start >= end) return true - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { return false - } catch (e: CancellationException) { + } catch (_: CancellationException) { return false - } catch (t: Throwable) { + } catch (_: Throwable) { continue } } @@ -1117,38 +953,6 @@ object VideoDownloadManager { ) } - /** 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 Map.appendAndDontOverride(rhs: Map): Map { - 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 - } - - private fun List.cancel() { - forEach { job -> - try { - job.cancel() - } catch (t: Throwable) { - logError(t) - } - } - } - - private suspend fun List.join() { - forEach { job -> - try { - job.join() - } catch (t: Throwable) { - logError(t) - } - } - } /** download a file that consist of a single stream of data*/ suspend fun downloadThing( @@ -1444,6 +1248,7 @@ object VideoDownloadManager { // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt + metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( @@ -1520,50 +1325,45 @@ object VideoDownloadManager { return@launch } - try { - fileMutex.lock() - // user pause - while (metadata.type == DownloadType.IsPaused) delay(100) - // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch + fileMutex.withLock { + try { + // user pause + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch - val segmentLength = bytes.size.toLong() - // send notification, no matter the actual write order - metadata.addSegment(segmentLength) + val segmentLength = bytes.size.toLong() + // send notification, no matter the actual write order + metadata.addSegment(segmentLength) - // directly write the bytes if you are first - if (metadata.hlsWrittenProgress == index) { - fileStream.write(bytes) + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) - metadata.addBytesWritten(segmentLength) - metadata.setWrittenSegment(index) - } else { - // no need to clone as there will be no modification of this bytearray - pendingData[index] = bytes - } + metadata.addBytesWritten(segmentLength) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } - // write the cached bytes submitted by other threads - while (true) { - val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break - val cacheLength = cache.size.toLong() + // write the cached bytes submitted by other threads + while (true) { + val cache = + pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() - fileStream.write(cache) + fileStream.write(cache) - metadata.addBytesWritten(cacheLength) - metadata.setWrittenSegment(metadata.hlsWrittenProgress) - } - } catch (t: Throwable) { - // this is in case of write fail - logError(t) - if (metadata.type != DownloadType.IsStopped) { - metadata.type = DownloadType.IsFailed - } - } finally { - try { - // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling - fileMutex.unlock() + metadata.addBytesWritten(cacheLength) + metadata.setWrittenSegment(metadata.hlsWrittenProgress) + } } catch (t: Throwable) { + // this is in case of write fail logError(t) + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } } } } @@ -1610,75 +1410,6 @@ object VideoDownloadManager { 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 getDefaultDir(context: Context): SafeFile? { - // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads - ) - } - - /** - * Turns a string to an UniFile. Used for stored string paths such as settings. - * Should only be used to get a download path. - * */ - fun basePathToFile(context: Context, path: String?): SafeFile? { - return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFilePath(context, path) - } - } - - /** - * 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 { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) - return basePathToFile(this, basePathSetting) to basePathSetting - } - - fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { - return getFileName(context, metadata.name, metadata.episode, metadata.season) - } - - 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?, @@ -1704,7 +1435,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createNotification( + createDownloadNotification( context, source, link.name, @@ -1758,100 +1489,17 @@ object VideoDownloadManager { ) } - else -> throw IllegalArgumentException("unsuported download type") + else -> throw IllegalArgumentException("Unsupported download type") } - } catch (t: Throwable) { + } catch (_: Throwable) { return DOWNLOAD_FAILED } finally { extractorJob.cancel() } } - suspend fun downloadCheck( - context: Context, notificationCallback: (Int, Notification) -> Unit, - ) { - if (!(currentDownloads.size < maxConcurrentDownloads(context) && downloadQueue.size > 0)) return - - val pkg = downloadQueue.removeAt(0) - val item = pkg.item - val id = item.ep.id - if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(id to DownloadActionType.Resume) - return - } - currentDownloads.add(id) - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - - if (connectionResult.retrySame) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult.success) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { - downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) - break - } - } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the work manager - downloadCheckUsingWorker(context) - } - - // return id - } - - /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { - val res = getDownloadFileInfo(context, id) - if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) - return res - } - */ - fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = - getDownloadFileInfo(context, id) - - private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { - return basePathToFile(context, this.basePath)?.gotoDirectory( - relativePath, - createMissingDirectories = false - ) - ?.findFile(displayName) - } - - private fun getDownloadFileInfo( + fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { @@ -1861,8 +1509,7 @@ object VideoDownloadManager { val file = info.toFile(context) // only delete the key if the file is not found - if (file == null || !file.existsOrThrow()) { - //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD + if (file == null || file.exists() == false) { return null } @@ -1913,35 +1560,20 @@ object VideoDownloadManager { return success } - /*private fun deleteFile( - context: Context, - folder: SafeFile?, - relativePath: String, - displayName: String - ): Boolean { - val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false - if (file.exists() == false) return true - return try { - file.delete() - } catch (e: Exception) { - logError(e) - (context.contentResolver?.delete(file.uri() ?: return true, null, null) - ?: return false) > 0 - } - }*/ - private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false val file = info.toFile(context) - downloadEvent.invoke(id to DownloadActionType.Stop) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(id to DownloadType.IsStopped) - downloadDeleteEvent.invoke(id) - val isFileDeleted = file?.delete() == true || file?.exists() == false - if (isFileDeleted) deleteMatchingSubtitles(context, info) + + if (isFileDeleted) { + deleteMatchingSubtitles(context, info) + downloadEvent.invoke(id to DownloadActionType.Stop) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) + } return isFileDeleted } @@ -1950,119 +1582,434 @@ object VideoDownloadManager { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - suspend fun downloadFromResume( - context: Context, - pkg: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - setKey: Boolean = true + fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { + return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + } + + fun getDownloadEpisodeMetadata( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadEpisodeMetadata { + return DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + class EpisodeDownloadInstance( + val context: Context, + val downloadQueueWrapper: DownloadQueueWrapper ) { - if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { - downloadQueue.addLast(pkg) - downloadCheck(context, notificationCallback) - if (setKey) saveQueue() - //ret - } else { - downloadEvent( - pkg.item.ep.id to DownloadActionType.Resume - ) - //null + private val TAG = "EpisodeDownloadInstance" + private var subtitleDownloadJob: Job? = null + private var downloadJob: Job? = null + private var linkLoadingJob: Job? = null + + /** isCompleted just means the download should not be retried. + * It includes stopped by user AND completion of file download. + * */ + var isCompleted = false + set(value) { + field = value + if (value) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + // Do not emit events when completed as it may also trigger on cancellation. + + + // Force refresh the queue when completed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() + } + } + + /** Cancels all active jobs and sets instance to failed. */ + fun cancelDownload() { + val cause = "Cancel call from cancelDownload" + this.subtitleDownloadJob?.cancel(cause) + this.linkLoadingJob?.cancel(cause) + + // Should not cancel the download job, it may need to clean up itself. + // Better to send a status event using isStopped and let it cancel itself. + isCancelled = true } - } - private fun saveQueue() { - try { - val dQueue = - downloadQueue.toList() - .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } - .toTypedArray() - setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) - } catch (t: Throwable) { - logError(t) + // Run to cancel ongoing work, delete partial work and refresh queue + private fun cleanup(status: DownloadType) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + val id = downloadQueueWrapper.id + + // Delete subtitles on cancel + safe { + val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) + if (info != null) { + deleteMatchingSubtitles(context, info) + } + } + + downloadStatusEvent.invoke(Pair(id, status)) + downloadStatus[id] = status + downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) + + // Force refresh the queue when failed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() } - } - /*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean { - 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 + var isCancelled = false + set(value) { + val oldField = field + field = value + + // Clean up cancelled work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsStopped) + } + } + + + /** This failure can be both downloader and user initiated. + * Do not automatically retry in case of failure. */ + var isFailed = false + set(value) { + val oldField = field + field = value + + // Clean up failed work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsFailed) + } + } + + companion object { + private fun displayNotification(context: Context, id: Int, notification: Notification) { + safe { + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) + } } } - return false - }*/ - suspend fun downloadEpisode( - context: Context?, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - notificationCallback: (Int, Notification) -> Unit, - ) { - if (context == null) return - if (links.isEmpty()) return - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) - } + private suspend fun downloadFromResume( + downloadResumePackage: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + ) { + val item = downloadResumePackage.item + val id = item.ep.id + if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } - /** 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 - ) - } + _currentDownloads.update { downloads -> + downloads + id + } - fun downloadCheckUsingWorker( - context: Context, - ) { - startWork(context, DOWNLOAD_CHECK) - } + try { + for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = downloadResumePackage.linkIndex == index + + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) - fun downloadFromResumeUsingWorker( - context: Context, - pkg: DownloadResumePackage, - ) { - val key = pkg.item.ep.id.toString() - setKey(WORK_KEY_PACKAGE, key, pkg) - startWork(context, key) - } + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) - // 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" + if (connectionResult.retrySame) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true + ) + } - fun downloadEpisodeUsingWorker( - context: Context, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - ) { - val info = DownloadInfo( - source, folder, ep, links - ) + if (connectionResult.success) { // SUCCESS + isCompleted = true + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + isFailed = true + break + } + } + } catch (e: Exception) { + isFailed = true + logError(e) + } finally { + isFailed = !isCompleted + _currentDownloads.update { downloads -> + downloads - id + } + } + } - val key = info.ep.id.toString() - setKey(WORK_KEY_INFO, key, info) - startWork(context, key) - } + private suspend fun startDownload( + info: DownloadItem?, + pkg: DownloadResumePackage? + ) { + try { + if (info != null) { + getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> + downloadFromResume(dpkg) { id, notification -> + displayNotification(context, id, notification) + } + } ?: run { + if (info.links.isEmpty()) return + downloadFromResume( + DownloadResumePackage(info, null) + ) { id, notification -> + displayNotification(context, id, notification) + } + } + } else if (pkg != null) { + downloadFromResume(pkg) { id, notification -> + displayNotification(context, id, notification) + } + } + return + } catch (e: Exception) { + isFailed = true + logError(e) + return + } + } + + private suspend fun downloadFromResume() { + val resumePackage = downloadQueueWrapper.resumePackage ?: return + downloadFromResume(resumePackage) { id, notification -> + displayNotification(context, id, notification) + } + } + + fun startDownload() { + Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") + setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) + + ioSafe { + if (downloadQueueWrapper.resumePackage != null) { + downloadFromResume() + // Load links if they are not already loaded + } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { + downloadEpisodeWithoutLinks() + } else if (downloadQueueWrapper.downloadItem?.links != null) { + downloadEpisodeWithLinks( + sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), + downloadQueueWrapper.downloadItem.subs + ) + } + } + } + + private fun downloadEpisodeWithLinks( + links: List, + subs: List? + ) { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + try { + // Prepare visual keys + setKey( + DOWNLOAD_HEADER_CACHE, + downloadItem.resultId.toString(), + DownloadObjects.DownloadHeaderCached( + apiName = downloadItem.apiName, + url = downloadItem.resultUrl, + type = downloadItem.resultType, + name = downloadItem.resultName, + poster = downloadItem.resultPoster, + id = downloadItem.resultId, + cacheTime = System.currentTimeMillis(), + ) + ) + setKey( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + downloadItem.resultId.toString() + ), // 3 deep folder for faster access + downloadItem.episode.id.toString(), + DownloadObjects.DownloadEpisodeCached( + name = downloadItem.episode.name, + poster = downloadItem.episode.poster, + episode = downloadItem.episode.episode, + season = downloadItem.episode.season, + id = downloadItem.episode.id, + parentId = downloadItem.resultId, + score = downloadItem.episode.score, + description = downloadItem.episode.description, + cacheTime = System.currentTimeMillis(), + ) + ) + + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + val folder = + getFolder(downloadItem.resultType, downloadItem.resultName) + val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" + + // DOWNLOAD VIDEO + val info = DownloadItem(src, folder, meta, links) - data class DownloadInfo( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List - ) + this.downloadJob = ioSafe { + startDownload(info, null) + } + + // 1. Checks if the lang should be downloaded + // 2. Makes it into the download format + // 3. Downloads it as a .vtt file + this.subtitleDownloadJob = ioSafe { + try { + val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() + + subs?.filter { subtitle -> + downloadList.any { langTagIETF -> + subtitle.languageCode == langTagIETF || + subtitle.originalName.contains( + fromTagToEnglishLanguageName( + langTagIETF + ) ?: langTagIETF + ) + } + } + ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } + ?.take(3) // max subtitles download hardcoded (?_?) + ?.forEach { link -> + val fileName = getFileName(context, meta) + downloadSubtitle(context, link, fileName, folder) + } + + } catch (_: CancellationException) { + val fileName = getFileName(context, meta) + + val info = DownloadedFileInfo( + totalBytes = 0, + relativePath = folder, + displayName = fileName, + basePath = context.getBasePath().second + ) + + deleteMatchingSubtitles(context, info) + } + } + } catch (e: Exception) { + // The work is only failed if the job did not get started + if (this.downloadJob == null) { + isFailed = true + } + logError(e) + } + } + + private suspend fun downloadEpisodeWithoutLinks() { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + + val generator = RepoLinkGenerator(listOf(downloadItem.episode)) + val currentLinks = mutableSetOf() + val currentSubs = mutableSetOf() + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + createDownloadNotification( + context, + downloadItem.apiName, + txt(R.string.loading).asString(context), + meta, + DownloadType.IsPending, + 0, + 1, + { _, _ -> }, + null, + null, + 0 + )?.let { linkLoadingNotification -> + displayNotification(context, downloadItem.episode.id, linkLoadingNotification) + } + + linkLoadingJob = ioSafe { + generator.generateLinks( + clearCache = false, + sourceTypes = LOADTYPE_INAPP_DOWNLOAD, + callback = { + it.first?.let { link -> + currentLinks.add(link) + } + }, + subtitleCallback = { sub -> + currentSubs.add(sub) + }) + } + + // Wait for link loading completion + linkLoadingJob?.join() + + // Remove link loading notification + NotificationManagerCompat.from(context).cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) + + if (linkLoadingJob?.isCancelled == true) { + // Same as if no links, but no toast. + // Cancelled link loading is presumed to be user initiated + isCancelled = true + return + } else if (currentLinks.isEmpty()) { + main { + showToast( + R.string.no_links_found_toast, + Toast.LENGTH_SHORT + ) + } + isFailed = true + return + } else { + main { + showToast( + R.string.download_started, + Toast.LENGTH_SHORT + ) + } + } + + downloadEpisodeWithLinks( + sortUrls(currentLinks), + sortSubs(currentSubs), + ) + } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt new file mode 100644 index 00000000000..1d945a6b4a5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -0,0 +1,222 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.safefile.SafeFile +import java.io.IOException +import java.io.OutputStream +import java.util.Objects + +object DownloadObjects { + /** An item can either be something to resume or something new to start */ + data class DownloadQueueWrapper( + @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, + @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, + ) { + init { + assert(resumePackage != null || downloadItem != null) { + "ResumeID and downloadItem cannot both be null at the same time!" + } + } + + /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ + fun isCurrentlyDownloading(): Boolean { + return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } + } + + @JsonProperty("id") + val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id + + @JsonProperty("parentId") + val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId + } + + /** General data about the episode and show to start a download from. */ + data class DownloadQueueItem( + @JsonProperty("episode") val episode: ResultEpisode, + @JsonProperty("isMovie") val isMovie: Boolean, + @JsonProperty("resultName") val resultName: String, + @JsonProperty("resultType") val resultType: TvType, + @JsonProperty("resultPoster") val resultPoster: String?, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("resultId") val resultId: Int, + @JsonProperty("resultUrl") val resultUrl: String, + @JsonProperty("links") val links: List? = null, + @JsonProperty("subs") val subs: List? = null, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(null, this) + } + } + + + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + /** Tills which link should get resumed */ + @JsonProperty("linkIndex") val linkIndex: Int?, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(this, null) + } + } + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + /** Metadata for a specific episode and how to display it. */ + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("parentId") val parentId: 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?, + ) + + + 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 getBasePath() + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) + + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) + + + data class CreateNotificationMetadata( + val type: VideoDownloadManager.DownloadType, + val bytesDownloaded: Long, + val bytesTotal: Long, + val hlsProgress: Long? = null, + val hlsTotal: Long? = null, + val bytesPerSecond: Long + ) + + data class StreamData( + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } + + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + + + /** 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt new file mode 100644 index 00000000000..f3866408833 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,250 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.util.Log +import androidx.core.content.ContextCompat +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet + +// 1. Put a download on the queue +// 2. The queue manager starts a foreground service to handle the queue +// 3. The service starts work manager jobs to handle the downloads? +object DownloadQueueManager { + private const val TAG = "DownloadQueueManager" + const val QUEUE_KEY = "download_queue_key" + + /** Flow of all active queued download, no active downloads. + * This flow may see many changes, do not place expensive observers. + * downloadInstances is the flow keeping track of active downloads. + * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances + */ + private val _queue: MutableStateFlow> by lazy { + /** Persistent queue */ + val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() + MutableStateFlow(currentValue) + } + + val queue: StateFlow> by lazy { _queue } + + /** Start the queue, marks all queue objects as in progress. + * Note that this may run twice without the service restarting + * because MainActivity may be recreated. */ + fun init(context: Context) { + ioSafe { + _queue.collect { queue -> + setKey(QUEUE_KEY, queue) + } + } + + ioSafe startQueue@{ + // Do not automatically start the queue if safe mode is activated. + if (PluginManager.isSafeMode()) { + // Prevent misleading UI + VideoDownloadManager.cancelAllDownloadNotifications(context) + return@startQueue + } + + val resumeQueue = + getPreResumeIds().filterNot { + VideoDownloadManager.currentDownloads.value.contains(it) + } + .mapNotNull { id -> + getDownloadResumePackage(context, id)?.toWrapper() + ?: getDownloadQueuePackage(context, id) + } + + val newQueue = _queue.updateAndGet { localQueue -> + // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started + (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() + } + + // Once added to the queue they can be safely removed + removeKeys(KEY_RESUME_IN_QUEUE) + + // Make sure the download buttons display a pending status + newQueue.forEach { obj -> + setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) + } + + if (newQueue.any()) { + startQueueService(context) + } + } + } + + /** Downloads not yet started or in progress. */ + private fun getPreResumeIds(): Set { + return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { + it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() + }?.toSet() + ?: emptySet() + } + + /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ + private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { + Log.d(TAG, "Download added to queue: $downloadQueueWrapper") + val newQueue = _queue.updateAndGet { localQueue -> + // Do not add the same episode twice + if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { + return@updateAndGet localQueue + } + localQueue + downloadQueueWrapper + } + return newQueue.any { it.id == downloadQueueWrapper.id } + } + + /** Removes all objects with the same id from the internal persistent queue */ + private fun remove(id: Int) { + Log.d(TAG, "Download removed from the queue: $id") + _queue.update { localQueue -> + // The check is to prevent unnecessary updates + if (!localQueue.any { it.id == id }) { + return@update localQueue + } + + localQueue.filter { it.id != id }.toTypedArray() + } + } + + /** Removes all items and returns the previous queue */ + private fun removeAll(): Array { + Log.d(TAG, "Removed everything from queue") + return _queue.getAndUpdate { + emptyArray() + } + } + + private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + _queue.update { localQueue -> + val newIndex = newPosition.coerceIn(0, localQueue.size) + val id = downloadQueueWrapper.id + + val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { + this.add(newIndex, downloadQueueWrapper) + }.toTypedArray() + + newQueue + } + } + + /** Start a real download from the first item in the queue */ + fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { + val first = queue.value.firstOrNull() ?: return null + + remove(first.id) + + val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) + + return downloadInstance + } + + /** Marks the item as in queue for the download button */ + private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { + downloadStatusEvent.invoke( + Pair( + id, + status + ) + ) + downloadStatus[id] = status + } + + private fun startQueueService(context: Context?) { + if (context == null) { + Log.d(TAG, "Cannot start download queue service, null context.") + return + } + // Do not restart the download queue service + if (DownloadQueueService.isRunning) { + return + } + ioSafe { + val intent = DownloadQueueService.getIntent(context) + ContextCompat.startForegroundService(context, intent) + } + } + + /** Cancels an active download or removes it from queue depending on where it is. */ + fun cancelDownload(id: Int) { + Log.d(TAG, "Cancelling download: $id") + + val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } + + if (currentInstance != null) { + currentInstance.cancelDownload() + } else { + removeFromQueue(id) + } + } + + /** Removes all queued items */ + fun removeAllFromQueue() { + removeAll().forEach { wrapper -> + setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Removes all objects with the same id from the internal persistent queue */ + fun removeFromQueue(id: Int) { + ioSafe { + remove(id) + setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Will move the download queue wrapper to a new position in the queue. + * If the item does not exist it will also insert it. */ + fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + ioSafe { + reorder(downloadQueueWrapper, newPosition) + } + } + + /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ + fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { + val context = CloudStreamApp.context ?: return@safe + val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) + val isComplete = fileInfo != null && + // Assure no division by 0 + fileInfo.totalBytes > 0 && + // If more than 98% downloaded then do not add to queue + (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f + // Do not queue completed files! + if (isComplete) return@safe + + if (add(downloadQueueWrapper)) { + setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) + startQueueService(context) + } + } + + + /** Refreshes the queue flow with the same value, but copied. + * Good to run if the downloads are affected by some outside value change. */ + fun forceRefreshQueue() { + _queue.update { localQueue -> + localQueue.copyOf() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt new file mode 100644 index 00000000000..b436bb49c8e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -0,0 +1,164 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import coil3.Extras +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking + +/** Separate object with helper functions for the downloader */ +object DownloadUtils { + private val cachedBitmaps = hashMapOf() + internal fun Context.getImageBitmapFromUrl( + url: String, + headers: Map? = null + ): Bitmap? = safe { + if (cachedBitmaps.containsKey(url)) { + return@safe cachedBitmaps[url] + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) + ?.toBitmap() + } + + bitmap?.let { + cachedBitmaps[url] = it + } + + return@safe bitmap + } + + //calculate the time + internal fun getEstimatedTimeLeft( + context: Context, + bytesPerSecond: Long, + progress: Long, + total: Long + ): String { + if (bytesPerSecond <= 0) return "" + val timeInSec = (total - progress) / bytesPerSecond + val hrs = timeInSec / 3600 + val mins = (timeInSec % 3600) / 60 + val secs = timeInSec % 60 + val timeFormated: UiText? = when { + hrs > 0 -> txt( + R.string.download_time_left_hour_min_sec_format, + hrs, + mins, + secs + ) + + mins > 0 -> txt( + R.string.download_time_left_min_sec_format, + mins, + secs + ) + + secs > 0 -> txt( + R.string.download_time_left_sec_format, + secs + ) + + else -> null + } + return timeFormated?.asString(context) ?: "" + } + + internal fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: DownloadObjects.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + + /** Helper function to make sure duplicate attributes don't get overridden 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) + * */ + internal fun Map.appendAndDontOverride(rhs: Map): Map { + 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 + } + + internal fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + internal suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml new file mode 100644 index 00000000000..dbbc7dc9f21 --- /dev/null +++ b/app/src/main/res/drawable/clear_all_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml new file mode 100644 index 00000000000..737ff195947 --- /dev/null +++ b/app/src/main/res/drawable/dashed_line_horizontal.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml new file mode 100644 index 00000000000..8ef633fd236 --- /dev/null +++ b/app/src/main/res/drawable/netflix_download_batch.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 00000000000..d1360f9489f --- /dev/null +++ b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/download_queue_item.xml b/app/src/main/res/layout/download_queue_item.xml new file mode 100644 index 00000000000..86562a5139c --- /dev/null +++ b/app/src/main/res/layout/download_queue_item.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_download_queue.xml b/app/src/main/res/layout/fragment_download_queue.xml new file mode 100644 index 00000000000..c562940f903 --- /dev/null +++ b/app/src/main/res/layout/fragment_download_queue.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml index d6f41d6b0b8..96e1e50ab05 100644 --- a/app/src/main/res/layout/fragment_downloads.xml +++ b/app/src/main/res/layout/fragment_downloads.xml @@ -74,24 +74,24 @@ + android:orientation="horizontal"> @@ -112,8 +112,8 @@ app:shimmer_base_alpha="0.2" app:shimmer_duration="@integer/loading_time" app:shimmer_highlight_alpha="0.3" - tools:visibility="gone" - tools:ignore="MissingClass"> + tools:ignore="MissingClass" + tools:visibility="gone"> + + android:layout_height="wrap_content" + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> @@ -248,20 +249,19 @@ - + app:tint="?attr/textColor"> @@ -308,9 +308,38 @@ android:descendantFocusability="afterDescendants" android:nextFocusUp="@id/download_appbar" android:nextFocusLeft="@id/navigation_downloads" + android:nextFocusDown="@id/download_queue_button" android:tag="@string/tv_no_focus_tag" tools:listitem="@layout/download_header_episode" /> - + + + + + + + + + + android:layout_gravity="bottom|end" + android:layout_marginBottom="50dp" + android:orientation="vertical"> + android:tooltipText="@string/open_local_video" /> + app:icon="@drawable/ic_network_stream" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index c6de5954279..a5c933d6a03 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -586,75 +586,81 @@ android:paddingVertical="5dp" android:visibility="gone" tools:visibility="visible"> + + android:visibility="visible" /> - - - - - - - - + android:gravity="center" + android:orientation="horizontal"> + + + + + + + + + + + + + + + app:icon="@drawable/cast_ic_mini_controller_skip_next" + tools:visibility="visible" /> + + - - + + + - +