From 45ccea57afd7b5e049a551f87a115b692e174932 Mon Sep 17 00:00:00 2001 From: Jcuhfehl <91626737+Jcuhfehl@users.noreply.github.com> Date: Sat, 29 Oct 2022 15:08:43 +0200 Subject: [PATCH] Improve downloads management (#179) * Fix deleted downloads This commit fixes downloads getting deleted after a few weeks by android's cleanup system. This is fixed by downloading the files under the .download extension and renaming them when the download is completed. * Add retry download feature * Add indicator when download is ongoing * Refactor download code * Disable button on retry and clean up Co-authored-by: Jarne Demeulemeester --- .../jellyfin/database/DownloadDatabaseDao.kt | 4 ++ .../fragments/EpisodeBottomSheetFragment.kt | 17 +++++- .../jellyfin/fragments/MediaInfoFragment.kt | 17 +++++- .../jellyfin/models/DownloadRequestItem.kt | 12 ---- .../jellyfin/utils/DownloadUtilities.kt | 55 +++++++++++++++---- .../jellyfin/viewmodels/DownloadViewModel.kt | 4 ++ .../viewmodels/EpisodeBottomSheetViewModel.kt | 27 +++------ .../jellyfin/viewmodels/MediaInfoViewModel.kt | 24 +++----- app/src/main/res/drawable/ic_rotate_ccw.xml | 20 +++++++ 9 files changed, 118 insertions(+), 62 deletions(-) delete mode 100644 app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt create mode 100644 app/src/main/res/drawable/ic_rotate_ccw.xml diff --git a/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt b/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt index 68427ae9..65597044 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/database/DownloadDatabaseDao.kt @@ -25,4 +25,8 @@ interface DownloadDatabaseDao { @Query("update downloads set downloadId = :downloadId where id = :id") fun updateDownloadId(id: UUID, downloadId: Long) + + @Query("SELECT EXISTS (SELECT 1 FROM downloads WHERE id = :id)") + fun exists(id: UUID): Boolean + } \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt index a36eb9cd..a7777c7d 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -50,6 +50,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { binding.playButton.setOnClickListener { binding.playButton.setImageResource(android.R.color.transparent) binding.progressCircular.isVisible = true + if (viewModel.canRetry){ + binding.playButton.isEnabled = false + viewModel.download() + return@setOnClickListener + } viewModel.item?.let { if (!args.isOffline) { playerViewModel.loadPlayerItems(it) @@ -112,7 +117,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { binding.downloadButton.setOnClickListener { binding.downloadButton.isEnabled = false - viewModel.loadDownloadRequestItem(episodeId) + viewModel.download() binding.downloadButton.setTintColor(R.color.red, requireActivity().theme) } @@ -155,8 +160,14 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { binding.progressBar.isVisible = true } - binding.playButton.isEnabled = available - binding.playButton.alpha = if (!available) 0.5F else 1.0F + val clickable = available || canRetry + binding.playButton.isEnabled = clickable + binding.playButton.alpha = if (!clickable) 0.5F else 1.0F + binding.playButton.setImageResource(if (!canRetry) R.drawable.ic_play else R.drawable.ic_rotate_ccw) + if (!clickable) { + binding.playButton.setImageResource(android.R.color.transparent) + binding.progressCircular.isVisible = true + } // Check icon when (played) { diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt index eb6983cb..c7fdca57 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt @@ -121,6 +121,11 @@ class MediaInfoFragment : Fragment() { binding.playButton.setOnClickListener { binding.playButton.setImageResource(android.R.color.transparent) binding.progressCircular.isVisible = true + if (viewModel.canRetry){ + binding.playButton.isEnabled = false + viewModel.download() + return@setOnClickListener + } viewModel.item?.let { item -> if (!args.isOffline) { playerViewModel.loadPlayerItems(item) { @@ -174,7 +179,7 @@ class MediaInfoFragment : Fragment() { binding.downloadButton.setOnClickListener { binding.downloadButton.isEnabled = false - viewModel.loadDownloadRequestItem(args.itemId) + viewModel.download() binding.downloadButton.imageTintList = ColorStateList.valueOf( resources.getColor( R.color.red, @@ -205,8 +210,14 @@ class MediaInfoFragment : Fragment() { binding.communityRating.isVisible = item.communityRating != null binding.actors.isVisible = actors.isNotEmpty() - binding.playButton.isEnabled = available - binding.playButton.alpha = if (!available) 0.5F else 1.0F + val clickable = available || canRetry + binding.playButton.isEnabled = clickable + binding.playButton.alpha = if (!clickable) 0.5F else 1.0F + binding.playButton.setImageResource(if (!canRetry) R.drawable.ic_play else R.drawable.ic_rotate_ccw) + if (!clickable) { + binding.playButton.setImageResource(android.R.color.transparent) + binding.progressCircular.isVisible = true + } // Check icon when (played) { diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt deleted file mode 100644 index f5b82a0c..00000000 --- a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.jdtech.jellyfin.models - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import java.util.* - -@Parcelize -data class DownloadRequestItem( - val uri: String, - val itemId: UUID, - val item: DownloadItem -) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt index 686d404e..636e7be6 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt @@ -8,7 +8,6 @@ import androidx.core.content.getSystemService import androidx.preference.PreferenceManager import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.models.DownloadItem -import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.DownloadSeriesMetadata import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.repository.JellyfinRepository @@ -21,31 +20,37 @@ import java.util.UUID var defaultStorage: File? = null -fun requestDownload( +suspend fun requestDownload( + jellyfinRepository: JellyfinRepository, downloadDatabase: DownloadDatabaseDao, - uri: Uri, - downloadRequestItem: DownloadRequestItem, - context: Context + context: Context, + itemId: UUID ) { - val downloadRequest = DownloadManager.Request(uri) - .setTitle(downloadRequestItem.item.name) + val episode = jellyfinRepository.getItem(itemId) + val uri = jellyfinRepository.getStreamUrl(itemId, episode.mediaSources?.get(0)?.id!!) + val metadata = baseItemDtoToDownloadMetadata(episode) + + val downloadRequest = DownloadManager.Request(Uri.parse(uri)) + .setTitle(metadata.name) .setDescription("Downloading") .setDestinationUri( Uri.fromFile( File( defaultStorage, - downloadRequestItem.itemId.toString() + metadata.id.toString() + ".downloading" ) ) ) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) try { - downloadDatabase.insertItem(downloadRequestItem.item) - if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists()) { + if (downloadDatabase.exists(metadata.id)) + downloadDatabase.deleteItem(metadata.id) + downloadDatabase.insertItem(metadata) + if (!File(defaultStorage, metadata.id.toString()).exists() && !File(defaultStorage, "${metadata.id}.downloading").exists()) { val downloadId = downloadFile(downloadRequest, context) Timber.d("$downloadId") - downloadDatabase.updateDownloadId(downloadRequestItem.itemId, downloadId) + downloadDatabase.updateDownloadId(metadata.id, downloadId) } } catch (e: Exception) { Timber.e(e) @@ -68,6 +73,21 @@ fun loadDownloadLocation(context: Context) { defaultStorage = context.getExternalFilesDir(Environment.DIRECTORY_MOVIES) } +fun checkDownloadStatus(downloadDatabase: DownloadDatabaseDao, context: Context) { + val items = downloadDatabase.loadItems() + for (item in items) { + try{ + val query = DownloadManager.Query() + .setFilterById(item.downloadId!!) + val result = context.getSystemService()!!.query(query) + result.moveToFirst() + if (result.getInt(7) == 8) { + File(defaultStorage, "${item.id}.downloading").renameTo(File(defaultStorage, item.id.toString())) + } + } catch (_: Exception) {} + } +} + fun loadDownloadedEpisodes(downloadDatabase: DownloadDatabaseDao): List { val items = downloadDatabase.loadItems() return items.map { @@ -88,6 +108,19 @@ fun isItemAvailable(itemId: UUID): Boolean { return File(defaultStorage, itemId.toString()).exists() } +fun canRetryDownload(itemId: UUID, downloadDatabaseDao: DownloadDatabaseDao, context: Context): Boolean { + if (isItemAvailable(itemId)) + return false + val downloadId = downloadDatabaseDao.loadItem(itemId)?.downloadId ?: return false + val query = DownloadManager.Query().setFilterById(downloadId) + val result = context.getSystemService()!!.query(query) + result.moveToFirst() + if (result.count == 0) + return true + val status = result.getInt(result.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + return status == 16 +} + fun isItemDownloaded(downloadDatabaseDao: DownloadDatabaseDao, itemId: UUID): Boolean { val item = downloadDatabaseDao.loadItem(itemId) return item != null diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt index 13f1df85..3c2b4af3 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/DownloadViewModel.kt @@ -1,11 +1,13 @@ package dev.jdtech.jellyfin.viewmodels +import android.app.Application import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.models.DownloadSection import dev.jdtech.jellyfin.models.DownloadSeriesMetadata import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.utils.checkDownloadStatus import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -19,6 +21,7 @@ import javax.inject.Inject class DownloadViewModel @Inject constructor( + private val application: Application, private val downloadDatabase: DownloadDatabaseDao, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState.Loading) @@ -38,6 +41,7 @@ constructor( viewModelScope.launch { _uiState.emit(UiState.Loading) try { + checkDownloadStatus(downloadDatabase, application) val items = loadDownloadedEpisodes(downloadDatabase) val showsMap = mutableMapOf>() diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt index a052c370..86dfb0f9 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/EpisodeBottomSheetViewModel.kt @@ -1,11 +1,9 @@ package dev.jdtech.jellyfin.viewmodels import android.app.Application -import android.net.Uri import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.database.DownloadDatabaseDao -import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.utils.* @@ -42,8 +40,8 @@ constructor( val favorite: Boolean, val canDownload: Boolean, val downloaded: Boolean, - val downloadEpisode: Boolean, val available: Boolean, + val canRetry: Boolean ) : UiState() object Loading : UiState() @@ -57,12 +55,10 @@ constructor( var favorite: Boolean = false private var canDownload = false private var downloaded: Boolean = false - private var downloadEpisode: Boolean = false private var available: Boolean = true + var canRetry: Boolean = false var playerItems: MutableList = mutableListOf() - private lateinit var downloadRequestItem: DownloadRequestItem - fun loadEpisode(episodeId: UUID) { viewModelScope.launch { _uiState.emit(UiState.Loading) @@ -84,8 +80,8 @@ constructor( favorite, canDownload, downloaded, - downloadEpisode, available, + canRetry, ) ) } catch (e: Exception) { @@ -100,7 +96,7 @@ constructor( playerItems.add(playerItem) item = downloadMetadataToBaseItemDto(playerItem.item!!) available = isItemAvailable(playerItem.itemId) - Timber.d("Available: $available") + canRetry = canRetryDownload(playerItem.itemId, downloadDatabase, application) _uiState.emit( UiState.Normal( item!!, @@ -110,8 +106,8 @@ constructor( favorite, canDownload, downloaded, - downloadEpisode, available, + canRetry, ) ) } @@ -161,18 +157,13 @@ constructor( favorite = false } - fun loadDownloadRequestItem(itemId: UUID) { + fun download() { viewModelScope.launch { - val episode = item - val uri = jellyfinRepository.getStreamUrl(itemId, episode?.mediaSources?.get(0)?.id!!) - val metadata = baseItemDtoToDownloadMetadata(episode) - downloadRequestItem = DownloadRequestItem(uri, itemId, metadata) - downloadEpisode = true requestDownload( + jellyfinRepository, downloadDatabase, - Uri.parse(downloadRequestItem.uri), - downloadRequestItem, - application + application, + item!!.id ) } } diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt index 7cd12a86..7a012d7d 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/MediaInfoViewModel.kt @@ -1,12 +1,10 @@ package dev.jdtech.jellyfin.viewmodels import android.app.Application -import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.database.DownloadDatabaseDao -import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.utils.* @@ -50,6 +48,7 @@ constructor( val favorite: Boolean, val canDownload: Boolean, val downloaded: Boolean, + var canRetry: Boolean = false, val available: Boolean, ) : UiState() @@ -71,11 +70,9 @@ constructor( var favorite: Boolean = false private var canDownload: Boolean = false private var downloaded: Boolean = false - private var downloadMedia: Boolean = false + var canRetry: Boolean = false private var available: Boolean = true - private lateinit var downloadRequestItem: DownloadRequestItem - lateinit var playerItem: PlayerItem fun loadData(itemId: UUID, itemType: BaseItemKind) { @@ -115,6 +112,7 @@ constructor( favorite, canDownload, downloaded, + canRetry, available ) ) @@ -139,6 +137,7 @@ constructor( played = tempItem.userData?.played ?: false favorite = tempItem.userData?.isFavorite ?: false available = isItemAvailable(tempItem.id) + canRetry = canRetryDownload(tempItem.id, downloadDatabase, application) _uiState.emit( UiState.Normal( tempItem, @@ -155,6 +154,7 @@ constructor( favorite, canDownload, downloaded, + canRetry, available ) ) @@ -253,19 +253,13 @@ constructor( return dateRange.joinToString(separator = " - ") } - fun loadDownloadRequestItem(itemId: UUID) { + fun download() { viewModelScope.launch { - val downloadItem = item - val uri = - jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!) - val metadata = baseItemDtoToDownloadMetadata(downloadItem) - downloadRequestItem = DownloadRequestItem(uri, itemId, metadata) - downloadMedia = true requestDownload( + jellyfinRepository, downloadDatabase, - Uri.parse(downloadRequestItem.uri), - downloadRequestItem, - application + application, + item!!.id ) } } diff --git a/app/src/main/res/drawable/ic_rotate_ccw.xml b/app/src/main/res/drawable/ic_rotate_ccw.xml new file mode 100644 index 00000000..bed0c9e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_rotate_ccw.xml @@ -0,0 +1,20 @@ + + + +