mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-03 18:38:49 +02:00
Delete partial files after 24 hours
This commit is contained in:
@@ -92,6 +92,7 @@ dependencies {
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
implementation "androidx.work:work-runtime-ktx:2.9.1"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
|
||||
@@ -63,6 +63,7 @@ class DownloadItemManager(
|
||||
|
||||
init {
|
||||
DeviceManager.dbManager.clearLegacyDownloadQueueOnce()
|
||||
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -83,6 +84,7 @@ class DownloadItemManager(
|
||||
}
|
||||
item.downloadItemParts.forEach { part ->
|
||||
if (part.moved) return@forEach
|
||||
if (item.terminalFailureAt != null && part.failed) return@forEach
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
part.failed = false
|
||||
@@ -91,6 +93,7 @@ class DownloadItemManager(
|
||||
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||
}
|
||||
downloadItemQueue.add(item)
|
||||
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
|
||||
clientEventEmitter.onDownloadItem(item)
|
||||
}
|
||||
checkUpdateDownloadQueue()
|
||||
@@ -110,6 +113,8 @@ class DownloadItemManager(
|
||||
@Synchronized
|
||||
fun retryAll() {
|
||||
downloadItemQueue.forEach { item ->
|
||||
item.terminalFailureAt = null
|
||||
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
@@ -243,7 +248,9 @@ class DownloadItemManager(
|
||||
part.failed = true
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||
persist(item, force = true)
|
||||
IncompleteDownloadCleanup.schedule(context, item)
|
||||
notifyQueueChanged()
|
||||
return
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Removes only terminally failed download items after their retention window has elapsed. */
|
||||
object IncompleteDownloadCleanup {
|
||||
private const val tag = "IncompleteDownloadCleanup"
|
||||
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
|
||||
private const val WORK_PREFIX = "incomplete-download-"
|
||||
|
||||
fun schedule(context: Context, item: DownloadItem) {
|
||||
val failedAt = item.terminalFailureAt ?: return
|
||||
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
|
||||
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, itemId: String) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
|
||||
}
|
||||
|
||||
/** Called on app/service startup as a catch-up for work delayed by Android or force-stop. */
|
||||
fun cleanupExpired(context: Context): Set<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
return DeviceManager.dbManager.getDownloadItems()
|
||||
.filter { item -> isEligible(item, now) }
|
||||
.map { item ->
|
||||
deleteItem(context, item)
|
||||
item.id
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun isEligible(item: DownloadItem, now: Long): Boolean {
|
||||
val failedAt = item.terminalFailureAt ?: return false
|
||||
if (now - failedAt < RETENTION_MS) return false
|
||||
// Do not remove an item while another part is still downloading, waiting, or finalizing.
|
||||
return item.downloadItemParts.all { part ->
|
||||
part.moved || (part.failed && !part.isMoving)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteItem(context: Context, item: DownloadItem) {
|
||||
item.downloadItemParts.forEach { part ->
|
||||
deleteAppOwnedFile(context, File(part.destinationPath))
|
||||
if (part.isInternalStorage && part.moved) {
|
||||
deleteAppOwnedFile(context, File(part.finalDestinationPath))
|
||||
} else if (!part.isInternalStorage && part.moved) {
|
||||
// Never infer an arbitrary SAF path during cleanup. The stored document URI is authoritative.
|
||||
part.completedDestinationUri?.let { uriString ->
|
||||
try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Could not delete expired SAF document for ${part.filename}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
cancel(context, item.id)
|
||||
Log.i(tag, "Deleted terminally failed download item ${item.id}")
|
||||
}
|
||||
|
||||
private fun deleteAppOwnedFile(context: Context, file: File) {
|
||||
val path = file.absolutePath
|
||||
val internal = context.filesDir.absolutePath
|
||||
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||
if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path")
|
||||
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||
} else {
|
||||
Log.w(tag, "Refusing to delete non-app-owned path $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
override fun doWork(): Result {
|
||||
DbManager.initialize(applicationContext)
|
||||
IncompleteDownloadCleanup.cleanupExpired(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -6,29 +6,32 @@ import com.audiobookshelf.app.data.MediaType
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val libraryItemId:String,
|
||||
val episodeId:String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId:String,
|
||||
val serverAddress:String,
|
||||
val serverUserId:String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath:String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
val id: String,
|
||||
val libraryItemId: String,
|
||||
val episodeId: String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId: String,
|
||||
val serverAddress: String,
|
||||
val serverUserId: String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath: String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||
@JsonIgnore var terminalFailureAt: Long? = null
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||
val isInternalStorage
|
||||
get() = localFolder.id.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
|
||||
val isDownloadFinished
|
||||
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
||||
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||
val itemParts = mutableListOf<DownloadItemPart>()
|
||||
if (limit == 0) return itemParts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user