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")
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.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) {

View file

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

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 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<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> {
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<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 {
val item = downloadDatabaseDao.loadItem(itemId)
return item != null

View file

@ -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>(UiState.Loading)
@ -38,6 +41,7 @@ constructor(
viewModelScope.launch {
_uiState.emit(UiState.Loading)
try {
checkDownloadStatus(downloadDatabase, application)
val items = loadDownloadedEpisodes(downloadDatabase)
val showsMap = mutableMapOf<UUID, MutableList<PlayerItem>>()

View file

@ -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<PlayerItem> = 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
)
}
}

View file

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

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>