From 108f34bfb7c55bddcf5f1d3da9ee8796be762787 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 13 Sep 2026 14:48:55 -0700 Subject: [PATCH 1/4] Debug: add forced bad download queue entry --- .../app/plugins/AbsDownloader.kt | 83 +++++++++++++++++++ pages/logs.vue | 26 +++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index cd678a18..aa809162 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -1,5 +1,6 @@ package com.audiobookshelf.app.plugins +import android.net.Uri import android.os.Environment import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* @@ -66,6 +67,88 @@ class AbsDownloader : Plugin() { call.resolve() } + /** Writes a malformed persisted queue entry for manually testing startup recovery. */ + @PluginMethod + fun createInvalidDownloadQueueEntry(call: PluginCall) { + try { + val downloadItemId = "debug-invalid-download" + val filename = "debug-invalid-download.m4b" + val localFolder = + LocalFolder( + "internal-book", + "Internal App Storage", + "", + "", + mainActivity.filesDir.absolutePath, + "internal", + "book" + ) + val itemFolder = File(mainActivity.filesDir, "downloads/$downloadItemId") + val stagingFile = + File(mainActivity.filesDir, "download-staging/$downloadItemId/$filename.part") + val finalFile = File(itemFolder, filename) + val part = + DownloadItemPart( + id = "$downloadItemId-part", + downloadItemId = downloadItemId, + filename = filename, + fileSize = 1L, + destinationPath = stagingFile.absolutePath, + finalDestinationPath = finalFile.absolutePath, + serverPath = "/debug/invalid-download", + localFolderName = localFolder.name, + localFolderUrl = localFolder.contentUrl, + localFolderId = localFolder.id, + ebookFile = null, + audioTrack = null, + episode = null, + completed = false, + moved = false, + isMoving = false, + failed = false, + uri = Uri.parse("https://invalid.local/debug/invalid-download"), + destinationUri = Uri.fromFile(stagingFile), + finalDestinationUri = Uri.fromFile(finalFile), + completedDestinationUri = null, + finalDestinationSubfolder = downloadItemId, + downloadId = null, + lastUpdateTime = null, + progress = 0L, + bytesDownloaded = 0L + ) + + DownloadItemPart::class.java.getDeclaredField("destinationPath").apply { + isAccessible = true + set(part, null) + } + + val downloadItem = + DownloadItem( + id = downloadItemId, + libraryItemId = downloadItemId, + episodeId = null, + userMediaProgress = null, + serverConnectionConfigId = "debug", + serverAddress = "https://invalid.local", + serverUserId = "debug", + mediaType = "book", + itemFolderPath = itemFolder.absolutePath, + localFolder = localFolder, + itemTitle = "Invalid Download Queue Test", + itemSubfolder = downloadItemId, + media = MediaType(MediaTypeMetadata("Invalid Download Queue Test", false), null), + downloadItemParts = mutableListOf(part) + ) + + DeviceManager.dbManager.saveDownloadItem(downloadItem) + AbsLogger.info(tag, "Created malformed download queue entry $downloadItemId") + call.resolve() + } catch (e: Exception) { + AbsLogger.error(tag, "Could not create malformed download queue entry: ${e.message}") + call.reject("Could not create malformed download queue entry", e) + } + } + /** Replays restored queue items when the frontend subscribes to download events. */ @PluginMethod(returnType = PluginMethod.RETURN_NONE) override fun addListener(call: PluginCall) { diff --git a/pages/logs.vue b/pages/logs.vue index b5db7b90..1818aac4 100644 --- a/pages/logs.vue +++ b/pages/logs.vue @@ -8,6 +8,10 @@ +
+ Create Invalid Download Queue +
+

{{ $strings.MessageNoLogs }}

@@ -29,7 +33,8 @@
- From ce6883331e7c1b045d2160442392dde2b0451241 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 13 Sep 2026 15:03:04 -0700 Subject: [PATCH 2/4] Fix: download queue null check before restoring queue --- .../audiobookshelf/app/managers/DbManager.kt | 80 +++++++++++++++++++ .../app/services/DownloadServiceHost.kt | 9 ++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt index 63c97a7c..26b3edee 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt @@ -121,6 +121,86 @@ class DbManager { return downloadItems } + /** + * Validate persisted download queue entries before restoring them. + * + * Queue entries can become incompatible or malformed, for example after an app update. If any + * entry cannot be read or fails the checks required for queue restoration, the entire persisted + * queue is cleared so startup can continue without restoring invalid data. + * + * @return true if the queue passes validation or was successfully cleared; false if an invalid + * queue could not be cleared + */ + fun ensureValidDownloadQueue(): Boolean { + val downloadBook = Paper.book("downloadItems") + val queueIsValid = + try { + downloadBook.allKeys + .map { downloadItemId -> + val item = + downloadBook.read(downloadItemId) + ?: throw IllegalStateException( + "Download queue entry $downloadItemId could not be read" + ) + if (item.id != downloadItemId) { + throw IllegalStateException( + "Download queue entry $downloadItemId has a mismatched item ID" + ) + } + item + } + .all { item -> + listOf(item.id, item.libraryItemId, item.localFolder.id).all { + !it.isNullOrBlank() + } && + item.downloadItemParts.isNotEmpty() && + item.downloadItemParts.all { part -> + listOf( + part.id, + part.downloadItemId, + part.filename, + part.destinationPath, + part.finalDestinationPath, + part.serverPath, + part.localFolderId + ) + .all { !it.isNullOrBlank() } && + part.downloadItemId == item.id + } + } + } catch (readError: Exception) { + AbsLogger.error( + tag, + "Could not read persisted download queue; clearing queue (${readError.message})" + ) + false + } + + if (queueIsValid) return true + + AbsLogger.error(tag, "Persisted download queue contains invalid data; clearing queue") + return try { + downloadBook.destroy() + val remainingKeys = downloadBook.allKeys + if (remainingKeys.isEmpty()) { + AbsLogger.info(tag, "Cleared invalid persisted download queue") + true + } else { + AbsLogger.error( + tag, + "Invalid persisted download queue still contains ${remainingKeys.size} entries after clearing" + ) + false + } + } catch (clearError: Exception) { + AbsLogger.error( + tag, + "Could not clear invalid persisted download queue (${clearError.message})" + ) + false + } + } + fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) { Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt index 1bf1f8c3..ff49cd4d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -2,6 +2,7 @@ package com.audiobookshelf.app.services import android.content.Context import androidx.core.content.ContextCompat +import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DownloadItemManager @@ -43,8 +44,12 @@ object DownloadServiceHost { DbManager.initialize(appContext) manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter) restoreJob = scope.launch { - IncompleteDownloadCleanup.cleanupExpired(appContext) - manager!!.restoreQueue() + if (DeviceManager.dbManager.ensureValidDownloadQueue()) { + IncompleteDownloadCleanup.cleanupExpired(appContext) + manager!!.restoreQueue() + } else { + AbsLogger.error(TAG, "Skipping invalid download queue because it could not be cleared") + } onRestoreComplete(appContext) } } From 989cf66715cd28e6a3171855a0a58e746efc935f Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 13 Sep 2026 15:03:15 -0700 Subject: [PATCH 3/4] Revert "Debug: add forced bad download queue entry" This reverts commit 108f34bfb7c55bddcf5f1d3da9ee8796be762787. --- .../app/plugins/AbsDownloader.kt | 83 ------------------- pages/logs.vue | 26 +----- 2 files changed, 2 insertions(+), 107 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index aa809162..cd678a18 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -1,6 +1,5 @@ package com.audiobookshelf.app.plugins -import android.net.Uri import android.os.Environment import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* @@ -67,88 +66,6 @@ class AbsDownloader : Plugin() { call.resolve() } - /** Writes a malformed persisted queue entry for manually testing startup recovery. */ - @PluginMethod - fun createInvalidDownloadQueueEntry(call: PluginCall) { - try { - val downloadItemId = "debug-invalid-download" - val filename = "debug-invalid-download.m4b" - val localFolder = - LocalFolder( - "internal-book", - "Internal App Storage", - "", - "", - mainActivity.filesDir.absolutePath, - "internal", - "book" - ) - val itemFolder = File(mainActivity.filesDir, "downloads/$downloadItemId") - val stagingFile = - File(mainActivity.filesDir, "download-staging/$downloadItemId/$filename.part") - val finalFile = File(itemFolder, filename) - val part = - DownloadItemPart( - id = "$downloadItemId-part", - downloadItemId = downloadItemId, - filename = filename, - fileSize = 1L, - destinationPath = stagingFile.absolutePath, - finalDestinationPath = finalFile.absolutePath, - serverPath = "/debug/invalid-download", - localFolderName = localFolder.name, - localFolderUrl = localFolder.contentUrl, - localFolderId = localFolder.id, - ebookFile = null, - audioTrack = null, - episode = null, - completed = false, - moved = false, - isMoving = false, - failed = false, - uri = Uri.parse("https://invalid.local/debug/invalid-download"), - destinationUri = Uri.fromFile(stagingFile), - finalDestinationUri = Uri.fromFile(finalFile), - completedDestinationUri = null, - finalDestinationSubfolder = downloadItemId, - downloadId = null, - lastUpdateTime = null, - progress = 0L, - bytesDownloaded = 0L - ) - - DownloadItemPart::class.java.getDeclaredField("destinationPath").apply { - isAccessible = true - set(part, null) - } - - val downloadItem = - DownloadItem( - id = downloadItemId, - libraryItemId = downloadItemId, - episodeId = null, - userMediaProgress = null, - serverConnectionConfigId = "debug", - serverAddress = "https://invalid.local", - serverUserId = "debug", - mediaType = "book", - itemFolderPath = itemFolder.absolutePath, - localFolder = localFolder, - itemTitle = "Invalid Download Queue Test", - itemSubfolder = downloadItemId, - media = MediaType(MediaTypeMetadata("Invalid Download Queue Test", false), null), - downloadItemParts = mutableListOf(part) - ) - - DeviceManager.dbManager.saveDownloadItem(downloadItem) - AbsLogger.info(tag, "Created malformed download queue entry $downloadItemId") - call.resolve() - } catch (e: Exception) { - AbsLogger.error(tag, "Could not create malformed download queue entry: ${e.message}") - call.reject("Could not create malformed download queue entry", e) - } - } - /** Replays restored queue items when the frontend subscribes to download events. */ @PluginMethod(returnType = PluginMethod.RETURN_NONE) override fun addListener(call: PluginCall) { diff --git a/pages/logs.vue b/pages/logs.vue index 1818aac4..b5db7b90 100644 --- a/pages/logs.vue +++ b/pages/logs.vue @@ -8,10 +8,6 @@
-
- Create Invalid Download Queue -
-

{{ $strings.MessageNoLogs }}

@@ -33,8 +29,7 @@
+ From ed356a8d99ad11e035877cd398af88d2a6aee780 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 27 Sep 2026 17:03:25 -0500 Subject: [PATCH 4/4] Add try/catch around android cleanupExpired downloads call --- .../app/services/DownloadServiceHost.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt index ff49cd4d..aaa1eb7a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -44,13 +44,20 @@ object DownloadServiceHost { DbManager.initialize(appContext) manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter) restoreJob = scope.launch { - if (DeviceManager.dbManager.ensureValidDownloadQueue()) { - IncompleteDownloadCleanup.cleanupExpired(appContext) - manager!!.restoreQueue() - } else { - AbsLogger.error(TAG, "Skipping invalid download queue because it could not be cleared") + try { + if (DeviceManager.dbManager.ensureValidDownloadQueue()) { + IncompleteDownloadCleanup.cleanupExpired(appContext) + manager!!.restoreQueue() + } else { + AbsLogger.error(TAG, "Skipping invalid download queue because it could not be cleared") + } + onRestoreComplete(appContext) + } catch (restoreError: Exception) { + AbsLogger.error( + TAG, + "Could not restore download queue (${restoreError.javaClass.simpleName}: ${restoreError.message})" + ) } - onRestoreComplete(appContext) } } return manager!!