Remove retry stubs

This commit is contained in:
Nicholas Wallace
2026-07-19 17:54:46 -07:00
parent 8142d626c0
commit 034a134319
3 changed files with 90 additions and 78 deletions
@@ -38,7 +38,8 @@ class DownloadItemManager(
private val lastPersistTime = mutableMapOf<String, Long>() private val lastPersistTime = mutableMapOf<String, Long>()
private var watcherRunning = false private var watcherRunning = false
private val jacksonMapper = private val jacksonMapper =
jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) jacksonObjectMapper()
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
private set private set
@@ -114,13 +115,6 @@ class DownloadItemManager(
notifyQueueChanged() notifyQueueChanged()
} }
@Synchronized
fun retryAll() {
downloadItemQueue.forEach(::retryDownloadItem)
checkUpdateDownloadQueue()
notifyQueueChanged()
}
private fun retryDownloadItem(item: DownloadItem) { private fun retryDownloadItem(item: DownloadItem) {
item.terminalFailureAt = null item.terminalFailureAt = null
IncompleteDownloadCleanup.cancel(context, item.id) IncompleteDownloadCleanup.cancel(context, item.id)
@@ -182,34 +176,41 @@ class DownloadItemManager(
part.lastUpdateTime = System.currentTimeMillis() part.lastUpdateTime = System.currentTimeMillis()
currentDownloadItemParts.add(part) currentDownloadItemParts.add(part)
persist(item, force = true) persist(item, force = true)
val activeConfig = DeviceManager.serverConnectionConfig
val token = val token =
DeviceManager.deviceData.serverConnectionConfigs if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
.find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token else
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
?: DeviceManager.token
activeCalls[part.id] = activeCalls[part.id] =
InternalDownloadManager(stagingFile, part.fileSize, object : InternalProgressCallback { InternalDownloadManager(
override fun onProgress(totalBytesWritten: Long, progress: Long) { stagingFile,
synchronized(this@DownloadItemManager) { part.fileSize,
if (part !in currentDownloadItemParts) return object : InternalProgressCallback {
part.bytesDownloaded = totalBytesWritten override fun onProgress(totalBytesWritten: Long, progress: Long) {
part.progress = progress synchronized(this@DownloadItemManager) {
part.lastUpdateTime = System.currentTimeMillis() if (part !in currentDownloadItemParts) return
persist(item) part.bytesDownloaded = totalBytesWritten
} part.progress = progress
} part.lastUpdateTime = System.currentTimeMillis()
persist(item)
}
}
override fun onComplete(failed: Boolean) { override fun onComplete(failed: Boolean) {
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
if (part !in currentDownloadItemParts) return if (part !in currentDownloadItemParts) return
part.failed = failed part.failed = failed
part.completed = !failed part.completed = !failed
part.lastUpdateTime = System.currentTimeMillis() part.lastUpdateTime = System.currentTimeMillis()
activeCalls.remove(part.id) activeCalls.remove(part.id)
persist(item, force = true) persist(item, force = true)
} }
} }
}, { hasAvailableSpace(part) }).download( },
serverUrl(item, part), { hasAvailableSpace(part) }
token) )
.download(serverUrl(item, part), token)
} }
@Synchronized @Synchronized
@@ -218,7 +219,8 @@ class DownloadItemManager(
watcherRunning = true watcherRunning = true
scope.launch { scope.launch {
while (true) { while (true) {
val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } val activeParts =
synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
activeParts.forEach(::handlePartUpdate) activeParts.forEach(::handlePartUpdate)
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
checkUpdateDownloadQueue() checkUpdateDownloadQueue()
@@ -235,10 +237,12 @@ class DownloadItemManager(
private fun handlePartUpdate(part: DownloadItemPart) { private fun handlePartUpdate(part: DownloadItemPart) {
clientEventEmitter.onDownloadItemPartUpdate(part) clientEventEmitter.onDownloadItemPartUpdate(part)
val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } ?: run { val item =
removeActivePart(part) synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
return ?: run {
} removeActivePart(part)
return
}
if (!part.completed && !part.failed) { if (!part.completed && !part.failed) {
val lastUpdate = part.lastUpdateTime ?: return val lastUpdate = part.lastUpdateTime ?: return
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
@@ -287,7 +291,8 @@ class DownloadItemManager(
val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup") val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
try { try {
if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup") if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup")
if (finalFile.exists() && !finalFile.renameTo(backup)) throw IllegalStateException("Could not protect existing file") if (finalFile.exists() && !finalFile.renameTo(backup))
throw IllegalStateException("Could not protect existing file")
if (!stagingFile.renameTo(finalFile)) { if (!stagingFile.renameTo(finalFile)) {
if (backup.exists()) backup.renameTo(finalFile) if (backup.exists()) backup.renameTo(finalFile)
throw IllegalStateException("Could not finalize internal staging file") throw IllegalStateException("Could not finalize internal staging file")
@@ -303,34 +308,42 @@ class DownloadItemManager(
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) { private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
if (part.moved || part.isMoving) return if (part.moved || part.isMoving) return
val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) val root =
?: return failFinalization(item, part, "Could not resolve SAF destination") DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
?: return failFinalization(item, part, "Could not resolve SAF destination")
part.isMoving = true part.isMoving = true
persist(item, force = true) persist(item, force = true)
scope.launch { scope.launch {
try { try {
if (!hasAvailableSpace(part)) throw IllegalStateException("Insufficient storage for SAF copy") if (!hasAvailableSpace(part))
throw IllegalStateException("Insufficient storage for SAF copy")
val folderKey = "${root.uri}/${part.finalDestinationSubfolder}" val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() } val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
val folder = synchronized(folderLock) { val folder =
getOrCreateFolder(root, part.finalDestinationSubfolder) synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) }
} ?: throw IllegalStateException("Could not create SAF destination folder") ?: throw IllegalStateException("Could not create SAF destination folder")
val temporaryName = ".${part.filename}.${part.id.hashCode()}.part" val temporaryName = ".${part.filename}.${part.id.hashCode()}.part"
folder.findFile(temporaryName)?.delete() folder.findFile(temporaryName)?.delete()
val temporary = folder.createFile(mimeTypeFor(part), temporaryName) val temporary =
?: throw IllegalStateException("Could not create SAF temporary file") folder.createFile(mimeTypeFor(part), temporaryName)
?: throw IllegalStateException("Could not create SAF temporary file")
val staging = File(part.destinationPath) val staging = File(part.destinationPath)
FileInputStream(staging).use { input -> FileInputStream(staging).use { input ->
context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) } context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) }
?: throw IllegalStateException("Could not open SAF output stream") ?: throw IllegalStateException("Could not open SAF output stream")
} }
if (temporary.length() != staging.length()) throw IllegalStateException("SAF copy size mismatch") if (temporary.length() != staging.length())
throw IllegalStateException("SAF copy size mismatch")
val existing = folder.findFile(part.filename) val existing = folder.findFile(part.filename)
if (existing != null && !existing.delete()) throw IllegalStateException("Could not replace existing file") if (existing != null && !existing.delete())
if (!temporary.renameTo(part.filename)) throw IllegalStateException("Could not finalize SAF temporary file") throw IllegalStateException("Could not replace existing file")
val destination = folder.findFile(part.filename) if (!temporary.renameTo(part.filename))
?: throw IllegalStateException("Could not reopen finalized SAF file") throw IllegalStateException("Could not finalize SAF temporary file")
if (destination.length() != staging.length()) throw IllegalStateException("SAF final size mismatch") val destination =
folder.findFile(part.filename)
?: throw IllegalStateException("Could not reopen finalized SAF file")
if (destination.length() != staging.length())
throw IllegalStateException("SAF final size mismatch")
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}") if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
part.completedDestinationUri = destination.uri.toString() part.completedDestinationUri = destination.uri.toString()
completePart(item, part) completePart(item, part)
@@ -364,12 +377,17 @@ class DownloadItemManager(
if (!item.isDownloadFinished) return if (!item.isDownloadFinished) return
scope.launch { scope.launch {
folderScanner.scanDownloadItem(item) { scanResult -> folderScanner.scanDownloadItem(item) { scanResult ->
val event = JSObject().apply { val event =
put("libraryItemId", item.id) JSObject().apply {
put("localFolderId", item.localFolder.id) put("libraryItemId", item.id)
scanResult?.localLibraryItem?.let { put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) } put("localFolderId", item.localFolder.id)
scanResult?.localMediaProgress?.let { put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) } scanResult?.localLibraryItem?.let {
} put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
}
scanResult?.localMediaProgress?.let {
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
}
}
clientEventEmitter.onDownloadItemComplete(event) clientEventEmitter.onDownloadItemComplete(event)
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
downloadItemQueue.remove(item) downloadItemQueue.remove(item)
@@ -385,7 +403,8 @@ class DownloadItemManager(
val staging = File(part.destinationPath) val staging = File(part.destinationPath)
staging.parentFile?.mkdirs() staging.parentFile?.mkdirs()
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
val remaining = (expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L) val remaining =
(expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
val required = if (part.isInternalStorage) remaining else remaining + expectedSize val required = if (part.isInternalStorage) remaining else remaining + expectedSize
val key = storageKey(staging) val key = storageKey(staging)
val fs = statFsFor(staging) val fs = statFsFor(staging)
@@ -410,7 +429,8 @@ class DownloadItemManager(
} }
private fun storageKey(file: File): String = private fun storageKey(file: File): String =
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal" else "external" if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal"
else "external"
@Synchronized @Synchronized
private fun removeActivePart(part: DownloadItemPart) { private fun removeActivePart(part: DownloadItemPart) {
@@ -425,7 +445,9 @@ class DownloadItemManager(
DeviceManager.dbManager.saveDownloadItem(item) DeviceManager.dbManager.saveDownloadItem(item)
} }
private fun notifyQueueChanged() { clientEventEmitter.onQueueChanged(hasWork()) } private fun notifyQueueChanged() {
clientEventEmitter.onQueueChanged(hasWork())
}
fun destroy() { fun destroy() {
activeCalls.values.forEach(Call::cancel) activeCalls.values.forEach(Call::cancel)
@@ -443,11 +465,12 @@ class DownloadItemManager(
} }
private fun mimeTypeFor(part: DownloadItemPart): String = private fun mimeTypeFor(part: DownloadItemPart): String =
part.audioTrack?.mimeType ?: when (part.ebookFile?.ebookFormat?.lowercase()) { part.audioTrack?.mimeType
"epub" -> "application/epub+zip" ?: when (part.ebookFile?.ebookFormat?.lowercase()) {
"pdf" -> "application/pdf" "epub" -> "application/epub+zip"
else -> "image/jpeg" "pdf" -> "application/pdf"
} else -> "image/jpeg"
}
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String { private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else "" val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else ""
@@ -24,7 +24,6 @@ class DownloadService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
ACTION_RETRY -> DownloadServiceHost.retryAll(this)
else -> DownloadServiceHost.ensure(this) else -> DownloadServiceHost.ensure(this)
} }
return START_STICKY return START_STICKY
@@ -54,8 +53,6 @@ class DownloadService : Service() {
private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification { private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification {
val cancelIntent = PendingIntent.getService( val cancelIntent = PendingIntent.getService(
this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags()) this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags())
val retryIntent = PendingIntent.getService(
this, 2, Intent(this, DownloadService::class.java).setAction(ACTION_RETRY), pendingIntentFlags())
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.icon) .setSmallIcon(R.drawable.icon)
.setContentTitle("Audiobookshelf downloads") .setContentTitle("Audiobookshelf downloads")
@@ -64,7 +61,6 @@ class DownloadService : Service() {
.setOngoing(true) .setOngoing(true)
.setProgress(100, progress, !determinate) .setProgress(100, progress, !determinate)
.addAction(0, "Cancel", cancelIntent) .addAction(0, "Cancel", cancelIntent)
.addAction(0, "Retry", retryIntent)
.build() .build()
} }
@@ -79,7 +75,6 @@ class DownloadService : Service() {
private const val CHANNEL_ID = "downloads" private const val CHANNEL_ID = "downloads"
private const val NOTIFICATION_ID = 4102 private const val NOTIFICATION_ID = 4102
private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL" private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL"
private const val ACTION_RETRY = "com.audiobookshelf.app.download.RETRY"
fun intent(context: Context) = Intent(context, DownloadService::class.java) fun intent(context: Context) = Intent(context, DownloadService::class.java)
} }
} }
@@ -55,12 +55,6 @@ object DownloadServiceHost {
startService(context) startService(context)
} }
@Synchronized
fun retryAll(context: Context) {
startService(context)
ensure(context).retryAll()
}
@Synchronized @Synchronized
fun cancelAll(context: Context) { ensure(context).cancelAll() } fun cancelAll(context: Context) { ensure(context).cancelAll() }