From e640cd0ba6cc391bde200513a7885795c4352681 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:30:24 +0000 Subject: [PATCH 1/4] download --- app/src/main/AndroidManifest.xml | 6 + .../lagradost/cloudstream3/MainActivity.kt | 11 +- .../cloudstream3/plugins/PluginManager.kt | 2 +- .../services/DownloadQueueService.kt | 153 +++ .../services/SubscriptionWorkManager.kt | 2 +- .../services/VideoDownloadService.kt | 19 +- .../ui/download/DownloadAdapter.kt | 14 +- .../ui/download/DownloadButtonSetup.kt | 23 +- .../ui/download/DownloadFragment.kt | 4 + .../ui/download/DownloadViewModel.kt | 36 +- .../ui/download/button/BaseFetchButton.kt | 6 +- .../ui/download/button/DownloadButton.kt | 4 +- .../ui/download/button/PieFetchButton.kt | 12 +- .../download/queue/DownloadQueueFragment.kt | 42 + .../cloudstream3/ui/home/HomeViewModel.kt | 4 +- .../ui/player/DownloadFileGenerator.kt | 6 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 2 +- .../cloudstream3/ui/result/EpisodeAdapter.kt | 14 +- .../ui/result/ResultFragmentPhone.kt | 6 +- .../ui/result/ResultViewModel2.kt | 315 ++---- .../cloudstream3/ui/search/SearchHelper.kt | 4 +- .../ui/settings/SettingsGeneral.kt | 9 +- .../ui/settings/SettingsUpdates.kt | 3 +- .../cloudstream3/utils/AppContextUtils.kt | 10 +- .../cloudstream3/utils/BackupUtils.kt | 9 +- .../cloudstream3/utils/DataStoreHelper.kt | 7 +- .../utils/DownloadFileWorkManager.kt | 104 -- .../cloudstream3/utils/SubtitleUtils.kt | 6 +- .../cloudstream3/utils/VideoDownloadHelper.kt | 55 -- .../utils/downloader/DownloadQueueManager.kt | 140 +++ .../downloader/VideoDownloadFileManagement.kt | 132 +++ .../{ => downloader}/VideoDownloadManager.kt | 910 +++++++----------- .../utils/downloader/VideoDownloadObjects.kt | 211 ++++ .../utils/downloader/VideoDownloadUtils.kt | 168 ++++ .../drawable/round_keyboard_arrow_up_24.xml | 13 + .../res/layout/fragment_download_queue.xml | 95 ++ .../main/res/layout/fragment_downloads.xml | 80 +- .../main/res/navigation/mobile_navigation.xml | 25 +- app/src/main/res/values/strings.xml | 10 + 39 files changed, 1562 insertions(+), 1110 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadFileManagement.kt rename app/src/main/java/com/lagradost/cloudstream3/utils/{ => downloader}/VideoDownloadManager.kt (74%) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadObjects.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadUtils.kt create mode 100644 app/src/main/res/drawable/round_keyboard_arrow_up_24.xml create mode 100644 app/src/main/res/layout/fragment_download_queue.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e1bc9ac97..56622aab91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -216,6 +216,12 @@ android:foregroundServiceType="dataSync" android:exported="false" /> + + = + mutableListOf() + + 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) + // 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() + + NotificationManagerCompat.from(context).notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) + } + + override fun onCreate() { + isRunning = true + 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()) + } + + val context = this.applicationContext + + ioSafe { + while (isRunning && (DownloadQueueManager.queue.isNotEmpty() || downloadInstances.isNotEmpty())) { + // Remove any completed or failed works + downloadInstances = + downloadInstances.filterNot { it.isCompleted || it.isFailed }.toMutableList() + + val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) + val currentDownloads = downloadInstances.size + + val newDownloads = minOf( + // Cannot exceed the max downloads + maxOf(0, maxDownloads - currentDownloads), + // Cannot start more downloads than the queue size + DownloadQueueManager.queue.size + ) + + repeat(newDownloads) { + val downloadInstance = DownloadQueueManager.popQueue(context) ?: return@repeat + downloadInstance.startDownload() + downloadInstances.add(downloadInstance) + } + + // TODO Notification for acquiring links + // The downloads actually displayed to the user with a notification + val currentVisualDownloads = VideoDownloadManager.currentDownloads.size + // The queue + the download instances not actively downloading + val currentVisualQueue = DownloadQueueManager.queue.size + downloadInstances.count { + VideoDownloadManager.currentDownloads.contains(it.downloadQueueWrapper.id).not() + } + + updateNotification(context, currentVisualDownloads, currentVisualQueue) + + // Arbitrary delay to prevent hogging the CPU, decrease to make the queue feel slightly more responsive + delay(500) + } + stopSelf() + } + } + + override fun onDestroy() { + Log.d(TAG, "Download queue service stopped.") + 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 fc31c1f3e0..b809120d40 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.VideoDownloadUtils.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 6151a0edd2..d63b18cdc9 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 d0740f66a8..061d26c701 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.VideoDownloadObjects const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 @@ -34,22 +34,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: VideoDownloadObjects.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadEpisodeCached, + override val data: VideoDownloadObjects.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: VideoDownloadObjects.DownloadHeaderCached, override var isSelected: Boolean, - val child: VideoDownloadHelper.DownloadEpisodeCached?, + val child: VideoDownloadObjects.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() @@ -57,12 +57,12 @@ sealed class VisualDownloadCached { data class DownloadClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadEpisodeCached + val data: VideoDownloadObjects.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadHeaderCached + val data: VideoDownloadObjects.DownloadHeaderCached ) class DownloadAdapter( 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 e9855ef3a3..9216a504bd 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 @@ -14,12 +14,13 @@ import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe 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.VideoDownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { @@ -82,7 +83,10 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) + ioSafe { + // TODO FIX THIS WITH PROPER QUEUE +// VideoDownloadManager.downloadFromResume(ctx, pkg) + } } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -95,7 +99,7 @@ object DownloadButtonSetup { DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( + VideoDownloadManager.getDownloadFileInfo( act, click.data.id )?.fileLength @@ -112,22 +116,25 @@ object DownloadButtonSetup { 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 e3d77abac9..0882b118a7 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 @@ -225,6 +225,10 @@ class DownloadFragment : BaseFragment( setOnClickListener { showStreamInputDialog(it.context) } } + downloadQueueButton.setOnClickListener { + findNavController().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 bf81e60698..c812d801b2 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 @@ -21,10 +21,9 @@ 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.getKeys -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.VideoDownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -119,14 +118,14 @@ class DownloadViewModel : ViewModel() { val visual = withContext(Dispatchers.IO) { val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = calculateDownloadStats(context, children) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } createVisualDownloadList( context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads @@ -163,7 +162,7 @@ class DownloadViewModel : ViewModel() { private fun calculateDownloadStats( context: Context, - children: List + children: List ): Triple, Map, Map> { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() @@ -173,8 +172,7 @@ class DownloadViewModel : ViewModel() { val totalDownloads = mutableMapOf() children.forEach { child -> - val childFile = - getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach + val childFile = getDownloadFileInfo(context, child.id) ?: return@forEach if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes @@ -189,7 +187,7 @@ class DownloadViewModel : ViewModel() { private fun createVisualDownloadList( context: Context, - cached: List, + cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map @@ -201,11 +199,10 @@ class DownloadViewModel : ViewModel() { 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( - DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) - ) + val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(it.id.toString(), it.id.toString()) + ) VisualDownloadCached.Header( currentBytes = currentBytes, @@ -236,11 +233,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, @@ -315,7 +311,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -339,7 +335,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 36a84d9f31..82c4dcc3be 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 20a4446114..e3b9d862cc 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.VideoDownloadObjects class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { @@ -35,7 +35,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: VideoDownloadObjects.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 3181a1bcd9..813509896b 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 @@ -23,9 +23,9 @@ 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.VideoDownloadObjects +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,7 +138,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : recycle() } - resetView() + // resetView() onInflate() } @@ -162,7 +162,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, + view: View, textView: TextView?, card: VideoDownloadObjects.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView @@ -212,7 +212,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } open fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: VideoDownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { 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 0000000000..20745453ed --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding + +class DownloadQueueFragment : Fragment() { +// private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentDownloadQueueBinding? = null + + companion object { + fun newInstance(): Bundle { + return Bundle().apply { + + } + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { +// downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentDownloadQueueBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + } +} \ 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 b7a322a848..e56fb8f0b8 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.VideoDownloadObjects 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 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 4c27dbc977..4789e16633 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.VideoDownloadFileManagement.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 48353736b4..5339be48e9 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 @@ -122,7 +122,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.VideoDownloadUtils.getImageBitmapFromUrl import com.lagradost.safefile.SafeFile import java.io.Serializable import java.util.Calendar 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 818e79d745..17e99137e1 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 @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.setPadding @@ -32,7 +33,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.VideoDownloadObjects +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 +162,7 @@ class EpisodeAdapter( downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + VideoDownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -198,6 +200,9 @@ class EpisodeAdapter( } } } + // TODO FIX THIS + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" @@ -376,7 +381,7 @@ class EpisodeAdapter( binding.apply { downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + VideoDownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -415,6 +420,9 @@ class EpisodeAdapter( } } + // TODO FIX THIS + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. 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 7f0339fa6a..b1b65f8045 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 @@ -86,15 +86,13 @@ 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.VideoDownloadObjects 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.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml import java.net.URLEncoder -import java.nio.charset.Charset -import kotlin.io.encoding.Base64 import kotlin.math.roundToInt open class ResultFragmentPhone : FullScreenPlayer() { @@ -728,7 +726,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { return@setOnLongClickListener true } downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + VideoDownloadObjects.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 106c05a3c3..a816a5b623 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 @@ -28,13 +28,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 @@ -44,9 +53,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 @@ -56,9 +62,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 @@ -85,10 +94,30 @@ 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.VideoDownloadManager.getDownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadUtils.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( @@ -665,228 +694,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) @@ -1610,16 +1417,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( + VideoDownloadObjects.DownloadQueueItem( + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url, + ).toWrapper() ) } @@ -1631,18 +1439,19 @@ class ResultViewModel2 : ViewModel() { txt(R.string.episode_action_download_mirror) ) { (result, index) -> ioSafe { - startDownload( - activity, - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url, - listOf(result.links[index]), - result.subs, + DownloadQueueManager.addToQueue( + VideoDownloadObjects.DownloadQueueItem( + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url, + listOf(result.links[index]), + result.subs, + ).toWrapper() ) } showToast( @@ -2807,7 +2616,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - VideoDownloadHelper.DownloadHeaderCached( + VideoDownloadObjects.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 e176d6c9b6..c2957aaeeb 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.VideoDownloadObjects object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -31,7 +31,7 @@ object SearchHelper { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - VideoDownloadHelper.DownloadEpisodeCached( + VideoDownloadObjects.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 e89865fc44..4c15fb1600 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 @@ -43,8 +43,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.VideoDownloadFileManagement +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import java.util.Locale // Change local language settings in the app. @@ -325,7 +326,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { fun getDownloadDirs(): List { return safe { context?.let { ctx -> - val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() + val defaultDir = VideoDownloadFileManagement.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -352,7 +353,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val currentDir = settingsManager.getString(getString(R.string.download_path_key_visual), null) - ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } + ?: context?.let { ctx -> VideoDownloadFileManagement.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 30cd00470a..97c8fa7425 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 @@ -21,7 +21,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 @@ -37,7 +36,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 8334833e46..b5b9cfe296 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -94,6 +94,12 @@ import java.net.URL import java.net.URLDecoder import java.util.concurrent.Executor import java.util.concurrent.Executors +import android.net.Uri +import android.util.Log +import androidx.tvprovider.media.tv.TvContractCompat +import com.lagradost.cloudstream3.SearchResponse +import android.content.Intent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects object AppContextUtils { @@ -152,7 +158,7 @@ object AppContextUtils { private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: VideoDownloadHelper.ResumeWatching? + resumeWatching: VideoDownloadObjects.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -319,7 +325,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 1b67fe90c3..2839bd7f1d 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,8 @@ 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.VideoDownloadObjects import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly @@ -207,7 +206,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"): VideoDownloadObjects.StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), @@ -289,7 +288,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/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 217dc2a520..44c85ea0c2 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.VideoDownloadObjects 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( + VideoDownloadObjects.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?): VideoDownloadObjects.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?): VideoDownloadObjects.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 0b9b81e402..e69de29bb2 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 66a6e156c7..d7c0e19832 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,9 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.lagradost.api.Log -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects import com.lagradost.safefile.SafeFile object SubtitleUtils { @@ -13,7 +15,7 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + fun deleteMatchingSubtitles(context: Context, info: VideoDownloadObjects.DownloadedFileInfo) { val relative = info.relativePath val display = info.displayName val cleanDisplay = cleanDisplayName(display) 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 fcee1e45ac..e69de29bb2 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/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt new file mode 100644 index 0000000000..cbf84cdfce --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,140 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.util.Log +import androidx.core.content.ContextCompat +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.DownloadQueueWrapper +import kotlin.collections.filter +import kotlin.collections.forEach + +// 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" + private const val QUEUE_KEY = "download_queue_key" + + /** 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 { + val resumePackages = + // We do not want to resume downloads already downloading + resumeIds.filterNot { VideoDownloadManager.currentDownloads.contains(it) } + .mapNotNull { id -> + getDownloadResumePackage(context, id)?.toWrapper() + } + + synchronized(queue) { + // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started + queue = (resumePackages + queue).distinctBy { it.id }.toTypedArray() + // Make sure the download buttons display a pending status + queue.forEach { obj -> + setQueueStatus(obj) + } + if (queue.any()) { + startQueueService(context) + } + } + } + } + + val resumeIds: Set + get() { + return getKeys(KEY_RESUME_PACKAGES)?.mapNotNull { + it.substringAfter("$KEY_RESUME_PACKAGES/").toIntOrNull() + }?.toSet() + ?: emptySet() + } + + /** Persistent queue */ + var queue: Array + get() { + return getKey>(QUEUE_KEY) ?: emptyArray() + } + private set(value) { + setKey(QUEUE_KEY, value) + } + + /** Adds an object to the internal persistent queue. It does not re-add an existing item. */ + private fun add(downloadQueueWrapper: DownloadQueueWrapper) { + synchronized(queue) { + val localQueue = queue + + // Do not add the same episode twice + if (localQueue.any { it.id == downloadQueueWrapper.id }) { + return + } + + queue = localQueue + downloadQueueWrapper + } + } + + /** Removes all objects with the same id from the internal persistent queue */ + private fun remove(downloadQueueWrapper: DownloadQueueWrapper) { + synchronized(queue) { + val localQueue = queue + val id = downloadQueueWrapper.id + queue = localQueue.filter { it.id != id }.toTypedArray() + } + } + + /** Start a real download from the first item in the queue */ + fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { + val first = synchronized(queue) { + queue.firstOrNull() + } ?: return null + remove(first) + + val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) + + return downloadInstance + } + + /** Marks the item as in queue for the download button */ + private fun setQueueStatus(downloadQueueWrapper: DownloadQueueWrapper) { + downloadStatusEvent.invoke( + Pair( + downloadQueueWrapper.id, + VideoDownloadManager.DownloadType.IsPending + ) + ) + downloadStatus[downloadQueueWrapper.id] = VideoDownloadManager.DownloadType.IsPending + } + + 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 + // TODO Prevent issues where the service is closed right as a user starts a new download + if (DownloadQueueService.isRunning) { + return + } + ioSafe { + val intent = DownloadQueueService.getIntent(context) + ContextCompat.startForegroundService(context, intent) + } + } + + /** Add a new object to the queue */ + fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) { + ioSafe { + add(downloadQueueWrapper) + setQueueStatus(downloadQueueWrapper) + startQueueService(AcraApplication.context) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadFileManagement.kt new file mode 100644 index 0000000000..d379e273aa --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadFileManagement.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 VideoDownloadFileManagement { + 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: VideoDownloadObjects.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 VideoDownloadObjects.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/VideoDownloadManager.kt similarity index 74% rename from app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadManager.kt index 9748bd2967..a497ab0972 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadManager.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 @@ -43,13 +32,52 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError 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.VideoDownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.getDefaultDir +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.toFile +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.CreateNotificationMetadata +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.DownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.DownloadItem +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.DownloadResumePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.DownloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.DownloadedFileInfo +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.DownloadedFileInfoResult +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.LazyStreamDownloadResponse +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadObjects.StreamData +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadUtils.appendAndDontOverride +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadUtils.cancel +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadUtils.getEstimatedTimeLeft +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadUtils.join import com.lagradost.safefile.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException @@ -62,21 +90,19 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay 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,7 +110,8 @@ object VideoDownloadManager { PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 - private var currentDownloads = mutableListOf() + var currentDownloads = mutableSetOf() + const val TAG = "VDM" private const val USER_AGENT = @@ -129,56 +156,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 = @@ -201,111 +178,32 @@ object VideoDownloadManager { const val KEY_RESUME_PACKAGES = "download_resume" const val KEY_DOWNLOAD_INFO = "download_info" - private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" +// 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) - - 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 - } + hasCreatedNotChannel = true - 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) ?: "" + this.createNotificationChannel( + DOWNLOAD_CHANNEL_ID, + DOWNLOAD_CHANNEL_NAME, + DOWNLOAD_CHANNEL_DESCRIPT + ) } + /** * @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 +219,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 +302,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) { @@ -520,7 +417,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChanel) { + if (!hasCreatedNotChannel) { context.createNotificationChannel() } @@ -544,69 +441,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 +462,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( @@ -718,7 +552,7 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() +// saveQueue() stopListener?.invoke() stopListener = null } @@ -880,34 +714,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 +806,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 +929,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( @@ -1610,75 +1390,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. - * */ - private 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 +1415,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createNotification( + createDownloadNotification( context, source, link.name, @@ -1758,100 +1469,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? { @@ -1913,23 +1541,6 @@ 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 @@ -1950,119 +1561,292 @@ 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 getDownloadEpisodeMetadata( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadEpisodeMetadata { + return DownloadEpisodeMetadata( + episode.id, + 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: VideoDownloadObjects.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 var subtitleDownloadJob: Job? = null + private var downloadJob: Job? = null + var isCompleted = false + var isFailed = false + + companion object { + private fun displayNotification(context: Context, id: Int, notification: Notification) { + NotificationManagerCompat.from(context).notify(id, notification) + } } - } - 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) + private suspend fun downloadFromResume( + downloadResumePackage: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + ) { + val item = downloadResumePackage.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 (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) + ) + + 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()) + isCompleted = true + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) + isFailed = true + break + } + } + } catch (e: Exception) { + logError(e) + isFailed = true + } finally { + currentDownloads.remove(id) + } } - } - /*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 + 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 } } - 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() { + val resumePackage = downloadQueueWrapper.resumePackage ?: return + downloadFromResume(resumePackage) { id, notification -> + displayNotification(context, id, notification) + } + } - /** 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 - ) - } + fun startDownload() { + 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 + ) + } + } + } - fun downloadCheckUsingWorker( - context: Context, - ) { - startWork(context, DOWNLOAD_CHECK) - } + private fun downloadEpisodeWithLinks( + links: List, + subs: List? + ) { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + try { + // Prepare visual keys + setKey( + DOWNLOAD_HEADER_CACHE, + downloadItem.resultId.toString(), + VideoDownloadObjects.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(), + VideoDownloadObjects.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(), + ) + ) - fun downloadFromResumeUsingWorker( - context: Context, - pkg: DownloadResumePackage, - ) { - val key = pkg.item.ep.id.toString() - setKey(WORK_KEY_PACKAGE, key, pkg) - startWork(context, key) - } - // 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" - fun downloadEpisodeUsingWorker( - context: Context, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - ) { - val info = DownloadInfo( - source, folder, ep, links - ) + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) - val key = info.ep.id.toString() - setKey(WORK_KEY_INFO, key, info) - startWork(context, key) - } + val folder = + getFolder(downloadItem.resultType, downloadItem.resultName) + val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" - data class DownloadInfo( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List - ) + // DOWNLOAD VIDEO + val info = DownloadItem(src, folder, meta, links) + + 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 { + 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 (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() + 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 + } 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/VideoDownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadObjects.kt new file mode 100644 index 0000000000..a3600860d8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadObjects.kt @@ -0,0 +1,211 @@ +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.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 VideoDownloadObjects { + /** An item can either be something to resume or something new to start */ + @ConsistentCopyVisibility + data class DownloadQueueWrapper internal constructor( + @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!" + } + } + + @JsonProperty("id") val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id + } + + /** 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) { + score = Score.Companion.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("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 getDefaultPath() + ) + + 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/VideoDownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadUtils.kt new file mode 100644 index 0000000000..6c18e25007 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/VideoDownloadUtils.kt @@ -0,0 +1,168 @@ +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.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.VideoDownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking + +/** Separate object with helper functions for the downloader */ +object VideoDownloadUtils { + private val cachedBitmaps = hashMapOf() + internal fun Context.getImageBitmapFromUrl( + url: String, + headers: Map? = null + ): Bitmap? { + try { + if (cachedBitmaps.containsKey(url)) { + return 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 bitmap + } catch (e: Exception) { + logError(e) + return null + } + } + + //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: VideoDownloadObjects.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/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 0000000000..d1360f9489 --- /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/fragment_download_queue.xml b/app/src/main/res/layout/fragment_download_queue.xml new file mode 100644 index 0000000000..c3ab356c27 --- /dev/null +++ b/app/src/main/res/layout/fragment_download_queue.xml @@ -0,0 +1,95 @@ + + + + + + + + + +