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 <jarnedemeulemeester@gmail.com>
This commit is contained in:
Jcuhfehl 2022-10-29 15:08:43 +02:00 committed by GitHub
parent a22a65ec16
commit 45ccea57af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 118 additions and 62 deletions

View file

@ -25,4 +25,8 @@ interface DownloadDatabaseDao {
@Query("update downloads set downloadId = :downloadId where id = :id") @Query("update downloads set downloadId = :downloadId where id = :id")
fun updateDownloadId(id: UUID, downloadId: Long) fun updateDownloadId(id: UUID, downloadId: Long)
@Query("SELECT EXISTS (SELECT 1 FROM downloads WHERE id = :id)")
fun exists(id: UUID): Boolean
} }

View file

@ -50,6 +50,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.playButton.setOnClickListener { binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.isVisible = true binding.progressCircular.isVisible = true
if (viewModel.canRetry){
binding.playButton.isEnabled = false
viewModel.download()
return@setOnClickListener
}
viewModel.item?.let { viewModel.item?.let {
if (!args.isOffline) { if (!args.isOffline) {
playerViewModel.loadPlayerItems(it) playerViewModel.loadPlayerItems(it)
@ -112,7 +117,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.downloadButton.setOnClickListener { binding.downloadButton.setOnClickListener {
binding.downloadButton.isEnabled = false binding.downloadButton.isEnabled = false
viewModel.loadDownloadRequestItem(episodeId) viewModel.download()
binding.downloadButton.setTintColor(R.color.red, requireActivity().theme) binding.downloadButton.setTintColor(R.color.red, requireActivity().theme)
} }
@ -155,8 +160,14 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
} }
binding.playButton.isEnabled = available val clickable = available || canRetry
binding.playButton.alpha = if (!available) 0.5F else 1.0F 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 // Check icon
when (played) { when (played) {

View file

@ -121,6 +121,11 @@ class MediaInfoFragment : Fragment() {
binding.playButton.setOnClickListener { binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent) binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.isVisible = true binding.progressCircular.isVisible = true
if (viewModel.canRetry){
binding.playButton.isEnabled = false
viewModel.download()
return@setOnClickListener
}
viewModel.item?.let { item -> viewModel.item?.let { item ->
if (!args.isOffline) { if (!args.isOffline) {
playerViewModel.loadPlayerItems(item) { playerViewModel.loadPlayerItems(item) {
@ -174,7 +179,7 @@ class MediaInfoFragment : Fragment() {
binding.downloadButton.setOnClickListener { binding.downloadButton.setOnClickListener {
binding.downloadButton.isEnabled = false binding.downloadButton.isEnabled = false
viewModel.loadDownloadRequestItem(args.itemId) viewModel.download()
binding.downloadButton.imageTintList = ColorStateList.valueOf( binding.downloadButton.imageTintList = ColorStateList.valueOf(
resources.getColor( resources.getColor(
R.color.red, R.color.red,
@ -205,8 +210,14 @@ class MediaInfoFragment : Fragment() {
binding.communityRating.isVisible = item.communityRating != null binding.communityRating.isVisible = item.communityRating != null
binding.actors.isVisible = actors.isNotEmpty() binding.actors.isVisible = actors.isNotEmpty()
binding.playButton.isEnabled = available val clickable = available || canRetry
binding.playButton.alpha = if (!available) 0.5F else 1.0F 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 // Check icon
when (played) { when (played) {

View file

@ -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

View file

@ -8,7 +8,6 @@ import androidx.core.content.getSystemService
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.DownloadItem import dev.jdtech.jellyfin.models.DownloadItem
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
@ -21,31 +20,37 @@ import java.util.UUID
var defaultStorage: File? = null var defaultStorage: File? = null
fun requestDownload( suspend fun requestDownload(
jellyfinRepository: JellyfinRepository,
downloadDatabase: DownloadDatabaseDao, downloadDatabase: DownloadDatabaseDao,
uri: Uri, context: Context,
downloadRequestItem: DownloadRequestItem, itemId: UUID
context: Context
) { ) {
val downloadRequest = DownloadManager.Request(uri) val episode = jellyfinRepository.getItem(itemId)
.setTitle(downloadRequestItem.item.name) 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") .setDescription("Downloading")
.setDestinationUri( .setDestinationUri(
Uri.fromFile( Uri.fromFile(
File( File(
defaultStorage, defaultStorage,
downloadRequestItem.itemId.toString() metadata.id.toString() + ".downloading"
) )
) )
) )
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
try { try {
downloadDatabase.insertItem(downloadRequestItem.item) if (downloadDatabase.exists(metadata.id))
if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists()) { 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) val downloadId = downloadFile(downloadRequest, context)
Timber.d("$downloadId") Timber.d("$downloadId")
downloadDatabase.updateDownloadId(downloadRequestItem.itemId, downloadId) downloadDatabase.updateDownloadId(metadata.id, downloadId)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
@ -68,6 +73,21 @@ fun loadDownloadLocation(context: Context) {
defaultStorage = context.getExternalFilesDir(Environment.DIRECTORY_MOVIES) 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<DownloadManager>()!!.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<PlayerItem> { fun loadDownloadedEpisodes(downloadDatabase: DownloadDatabaseDao): List<PlayerItem> {
val items = downloadDatabase.loadItems() val items = downloadDatabase.loadItems()
return items.map { return items.map {
@ -88,6 +108,19 @@ fun isItemAvailable(itemId: UUID): Boolean {
return File(defaultStorage, itemId.toString()).exists() 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<DownloadManager>()!!.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 { fun isItemDownloaded(downloadDatabaseDao: DownloadDatabaseDao, itemId: UUID): Boolean {
val item = downloadDatabaseDao.loadItem(itemId) val item = downloadDatabaseDao.loadItem(itemId)
return item != null return item != null

View file

@ -1,11 +1,13 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import androidx.lifecycle.* import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.DownloadSection import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkDownloadStatus
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -19,6 +21,7 @@ import javax.inject.Inject
class DownloadViewModel class DownloadViewModel
@Inject @Inject
constructor( constructor(
private val application: Application,
private val downloadDatabase: DownloadDatabaseDao, private val downloadDatabase: DownloadDatabaseDao,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading) private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
@ -38,6 +41,7 @@ constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.emit(UiState.Loading) _uiState.emit(UiState.Loading)
try { try {
checkDownloadStatus(downloadDatabase, application)
val items = loadDownloadedEpisodes(downloadDatabase) val items = loadDownloadedEpisodes(downloadDatabase)
val showsMap = mutableMapOf<UUID, MutableList<PlayerItem>>() val showsMap = mutableMapOf<UUID, MutableList<PlayerItem>>()

View file

@ -1,11 +1,9 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.app.Application import android.app.Application
import android.net.Uri
import androidx.lifecycle.* import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.* import dev.jdtech.jellyfin.utils.*
@ -42,8 +40,8 @@ constructor(
val favorite: Boolean, val favorite: Boolean,
val canDownload: Boolean, val canDownload: Boolean,
val downloaded: Boolean, val downloaded: Boolean,
val downloadEpisode: Boolean,
val available: Boolean, val available: Boolean,
val canRetry: Boolean
) : UiState() ) : UiState()
object Loading : UiState() object Loading : UiState()
@ -57,12 +55,10 @@ constructor(
var favorite: Boolean = false var favorite: Boolean = false
private var canDownload = false private var canDownload = false
private var downloaded: Boolean = false private var downloaded: Boolean = false
private var downloadEpisode: Boolean = false
private var available: Boolean = true private var available: Boolean = true
var canRetry: Boolean = false
var playerItems: MutableList<PlayerItem> = mutableListOf() var playerItems: MutableList<PlayerItem> = mutableListOf()
private lateinit var downloadRequestItem: DownloadRequestItem
fun loadEpisode(episodeId: UUID) { fun loadEpisode(episodeId: UUID) {
viewModelScope.launch { viewModelScope.launch {
_uiState.emit(UiState.Loading) _uiState.emit(UiState.Loading)
@ -84,8 +80,8 @@ constructor(
favorite, favorite,
canDownload, canDownload,
downloaded, downloaded,
downloadEpisode,
available, available,
canRetry,
) )
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -100,7 +96,7 @@ constructor(
playerItems.add(playerItem) playerItems.add(playerItem)
item = downloadMetadataToBaseItemDto(playerItem.item!!) item = downloadMetadataToBaseItemDto(playerItem.item!!)
available = isItemAvailable(playerItem.itemId) available = isItemAvailable(playerItem.itemId)
Timber.d("Available: $available") canRetry = canRetryDownload(playerItem.itemId, downloadDatabase, application)
_uiState.emit( _uiState.emit(
UiState.Normal( UiState.Normal(
item!!, item!!,
@ -110,8 +106,8 @@ constructor(
favorite, favorite,
canDownload, canDownload,
downloaded, downloaded,
downloadEpisode,
available, available,
canRetry,
) )
) )
} }
@ -161,18 +157,13 @@ constructor(
favorite = false favorite = false
} }
fun loadDownloadRequestItem(itemId: UUID) { fun download() {
viewModelScope.launch { 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( requestDownload(
jellyfinRepository,
downloadDatabase, downloadDatabase,
Uri.parse(downloadRequestItem.uri), application,
downloadRequestItem, item!!.id
application
) )
} }
} }

View file

@ -1,12 +1,10 @@
package dev.jdtech.jellyfin.viewmodels package dev.jdtech.jellyfin.viewmodels
import android.app.Application import android.app.Application
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.database.DownloadDatabaseDao import dev.jdtech.jellyfin.database.DownloadDatabaseDao
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.* import dev.jdtech.jellyfin.utils.*
@ -50,6 +48,7 @@ constructor(
val favorite: Boolean, val favorite: Boolean,
val canDownload: Boolean, val canDownload: Boolean,
val downloaded: Boolean, val downloaded: Boolean,
var canRetry: Boolean = false,
val available: Boolean, val available: Boolean,
) : UiState() ) : UiState()
@ -71,11 +70,9 @@ constructor(
var favorite: Boolean = false var favorite: Boolean = false
private var canDownload: Boolean = false private var canDownload: Boolean = false
private var downloaded: Boolean = false private var downloaded: Boolean = false
private var downloadMedia: Boolean = false var canRetry: Boolean = false
private var available: Boolean = true private var available: Boolean = true
private lateinit var downloadRequestItem: DownloadRequestItem
lateinit var playerItem: PlayerItem lateinit var playerItem: PlayerItem
fun loadData(itemId: UUID, itemType: BaseItemKind) { fun loadData(itemId: UUID, itemType: BaseItemKind) {
@ -115,6 +112,7 @@ constructor(
favorite, favorite,
canDownload, canDownload,
downloaded, downloaded,
canRetry,
available available
) )
) )
@ -139,6 +137,7 @@ constructor(
played = tempItem.userData?.played ?: false played = tempItem.userData?.played ?: false
favorite = tempItem.userData?.isFavorite ?: false favorite = tempItem.userData?.isFavorite ?: false
available = isItemAvailable(tempItem.id) available = isItemAvailable(tempItem.id)
canRetry = canRetryDownload(tempItem.id, downloadDatabase, application)
_uiState.emit( _uiState.emit(
UiState.Normal( UiState.Normal(
tempItem, tempItem,
@ -155,6 +154,7 @@ constructor(
favorite, favorite,
canDownload, canDownload,
downloaded, downloaded,
canRetry,
available available
) )
) )
@ -253,19 +253,13 @@ constructor(
return dateRange.joinToString(separator = " - ") return dateRange.joinToString(separator = " - ")
} }
fun loadDownloadRequestItem(itemId: UUID) { fun download() {
viewModelScope.launch { 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( requestDownload(
jellyfinRepository,
downloadDatabase, downloadDatabase,
Uri.parse(downloadRequestItem.uri), application,
downloadRequestItem, item!!.id
application
) )
} }
} }

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,2v6h6"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M3,13a9,9 0,1 0,3 -7.7L3,8"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@color/white"
android:strokeLineCap="round"/>
</vector>