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:
parent
a22a65ec16
commit
45ccea57af
9 changed files with 118 additions and 62 deletions
|
@ -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
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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>>()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
20
app/src/main/res/drawable/ic_rotate_ccw.xml
Normal file
20
app/src/main/res/drawable/ic_rotate_ccw.xml
Normal 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>
|
Loading…
Reference in a new issue