mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-28 06:14:00 +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 project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
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-core:$kotlin_coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class DownloadItemManager(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
DeviceManager.dbManager.clearLegacyDownloadQueueOnce()
|
DeviceManager.dbManager.clearLegacyDownloadQueueOnce()
|
||||||
|
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -83,6 +84,7 @@ class DownloadItemManager(
|
|||||||
}
|
}
|
||||||
item.downloadItemParts.forEach { part ->
|
item.downloadItemParts.forEach { part ->
|
||||||
if (part.moved) return@forEach
|
if (part.moved) return@forEach
|
||||||
|
if (item.terminalFailureAt != null && part.failed) return@forEach
|
||||||
part.downloadId = null
|
part.downloadId = null
|
||||||
part.isMoving = false
|
part.isMoving = false
|
||||||
part.failed = false
|
part.failed = false
|
||||||
@@ -91,6 +93,7 @@ class DownloadItemManager(
|
|||||||
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||||
}
|
}
|
||||||
downloadItemQueue.add(item)
|
downloadItemQueue.add(item)
|
||||||
|
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
|
||||||
clientEventEmitter.onDownloadItem(item)
|
clientEventEmitter.onDownloadItem(item)
|
||||||
}
|
}
|
||||||
checkUpdateDownloadQueue()
|
checkUpdateDownloadQueue()
|
||||||
@@ -110,6 +113,8 @@ class DownloadItemManager(
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun retryAll() {
|
fun retryAll() {
|
||||||
downloadItemQueue.forEach { item ->
|
downloadItemQueue.forEach { item ->
|
||||||
|
item.terminalFailureAt = null
|
||||||
|
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||||
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||||
part.failed = false
|
part.failed = false
|
||||||
part.completed = false
|
part.completed = false
|
||||||
@@ -243,7 +248,9 @@ class DownloadItemManager(
|
|||||||
part.failed = true
|
part.failed = true
|
||||||
part.completed = false
|
part.completed = false
|
||||||
part.downloadId = null
|
part.downloadId = null
|
||||||
|
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||||
persist(item, force = true)
|
persist(item, force = true)
|
||||||
|
IncompleteDownloadCleanup.schedule(context, item)
|
||||||
notifyQueueChanged()
|
notifyQueueChanged()
|
||||||
return
|
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
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
|
||||||
data class DownloadItem(
|
data class DownloadItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val libraryItemId:String,
|
val libraryItemId: String,
|
||||||
val episodeId:String?,
|
val episodeId: String?,
|
||||||
val userMediaProgress: MediaProgress?,
|
val userMediaProgress: MediaProgress?,
|
||||||
val serverConnectionConfigId:String,
|
val serverConnectionConfigId: String,
|
||||||
val serverAddress:String,
|
val serverAddress: String,
|
||||||
val serverUserId:String,
|
val serverUserId: String,
|
||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
val itemFolderPath:String,
|
val itemFolderPath: String,
|
||||||
val localFolder: LocalFolder,
|
val localFolder: LocalFolder,
|
||||||
val itemTitle: String,
|
val itemTitle: String,
|
||||||
val itemSubfolder: String,
|
val itemSubfolder: String,
|
||||||
val media: MediaType,
|
val media: MediaType,
|
||||||
val downloadItemParts: MutableList<DownloadItemPart>
|
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||||
|
@JsonIgnore var terminalFailureAt: Long? = null
|
||||||
) {
|
) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
val isInternalStorage
|
||||||
|
get() = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
@get:JsonIgnore
|
@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
|
@JsonIgnore
|
||||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||||
val itemParts = mutableListOf<DownloadItemPart>()
|
val itemParts = mutableListOf<DownloadItemPart>()
|
||||||
if (limit == 0) return itemParts
|
if (limit == 0) return itemParts
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user