From eb2483d039cee4f14a69e0513c464f4f19849d7a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 18 Jul 2026 17:32:37 -0700 Subject: [PATCH 01/17] Initial rewrite of 1587 by codex --- .../audiobookshelf/app/data/DeviceClasses.kt | 16 +- .../app/data/FolderScanResult.kt | 15 - .../app/data/LocalLibraryItem.kt | 12 +- .../app/device/FolderScanner.kt | 759 ++++++------------ .../audiobookshelf/app/managers/DbManager.kt | 30 +- .../app/managers/DownloadItemManager.kt | 521 +++++------- .../app/managers/InternalDownloadManager.kt | 189 +++-- .../audiobookshelf/app/models/DownloadItem.kt | 2 +- .../app/models/DownloadItemPart.kt | 18 +- .../app/plugins/AbsAudioPlayer.kt | 2 +- .../audiobookshelf/app/plugins/AbsDatabase.kt | 2 +- .../app/plugins/AbsDownloader.kt | 50 +- 12 files changed, 622 insertions(+), 994 deletions(-) delete mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 76b8f4b4..9c242b42 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -1,12 +1,14 @@ package com.audiobookshelf.app.data import android.content.Context +import android.net.Uri import android.support.v4.media.MediaDescriptionCompat import android.util.Log import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo +import java.io.File enum class LockOrientationSetting { NONE, PORTRAIT, LANDSCAPE @@ -57,6 +59,19 @@ data class LocalFile( var mimeType:String?, var size:Long ) { + @JsonIgnore + fun exists(ctx: Context): Boolean { + if (contentUrl.startsWith("content:")) { + return try { + ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false + } catch (e: Exception) { + Log.w("LocalFile", "Cannot access SAF file $contentUrl", e) + false + } + } + return File(absolutePath).exists() + } + @JsonIgnore fun isAudioFile():Boolean { if (mimeType == "application/octet-stream") return true @@ -218,4 +233,3 @@ data class DeviceData( } } } - diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt deleted file mode 100644 index f4f706bf..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.audiobookshelf.app.data - -data class FolderScanResult( - var itemsAdded:Int, - var itemsUpdated:Int, - var itemsRemoved:Int, - var itemsUpToDate:Int, - val localFolder:LocalFolder, - val localLibraryItems:List, -) - -data class LocalLibraryItemScanResult( - val updated:Boolean, - val localLibraryItem:LocalLibraryItem, -) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index 635c98f2..9251e7d9 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -80,7 +80,7 @@ class LocalLibraryItem( } @JsonIgnore - fun hasTracks(episode:PodcastEpisode?): Boolean { + fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean { var audioTracks = media.getAudioTracks() as MutableList if (episode != null) { // Get podcast episode audio track episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } @@ -91,15 +91,19 @@ class LocalLibraryItem( if (it.metadata === null) { return false } - // Check that file exists - val file = File(it.metadata!!.path) - if (!file.exists()) { + if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) { return false } } return true } + /** App-private files have paths; SAF files must be validated through their persisted URI. */ + @JsonIgnore + private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean { + return LocalFile("", null, contentUrl ?: "", "", path, "", null, 0).exists(ctx) + } + @JsonIgnore fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession { val localEpisodeId = episode?.id diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt index 951b6a3b..c53b9b8b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -7,570 +7,299 @@ import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.* import com.audiobookshelf.app.data.* import com.audiobookshelf.app.models.DownloadItem -import com.fasterxml.jackson.core.json.JsonReadFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.audiobookshelf.app.models.DownloadItemPart import java.io.File -class FolderScanner(var ctx: Context) { +/** Creates local-library records from the completed download manifest, not a recursive rescan. */ +class FolderScanner(private val ctx: Context) { private val tag = "FolderScanner" - private var jacksonMapper = - jacksonObjectMapper() - .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) data class DownloadItemScanResult( val localLibraryItem: LocalLibraryItem, var localMediaProgress: LocalMediaProgress? ) - private fun getLocalLibraryItemId(mediaItemId: String): String { - return "local_" + DeviceManager.getBase64Id(mediaItemId) - } + private fun localLibraryItemId(mediaItemId: String) = "local_${DeviceManager.getBase64Id(mediaItemId)}" - private fun scanInternalDownloadItem( - downloadItem: DownloadItem, - cb: (DownloadItemScanResult?) -> Unit - ) { - val localLibraryItemId = "local_${downloadItem.libraryItemId}" - - var localEpisodeId: String? = null - var localLibraryItem: LocalLibraryItem? - if (downloadItem.mediaType == "book") { - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - downloadItem.itemFolderPath, - downloadItem.itemFolderPath, - "", - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } else { - // Lookup or create podcast local library item - localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) - if (localLibraryItem == null) { - Log.d( - tag, - "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}" - ) - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - downloadItem.itemFolderPath, - downloadItem.itemFolderPath, - "", - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } + private fun createLocalFile(part: DownloadItemPart, externalFile: DocumentFile? = null): LocalFile? { + if (part.isInternalStorage) { + val file = File(part.finalDestinationPath) + if (!file.exists()) return null + return LocalFile( + DeviceManager.getBase64Id(file.name), + file.name, + Uri.fromFile(file).toString(), + file.getBasePath(ctx), + file.absolutePath, + file.getSimplePath(ctx), + file.mimeType, + file.length() + ) } - val audioTracks: MutableList = mutableListOf() - var foundEBookFile = false - - downloadItem.downloadItemParts.forEach { downloadItemPart -> - Log.d( - tag, - "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}" + part.completedDestinationUri?.let { contentUrl -> + val uri = Uri.parse(contentUrl) + val size = + try { + ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.statSize.coerceAtLeast(0L) + } ?: 0L + } catch (e: Exception) { + Log.e(tag, "Could not open completed SAF file: $contentUrl", e) + return null + } + // Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even + // though the URI remains readable. Keep the URI as the authoritative local-file location. + return LocalFile( + DeviceManager.getBase64Id(contentUrl), + part.filename, + contentUrl, + part.localFolderName, + part.finalDestinationPath, + part.finalDestinationPath, + mimeTypeFor(part), + size ) + } - val file = File(downloadItemPart.finalDestinationPath) - Log.d(tag, "Scan internal storage item created file ${file.name}") + // Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a + // file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted + // SAF tree grant instead. + val document = externalFile + if (document == null || !document.exists()) { + Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}") + return null + } + return LocalFile( + DeviceManager.getBase64Id(document.id), + document.name, + document.uri.toString(), + document.getBasePath(ctx), + document.getAbsolutePath(ctx), + document.getSimplePath(ctx), + document.mimeType, + document.length() + ) + } - if (file == null) { - Log.e( - tag, - "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}" - ) - } else { - if (downloadItemPart.audioTrack != null) { - val audioTrackFromServer = downloadItemPart.audioTrack - Log.d( - tag, - "scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}" + private fun newLocalLibraryItem( + id: String, + downloadItem: DownloadItem, + basePath: String, + absolutePath: String, + contentUrl: String + ) = + LocalLibraryItem( + id, + downloadItem.localFolder.id, + basePath, + absolutePath, + contentUrl, + false, + downloadItem.mediaType, + downloadItem.media.getLocalCopy(), + mutableListOf(), + null, + null, + true, + downloadItem.serverConnectionConfigId, + downloadItem.serverAddress, + downloadItem.serverUserId, + downloadItem.libraryItemId ) - val localFileId = DeviceManager.getBase64Id(file.name) - Log.d(tag, "Scan internal file localFileId=$localFileId") - val localFile = - LocalFile( - localFileId, - file.name, - downloadItemPart.finalDestinationUri.toString(), - file.getBasePath(ctx), - file.absolutePath, - file.getSimplePath(ctx), - file.mimeType, - file.length() - ) - localLibraryItem.localFiles.add(localFile) + private fun scanParts( + item: DownloadItem, + localItem: LocalLibraryItem, + externalFolder: DocumentFile? = null, + callback: (DownloadItemScanResult?) -> Unit + ) { + val tracks = mutableListOf() + var foundEbook = false + var localEpisodeId: String? = null - val trackFileMetadata = + item.downloadItemParts.forEach { part -> + val externalFile = + if (part.isInternalStorage) { + null + } else { + part.completedDestinationUri + ?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) } + ?: resolveExternalFile(externalFolder, part) + } + Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}") + val localFile = createLocalFile(part, externalFile) ?: return@forEach + when { + part.audioTrack != null -> { + val serverTrack = part.audioTrack + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) + val metadata = FileMetadata( - file.name, - file.extension, - file.absolutePath, - file.getBasePath(ctx), - file.length() + localFile.filename ?: "", + File(localFile.filename ?: "").extension, + localFile.absolutePath, + localFile.basePath, + localFile.size ) - // Create new audio track val track = AudioTrack( - audioTrackFromServer.index, - audioTrackFromServer.startOffset, - audioTrackFromServer.duration, + serverTrack.index, + serverTrack.startOffset, + serverTrack.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", - trackFileMetadata, + metadata, true, - localFileId, - audioTrackFromServer.index + localFile.id, + serverTrack.index ) - audioTracks.add(track) - - Log.d( - tag, - "scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}" - ) - - // Add podcast episodes to library - downloadItemPart.episode?.let { podcastEpisode -> - val podcast = localLibraryItem.media as Podcast - val newEpisode = podcast.addEpisode(track, podcastEpisode) - localEpisodeId = newEpisode.id - Log.d( - tag, - "scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}" - ) + tracks.add(track) + Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})") + part.episode?.let { episode -> + val podcast = localItem.media as Podcast + localEpisodeId = podcast.addEpisode(track, episode).id } - } else if (downloadItemPart.ebookFile != null) { - foundEBookFile = true - Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}") - val localFileId = DeviceManager.getBase64Id(file.name) - val localFile = - LocalFile( - localFileId, - file.name, - Uri.fromFile(file).toString(), - file.getBasePath(ctx), - file.absolutePath, - file.getSimplePath(ctx), - file.mimeType, - file.length() - ) - localLibraryItem.localFiles.add(localFile) - - val ebookFile = + } + part.ebookFile != null -> { + foundEbook = true + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) + (localItem.media as Book).ebookFile = EBookFile( - downloadItemPart.ebookFile.ino, - downloadItemPart.ebookFile.metadata, - downloadItemPart.ebookFile.ebookFormat, + part.ebookFile.ino, + part.ebookFile.metadata, + part.ebookFile.ebookFormat, true, - localFileId, + localFile.id, localFile.contentUrl ) - (localLibraryItem.media as Book).ebookFile = ebookFile - Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}") - } else { - val localFileId = DeviceManager.getBase64Id(file.name) - val localFile = - LocalFile( - localFileId, - file.name, - Uri.fromFile(file).toString(), - file.getBasePath(ctx), - file.absolutePath, - file.getSimplePath(ctx), - file.mimeType, - file.length() - ) - - localLibraryItem.coverAbsolutePath = localFile.absolutePath - localLibraryItem.coverContentUrl = localFile.contentUrl - localLibraryItem.localFiles.add(localFile) + } + else -> { + localItem.coverAbsolutePath = localFile.absolutePath + localItem.coverContentUrl = localFile.contentUrl + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) } } } - if (audioTracks.isEmpty() && !foundEBookFile) { - Log.d( - tag, - "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}" - ) - return cb(null) + if (tracks.isEmpty() && !foundEbook) { + callback(null) + return } - - // For books sort audio tracks then set - if (downloadItem.mediaType == "book") { - audioTracks.sortBy { it.index } - - var indexCheck = 1 - var startOffset = 0.0 - audioTracks.forEach { audioTrack -> - if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) { - audioTrack.index = indexCheck - audioTrack.startOffset = startOffset - } - indexCheck++ - startOffset += audioTrack.duration + if (item.mediaType == "book") { + tracks.sortBy { it.index } + var expectedIndex = 1 + var offset = 0.0 + tracks.forEach { track -> + track.index = expectedIndex++ + track.startOffset = offset + offset += track.duration } - - localLibraryItem.media.setAudioTracks(audioTracks) + localItem.media.setAudioTracks(tracks) } - val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null) - - // If library item had media progress then make local media progress and save - downloadItem.userMediaProgress?.let { mediaProgress -> - val localMediaProgressId = - if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId - else "$localLibraryItemId-$localEpisodeId" - val newLocalMediaProgress = + val result = DownloadItemScanResult(localItem, null) + item.userMediaProgress?.let { progress -> + val progressId = if (item.episodeId.isNullOrEmpty()) localItem.id else "${localItem.id}-$localEpisodeId" + result.localMediaProgress = LocalMediaProgress( - id = localMediaProgressId, - localLibraryItemId = localLibraryItemId, - localEpisodeId = localEpisodeId, - duration = mediaProgress.duration, - progress = mediaProgress.progress, - currentTime = mediaProgress.currentTime, - isFinished = mediaProgress.isFinished, - ebookLocation = mediaProgress.ebookLocation, - ebookProgress = mediaProgress.ebookProgress, - lastUpdate = mediaProgress.lastUpdate, - startedAt = mediaProgress.startedAt, - finishedAt = mediaProgress.finishedAt, - serverConnectionConfigId = downloadItem.serverConnectionConfigId, - serverAddress = downloadItem.serverAddress, - serverUserId = downloadItem.serverUserId, - libraryItemId = downloadItem.libraryItemId, - episodeId = downloadItem.episodeId + progressId, + localItem.id, + localEpisodeId, + progress.duration, + progress.progress, + progress.currentTime, + progress.isFinished, + progress.ebookLocation, + progress.ebookProgress, + progress.lastUpdate, + progress.startedAt, + progress.finishedAt, + item.serverConnectionConfigId, + item.serverAddress, + item.serverUserId, + item.libraryItemId, + item.episodeId ) - Log.d( - tag, - "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}" - ) - DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress) - - downloadItemScanResult.localMediaProgress = newLocalMediaProgress + DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!) } - - DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) - - cb(downloadItemScanResult) + DeviceManager.dbManager.saveLocalLibraryItem(localItem) + callback(result) } - // Scan item after download and create local library item - fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { - // If downloading to internal storage handle separately - if (downloadItem.isInternalStorage) { - scanInternalDownloadItem(downloadItem, cb) + private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? { + if (subPath.isBlank()) return root + var current = root + subPath.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + current = current.findFile(segment) ?: return null + } + return current + } + + /** + * DownloadsProvider on Android 10 may expose an audio document without its extension through + * DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base + * filename. MIME type and server-reported size are unreliable for Opus on this platform. + */ + private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? { + if (folder == null) return null + folder.findFile(part.filename)?.let { return it } + val expectedBaseName = part.filename.substringBeforeLast('.') + return folder.listFiles().firstOrNull { document -> + document.name == part.filename || + document.fullName == part.filename || + (part.audioTrack != null && document.isFile && + (document.name ?: "").substringBeforeLast('.') == expectedBaseName) || + (part.audioTrack != null && document.isFile && + document.fullName.substringBeforeLast('.') == expectedBaseName) + } + } + + private fun mimeTypeFor(part: DownloadItemPart): String? { + return part.audioTrack?.mimeType + ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" + } + } + + fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) { + if (item.isInternalStorage) { + val id = "local_${item.libraryItemId}" + val localItem = + DeviceManager.dbManager.getLocalLibraryItem(id) + ?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "") + scanParts(item, localItem, callback = callback) return } - val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) - val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf() - - var itemFolderId = "" - var itemFolderUrl = "" - var itemFolderBasePath = "" - var itemFolderAbsolutePath = "" - foldersFound.forEach { - // e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game" - // and itemSubfolder is "Orson Scott Card/Enders Game" - if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) { - itemFolderId = it.id - itemFolderUrl = it.uri.toString() - itemFolderBasePath = it.getBasePath(ctx) - itemFolderAbsolutePath = it.getAbsolutePath(ctx) - } + val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl)) + if (root == null) { + Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}") + callback(null) + return } - - if (itemFolderUrl == "") { - Log.d(tag, "scanDownloadItem failed to find media folder") - return cb(null) + val itemFolder = findFolderByPath(root, item.itemSubfolder) + if (itemFolder == null) { + Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}") + callback(null) + return } - val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl)) - - if (df == null) { - Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}") - return cb(null) - } - - val localLibraryItemId = getLocalLibraryItemId(itemFolderId) - Log.d( - tag, - "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId" - ) - - // Search for files in media item folder - // m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154 - val filesFound = - df.search( - false, - DocumentFileType.FILE, - arrayOf("audio/*", "image/*", "video/mp4", "application/*") - ) - Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") - - var localEpisodeId: String? = null - var localLibraryItem: LocalLibraryItem? - if (downloadItem.mediaType == "book") { - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - itemFolderBasePath, - itemFolderAbsolutePath, - itemFolderUrl, - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } else { - // Lookup or create podcast local library item - localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) - if (localLibraryItem == null) { - Log.d( - tag, - "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}" - ) - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - itemFolderBasePath, - itemFolderAbsolutePath, - itemFolderUrl, - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } - } - - val audioTracks: MutableList = mutableListOf() - var foundEBookFile = false - - filesFound.forEach { docFile -> - val itemPart = - downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name } - if (itemPart == null) { - if (downloadItem.mediaType == "book" - ) { // for books every download item should be a file found - Log.e( - tag, - "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}" - ) - } - } else if (itemPart.audioTrack != null) { // Is audio track - val audioTrackFromServer = itemPart.audioTrack - Log.d( - tag, - "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}" - ) - - val localFileId = DeviceManager.getBase64Id(docFile.id) - val localFile = - LocalFile( - localFileId, - docFile.name, - docFile.uri.toString(), - docFile.getBasePath(ctx), - docFile.getAbsolutePath(ctx), - docFile.getSimplePath(ctx), - docFile.mimeType, - docFile.length() - ) - localLibraryItem.localFiles.add(localFile) - - // Create new audio track - val trackFileMetadata = - FileMetadata( - docFile.name ?: "", - docFile.extension ?: "", - docFile.getAbsolutePath(ctx), - docFile.getBasePath(ctx), - docFile.length() - ) - val track = - AudioTrack( - audioTrackFromServer.index, - audioTrackFromServer.startOffset, - audioTrackFromServer.duration, - localFile.filename ?: "", - localFile.contentUrl, - localFile.mimeType ?: "", - trackFileMetadata, - true, - localFileId, - audioTrackFromServer.index - ) - audioTracks.add(track) - - Log.d( - tag, - "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}" - ) - - // Add podcast episodes to library - itemPart.episode?.let { podcastEpisode -> - val podcast = localLibraryItem.media as Podcast - val newEpisode = podcast.addEpisode(track, podcastEpisode) - localEpisodeId = newEpisode.id - Log.d( - tag, - "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}" - ) - } - } else if (itemPart.ebookFile != null) { // Ebook - foundEBookFile = true - Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}") - val localFileId = DeviceManager.getBase64Id(docFile.id) - val localFile = - LocalFile( - localFileId, - docFile.name, - docFile.uri.toString(), - docFile.getBasePath(ctx), - docFile.getAbsolutePath(ctx), - docFile.getSimplePath(ctx), - docFile.mimeType, - docFile.length() - ) - localLibraryItem.localFiles.add(localFile) - - val ebookFile = - EBookFile( - itemPart.ebookFile.ino, - itemPart.ebookFile.metadata, - itemPart.ebookFile.ebookFormat, - true, - localFileId, - localFile.contentUrl - ) - (localLibraryItem.media as Book).ebookFile = ebookFile - Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}") - } else { // Cover image - val localFileId = DeviceManager.getBase64Id(docFile.id) - val localFile = - LocalFile( - localFileId, - docFile.name, - docFile.uri.toString(), - docFile.getBasePath(ctx), - docFile.getAbsolutePath(ctx), - docFile.getSimplePath(ctx), - docFile.mimeType, - docFile.length() - ) - - localLibraryItem.coverAbsolutePath = localFile.absolutePath - localLibraryItem.coverContentUrl = localFile.contentUrl - localLibraryItem.localFiles.add(localFile) - } - } - - if (audioTracks.isEmpty() && !foundEBookFile) { - Log.d( - tag, - "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}" - ) - return cb(null) - } - - // For books sort audio tracks then set - if (downloadItem.mediaType == "book") { - audioTracks.sortBy { it.index } - - var indexCheck = 1 - var startOffset = 0.0 - audioTracks.forEach { audioTrack -> - if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) { - audioTrack.index = indexCheck - audioTrack.startOffset = startOffset - } - indexCheck++ - startOffset += audioTrack.duration - } - - localLibraryItem.media.setAudioTracks(audioTracks) - } - - val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null) - - // If library item had media progress then make local media progress and save - downloadItem.userMediaProgress?.let { mediaProgress -> - val localMediaProgressId = - if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId - else "$localLibraryItemId-$localEpisodeId" - val newLocalMediaProgress = - LocalMediaProgress( - id = localMediaProgressId, - localLibraryItemId = localLibraryItemId, - localEpisodeId = localEpisodeId, - duration = mediaProgress.duration, - progress = mediaProgress.progress, - currentTime = mediaProgress.currentTime, - isFinished = mediaProgress.isFinished, - ebookLocation = mediaProgress.ebookLocation, - ebookProgress = mediaProgress.ebookProgress, - lastUpdate = mediaProgress.lastUpdate, - startedAt = mediaProgress.startedAt, - finishedAt = mediaProgress.finishedAt, - serverConnectionConfigId = downloadItem.serverConnectionConfigId, - serverAddress = downloadItem.serverAddress, - serverUserId = downloadItem.serverUserId, - libraryItemId = downloadItem.libraryItemId, - episodeId = downloadItem.episodeId - ) - Log.d( - tag, - "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}" - ) - - DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress) - - downloadItemScanResult.localMediaProgress = newLocalMediaProgress - } - - DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) - - cb(downloadItemScanResult) + val id = localLibraryItemId(itemFolder.id) + val localItem = + DeviceManager.dbManager.getLocalLibraryItem(id) + ?: newLocalLibraryItem( + id, + item, + itemFolder.getBasePath(ctx), + itemFolder.getAbsolutePath(ctx), + itemFolder.uri.toString() + ) + scanParts(item, localItem, itemFolder, callback) } } 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 b2f8293e..d02f2d3c 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 @@ -122,6 +122,21 @@ class DbManager { return downloadItems } + /** + * The downloader now persists app-owned staging paths instead of DownloadManager state. Old + * queue entries cannot be resumed safely, but completed local media lives in other books and is + * deliberately left alone. + */ + fun clearLegacyDownloadQueueOnce() { + val metadata = Paper.book("downloadQueueMetadata") + val architectureVersion = metadata.read("architectureVersion") ?: 0 + if (architectureVersion >= 2) return + + Paper.book("downloadItems").destroy() + metadata.write("architectureVersion", 2) + Log.i(tag, "Cleared legacy persisted download queue for architecture v2") + } + fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) { Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress) } @@ -148,7 +163,7 @@ class DbManager { } // Make sure all local file ids still exist - fun cleanLocalLibraryItems() { + fun cleanLocalLibraryItems(context: Context) { val localLibraryItems = getLocalLibraryItems() localLibraryItems.forEach { lli -> @@ -157,15 +172,15 @@ class DbManager { // Check local files lli.localFiles = lli.localFiles.filter { localFile -> - val file = File(localFile.absolutePath) - if (!file.exists()) { + val exists = localFile.exists(context) + if (!exists) { Log.d( tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}" ) hasUpdates = true } - file.exists() + exists } as MutableList @@ -203,9 +218,10 @@ class DbManager { // Check cover still there lli.coverAbsolutePath?.let { - val coverFile = File(it) - - if (!coverFile.exists()) { + val coverExists = lli.localFiles.any { localFile -> + localFile.absolutePath == it && localFile.exists(context) + } + if (!coverExists) { Log.d( tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}" diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index b57cc9f6..b4e304b3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -1,15 +1,8 @@ package com.audiobookshelf.app.managers -import android.app.DownloadManager import android.net.Uri import android.util.Log import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.file.DocumentFileCompat -import com.anggrayudi.storage.file.MimeType -import com.anggrayudi.storage.file.getAbsolutePath -import com.anggrayudi.storage.file.moveFileTo -import com.anggrayudi.storage.media.FileDescription import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.FolderScanner @@ -19,36 +12,32 @@ import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject import java.io.File -import java.io.FileOutputStream -import java.util.* +import java.io.FileInputStream +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import okhttp3.Call -/** Manages download items and their parts. */ +/** Owns the Android download queue and writes all bytes to app-owned staging files. */ class DownloadItemManager( - var downloadManager: DownloadManager, - private var folderScanner: FolderScanner, - var mainActivity: MainActivity, - private var clientEventEmitter: DownloadEventEmitter + private val folderScanner: FolderScanner, + private val mainActivity: MainActivity, + private val clientEventEmitter: DownloadEventEmitter ) { - val tag = "DownloadItemManager" + private val tag = "DownloadItemManager" private val maxSimultaneousDownloads = 3 - private var jacksonMapper = - jacksonObjectMapper() - .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val activeCalls = ConcurrentHashMap() + private var watcherRunning = false + private val jacksonMapper = + jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) - enum class DownloadCheckStatus { - InProgress, - Successful, - Failed - } - - var downloadItemQueue: MutableList = - mutableListOf() // All pending and downloading items - var currentDownloadItemParts: MutableList = - mutableListOf() // Item parts currently being downloaded + var downloadItemQueue: MutableList = mutableListOf() + var currentDownloadItemParts: MutableList = mutableListOf() interface DownloadEventEmitter { fun onDownloadItem(downloadItem: DownloadItem) @@ -61,323 +50,223 @@ class DownloadItemManager( fun onComplete(failed: Boolean) } - companion object { - var isDownloading: Boolean = false + init { + DeviceManager.dbManager.clearLegacyDownloadQueueOnce() } - /** Adds a download item to the queue and starts processing the queue. */ + @Synchronized fun addDownloadItem(downloadItem: DownloadItem) { DeviceManager.dbManager.saveDownloadItem(downloadItem) - Log.i(tag, "Add download item ${downloadItem.media.metadata.title}") - downloadItemQueue.add(downloadItem) clientEventEmitter.onDownloadItem(downloadItem) checkUpdateDownloadQueue() } - /** Checks and updates the download queue. */ + @Synchronized private fun checkUpdateDownloadQueue() { - for (downloadItem in downloadItemQueue) { - val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size - val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet) - Log.d( - tag, - "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}" - ) - - if (nextDownloadItemParts.isNotEmpty()) { - processDownloadItemParts(nextDownloadItemParts) - } - - if (currentDownloadItemParts.size >= maxSimultaneousDownloads) { - break - } + for (downloadItem in downloadItemQueue.toList()) { + val availableSlots = maxSimultaneousDownloads - currentDownloadItemParts.size + if (availableSlots <= 0) break + downloadItem.getNextDownloadItemParts(availableSlots).forEach(::startDownload) } - if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads() } - /** Processes the download item parts. */ - private fun processDownloadItemParts(nextDownloadItemParts: List) { - nextDownloadItemParts.forEach { - if (it.isInternalStorage) { - startInternalDownload(it) - } else { - startExternalDownload(it) - } - } - } - - /** Starts an internal download. */ - private fun startInternalDownload(downloadItemPart: DownloadItemPart) { - val file = File(downloadItemPart.finalDestinationPath) - file.parentFile?.mkdirs() - - val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath) - val internalProgressCallback = + private fun startDownload(part: DownloadItemPart) { + val stagingFile = File(part.destinationPath) + stagingFile.parentFile?.mkdirs() + part.downloadId = APP_MANAGED_DOWNLOAD_ID + part.lastUpdateTime = System.currentTimeMillis() + currentDownloadItemParts.add(part) + val callback = object : InternalProgressCallback { override fun onProgress(totalBytesWritten: Long, progress: Long) { - downloadItemPart.bytesDownloaded = totalBytesWritten - downloadItemPart.progress = progress + synchronized(this@DownloadItemManager) { + part.bytesDownloaded = totalBytesWritten + part.progress = progress + part.lastUpdateTime = System.currentTimeMillis() + } } override fun onComplete(failed: Boolean) { - downloadItemPart.failed = failed - downloadItemPart.completed = true - } - } - - Log.d( - tag, - "Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}" - ) - InternalDownloadManager(fileOutputStream, internalProgressCallback) - .download(downloadItemPart.serverUrl) - downloadItemPart.downloadId = 1 - currentDownloadItemParts.add(downloadItemPart) - } - - /** Starts an external download. */ - private fun startExternalDownload(downloadItemPart: DownloadItemPart) { - val dlRequest = downloadItemPart.getDownloadRequest() - val downloadId = downloadManager.enqueue(dlRequest) - downloadItemPart.downloadId = downloadId - Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId") - currentDownloadItemParts.add(downloadItemPart) - } - - /** Starts watching the downloads. */ - private fun startWatchingDownloads() { - if (isDownloading) return // Already watching - - GlobalScope.launch(Dispatchers.IO) { - Log.d(tag, "Starting watching downloads") - isDownloading = true - - while (currentDownloadItemParts.isNotEmpty()) { - val itemParts = currentDownloadItemParts.filter { !it.isMoving } - for (downloadItemPart in itemParts) { - if (downloadItemPart.isInternalStorage) { - handleInternalDownloadPart(downloadItemPart) - } else { - handleExternalDownloadPart(downloadItemPart) - } - } - - delay(500) - - if (currentDownloadItemParts.size < maxSimultaneousDownloads) { - checkUpdateDownloadQueue() - } - } - - Log.d(tag, "Finished watching downloads") - isDownloading = false - } - } - - /** Handles an internal download part. */ - private fun handleInternalDownloadPart(downloadItemPart: DownloadItemPart) { - clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) - - if (downloadItemPart.completed) { - val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId } - downloadItem?.let { checkDownloadItemFinished(it) } - currentDownloadItemParts.remove(downloadItemPart) - } - } - - /** Handles an external download part. */ - private fun handleExternalDownloadPart(downloadItemPart: DownloadItemPart) { - val downloadCheckStatus = checkDownloadItemPart(downloadItemPart) - clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) - - // Will move to final destination, remove current item parts, and check if download item is - // finished - handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart) - } - - /** Checks the status of a download item part. */ - private fun checkDownloadItemPart(downloadItemPart: DownloadItemPart): DownloadCheckStatus { - val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed - - val query = DownloadManager.Query().setFilterById(downloadId) - downloadManager.query(query).use { - if (it.moveToFirst()) { - val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) - val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS) - val bytesDownloadedColumnIndex = - it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) - - val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0 - val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0 - val bytesDownloadedSoFar = - if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0 - Log.d( - tag, - "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus" - ) - - return when (downloadStatus) { - DownloadManager.STATUS_SUCCESSFUL -> { - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful") - downloadItemPart.completed = true - downloadItemPart.progress = 1 - downloadItemPart.bytesDownloaded = bytesDownloadedSoFar - - DownloadCheckStatus.Successful - } - DownloadManager.STATUS_FAILED -> { - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed") - downloadItemPart.completed = true - downloadItemPart.failed = true - - DownloadCheckStatus.Failed - } - else -> { - val percentProgress = - if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0 - Log.d( - tag, - "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%" - ) - downloadItemPart.progress = percentProgress - downloadItemPart.bytesDownloaded = bytesDownloadedSoFar - - DownloadCheckStatus.InProgress - } - } - } else { - Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager") - downloadItemPart.completed = true - downloadItemPart.failed = true - return DownloadCheckStatus.Failed - } - } - } - - /** Handles the result of a download item part check. */ - private fun handleDownloadItemPartCheck( - downloadCheckStatus: DownloadCheckStatus, - downloadItemPart: DownloadItemPart - ) { - val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId } - if (downloadItem == null) { - Log.e( - tag, - "Download item part finished but download item not found ${downloadItemPart.filename}" - ) - currentDownloadItemParts.remove(downloadItemPart) - } else if (downloadCheckStatus == DownloadCheckStatus.Successful) { - moveDownloadedFile(downloadItem, downloadItemPart) - } else if (downloadCheckStatus != DownloadCheckStatus.InProgress) { - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) - } - } - - /** Moves the downloaded file to its final destination. */ - private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) { - val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri) - Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}") - - val fcb = - object : FileCallback() { - override fun onPrepare() { - Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE") - } - - override fun onFailed(errorCode: ErrorCode) { - Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode") - downloadItemPart.failed = true - downloadItemPart.isMoving = false - file?.delete() - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) - } - - override fun onCompleted(result: Any) { - Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED") - val resultDocFile = result as DocumentFile - Log.d( - tag, - "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}" - ) - - // Rename to fix appended .mp3 on m4b/m4a files - // REF: https://github.com/anggrayudi/SimpleStorage/issues/94 - val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: "" - if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3") - ) { - resultDocFile.renameTo(downloadItemPart.filename) + synchronized(this@DownloadItemManager) { + part.failed = failed + part.completed = true + part.lastUpdateTime = System.currentTimeMillis() + activeCalls.remove(part.id) } - - downloadItemPart.moved = true - downloadItemPart.isMoving = false - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) } } + activeCalls[part.id] = InternalDownloadManager(stagingFile, part.fileSize, callback).download(part.serverUrl) + } - val localFolderFile = - DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl)) - if (localFolderFile == null) { - // Failed - downloadItemPart.failed = true - Log.e(tag, "Local Folder File from uri is null") - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) - } else { - downloadItemPart.isMoving = true - val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE - val fileDescription = - FileDescription( - downloadItemPart.filename, - downloadItemPart.finalDestinationSubfolder, - mimetype - ) - file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb) + @Synchronized + private fun startWatchingDownloads() { + if (watcherRunning) return + watcherRunning = true + scope.launch { + while (true) { + val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } + if (activeParts.isEmpty()) break + activeParts.forEach(::handlePartUpdate) + delay(WATCH_INTERVAL_MS) + synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() } + } + synchronized(this@DownloadItemManager) { watcherRunning = false } } } - /** Checks if a download item is finished and processes it. */ + private fun handlePartUpdate(part: DownloadItemPart) { + clientEventEmitter.onDownloadItemPartUpdate(part) + if (!part.completed) { + val lastUpdate = part.lastUpdateTime ?: return + if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { + Log.e(tag, "Download stalled: ${part.filename}") + activeCalls.remove(part.id)?.cancel() + synchronized(this) { + part.failed = true + part.completed = true + } + } + return + } + + val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } + if (item == null) { + removeActivePart(part) + return + } + if (part.failed) { + removeActivePart(part) + return + } + if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part) + } + + private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) { + if (part.moved || part.isMoving) return + part.isMoving = true + val stagingFile = File(part.destinationPath) + val finalFile = File(part.finalDestinationPath) + finalFile.parentFile?.mkdirs() + if (finalFile.exists() && !finalFile.delete()) { + failFinalization(item, part, "Could not replace existing internal file") + return + } + if (!stagingFile.renameTo(finalFile)) { + failFinalization(item, part, "Could not finalize internal staging file") + return + } + part.moved = true + part.isMoving = false + removeActivePart(part) + checkDownloadItemFinished(item) + } + + private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) { + if (part.moved || part.isMoving) return + val destinationRoot = DocumentFile.fromTreeUri(mainActivity, Uri.parse(part.localFolderUrl)) + if (destinationRoot == null) { + failFinalization(item, part, "Could not resolve SAF destination") + return + } + part.isMoving = true + scope.launch { + try { + val destinationFolder = getOrCreateFolder(destinationRoot, part.finalDestinationSubfolder) + ?: throw IllegalStateException("Could not create SAF destination folder") + destinationFolder.findFile(part.filename)?.let { existing -> + if (!existing.delete()) throw IllegalStateException("Could not replace ${part.filename}") + } + val destinationFile = destinationFolder.createFile(mimeTypeFor(part), part.filename) + ?: throw IllegalStateException("Could not create ${part.filename}") + val stagingFile = File(part.destinationPath) + FileInputStream(stagingFile).use { input -> + mainActivity.contentResolver.openOutputStream(destinationFile.uri, "w")?.use { output -> + input.copyTo(output) + } ?: throw IllegalStateException("Could not open SAF output stream") + } + if (destinationFile.length() != stagingFile.length()) { + destinationFile.delete() + throw IllegalStateException("SAF copy size mismatch for ${part.filename}") + } + stagingFile.delete() + part.completedDestinationUri = destinationFile.uri.toString() + part.moved = true + part.isMoving = false + removeActivePart(part) + checkDownloadItemFinished(item) + } catch (e: Exception) { + failFinalization(item, part, "SAF copy failed: ${e.message}") + } + } + } + + private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? { + var current = root + relativePath.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null + } + return current + } + + private fun mimeTypeFor(part: DownloadItemPart): String { + return part.audioTrack?.mimeType + ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" + } + } + + private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { + Log.e(tag, message) + part.failed = true + part.isMoving = false + part.completed = true + removeActivePart(part) + } + + @Synchronized + private fun removeActivePart(part: DownloadItemPart) { + activeCalls.remove(part.id) + currentDownloadItemParts.remove(part) + } + private fun checkDownloadItemFinished(downloadItem: DownloadItem) { - if (downloadItem.isDownloadFinished) { - Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}") - - GlobalScope.launch(Dispatchers.IO) { - folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult -> - Log.d( - tag, - "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}" - ) - - val jsobj = - JSObject().apply { - put("libraryItemId", downloadItem.id) - put("localFolderId", downloadItem.localFolder.id) - - downloadItemScanResult?.localLibraryItem?.let { localLibraryItem -> - put( - "localLibraryItem", - JSObject(jacksonMapper.writeValueAsString(localLibraryItem)) - ) - } - downloadItemScanResult?.localMediaProgress?.let { localMediaProgress -> - put( - "localMediaProgress", - JSObject(jacksonMapper.writeValueAsString(localMediaProgress)) - ) - } + if (!downloadItem.isDownloadFinished) return + scope.launch { + folderScanner.scanDownloadItem(downloadItem) { scanResult -> + val event = + JSObject().apply { + put("libraryItemId", downloadItem.id) + put("localFolderId", downloadItem.localFolder.id) + scanResult?.localLibraryItem?.let { + put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) } - - launch(Dispatchers.Main) { - clientEventEmitter.onDownloadItemComplete(jsobj) - downloadItemQueue.remove(downloadItem) - DeviceManager.dbManager.removeDownloadItem(downloadItem.id) - } + scanResult?.localMediaProgress?.let { + put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) + } + } + clientEventEmitter.onDownloadItemComplete(event) + synchronized(this@DownloadItemManager) { + downloadItemQueue.remove(downloadItem) + DeviceManager.dbManager.removeDownloadItem(downloadItem.id) } } } } + + fun destroy() { + activeCalls.values.forEach(Call::cancel) + activeCalls.clear() + scope.cancel() + } + + private companion object { + const val APP_MANAGED_DOWNLOAD_ID = -1L + const val WATCH_INTERVAL_MS = 500L + const val STALL_TIMEOUT_MS = 60_000L + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index 3f1f9348..128d41a6 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -1,114 +1,113 @@ package com.audiobookshelf.app.managers import android.util.Log -import java.io.* +import java.io.File +import java.io.FileOutputStream +import java.io.IOException import java.util.concurrent.TimeUnit -import okhttp3.* +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response -/** - * Manages the internal download process. - * - * @property outputStream The output stream to write the downloaded data. - * @property progressCallback The callback to report download progress. - */ +/** Streams a download into an app-owned staging file. */ class InternalDownloadManager( - private val outputStream: FileOutputStream, + private val destinationFile: File, + private val expectedSize: Long, private val progressCallback: DownloadItemManager.InternalProgressCallback -) : AutoCloseable { - +) { private val tag = "InternalDownloadManager" - private val client: OkHttpClient = - OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build() - private val writer = BinaryFileWriter(outputStream, progressCallback) + private val client = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() /** - * Downloads a file from the given URL. - * - * @param url The URL to download the file from. - * @throws IOException If an I/O error occurs. + * Returns the active call so the queue can cancel a stalled transfer. A partial staging file is + * retained only when the server proves that it honoured a subsequent range request. */ - @Throws(IOException::class) - fun download(url: String) { - val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build() - client.newCall(request) - .enqueue( - object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.e(tag, "Download URL $url FAILED", e) - progressCallback.onComplete(true) - } + fun download(url: String): Call { + destinationFile.parentFile?.mkdirs() + val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L + val request = + Request.Builder() + .url(url) + .addHeader("Accept-Encoding", "identity") + .apply { + if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") + } + .build() + val call = client.newCall(request) + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(tag, "Download URL failed", e) + progressCallback.onComplete(true) + } - override fun onResponse(call: Call, response: Response) { - response.body?.let { responseBody -> - val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L - writer.write(responseBody.byteStream(), length) + override fun onResponse(call: Call, response: Response) { + response.use { + try { + val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes) + if (existingBytes > 0L && !append && response.code != 200) { + Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes") + progressCallback.onComplete(true) + return + } + if (!response.isSuccessful || response.body == null) { + Log.e(tag, "Download HTTP failure ${response.code}") + progressCallback.onComplete(true) + return + } + + val startingBytes = if (append) existingBytes else 0L + val responseLength = response.body!!.contentLength() + val totalLength = + if (expectedSize > 0L) expectedSize + else if (responseLength >= 0L) startingBytes + responseLength + else 0L + + FileOutputStream(destinationFile, append).use { output -> + response.body!!.byteStream().use { input -> + val buffer = ByteArray(CHUNK_SIZE) + var totalBytes = startingBytes + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + totalBytes += read + val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L + progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L)) } - ?: run { - Log.e(tag, "Response doesn't contain a file") - progressCallback.onComplete(true) - } } } - ) + + if (expectedSize > 0L && destinationFile.length() != expectedSize) { + Log.e(tag, "Downloaded size ${destinationFile.length()} did not match $expectedSize") + progressCallback.onComplete(true) + } else { + progressCallback.onComplete(false) + } + } catch (e: IOException) { + Log.e(tag, "Could not write staging file", e) + progressCallback.onComplete(true) + } + } + } + } + ) + return call } - /** - * Closes the download manager and releases resources. - * - * @throws Exception If an error occurs during closing. - */ - @Throws(Exception::class) - override fun close() { - writer.close() - } -} - -/** - * Writes binary data to an output stream. - * - * @property outputStream The output stream to write the data to. - * @property progressCallback The callback to report write progress. - */ -class BinaryFileWriter( - private val outputStream: OutputStream, - private val progressCallback: DownloadItemManager.InternalProgressCallback -) : AutoCloseable { - - /** - * Writes data from the input stream to the output stream. - * - * @param inputStream The input stream to read the data from. - * @param length The total length of the data to be written. - * @return The total number of bytes written. - * @throws IOException If an I/O error occurs. - */ - @Throws(IOException::class) - fun write(inputStream: InputStream, length: Long): Long { - BufferedInputStream(inputStream).use { input -> - val dataBuffer = ByteArray(CHUNK_SIZE) - var totalBytes: Long = 0 - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - totalBytes += readBytes - outputStream.write(dataBuffer, 0, readBytes) - progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length) - } - progressCallback.onComplete(false) - return totalBytes - } - } - - /** - * Closes the writer and releases resources. - * - * @throws IOException If an error occurs during closing. - */ - @Throws(IOException::class) - override fun close() { - outputStream.close() - } - - companion object { - private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance + private fun hasExpectedRange(response: Response, offset: Long): Boolean { + val range = response.header("Content-Range") ?: return false + return range.startsWith("bytes $offset-") + } + + private companion object { + const val CHUNK_SIZE = 8 * 1024 } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt index 918744eb..95ec320b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -25,7 +25,7 @@ data class DownloadItem( val isInternalStorage get() = localFolder.id.startsWith("internal-") @get:JsonIgnore - val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving } + val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed } @JsonIgnore fun getNextDownloadItemParts(limit:Int): MutableList { diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index fd65271e..a60a24cd 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -1,6 +1,5 @@ package com.audiobookshelf.app.models -import android.app.DownloadManager import android.net.Uri import android.util.Log import com.audiobookshelf.app.data.AudioTrack @@ -16,6 +15,8 @@ data class DownloadItemPart( val downloadItemId: String, val filename: String, val fileSize: Long, + /** App-owned staging location. This is intentionally a String so it survives process storage. */ + val destinationPath: String, val finalDestinationPath:String, val serverPath: String, val localFolderName: String, @@ -31,8 +32,11 @@ data class DownloadItemPart( @JsonIgnore val uri: Uri, @JsonIgnore val destinationUri: Uri, @JsonIgnore val finalDestinationUri: Uri, + /** Final SAF document returned by the provider after a successful move. */ + @JsonIgnore var completedDestinationUri: String?, val finalDestinationSubfolder: String, var downloadId: Long?, + var lastUpdateTime: Long?, var progress: Long, var bytesDownloaded: Long ) { @@ -53,6 +57,7 @@ data class DownloadItemPart( downloadItemId, filename = filename, fileSize = fileSize, + destinationPath = destinationFile.absolutePath, finalDestinationPath = finalDestinationFile.absolutePath, serverPath = serverPath, localFolderName = localFolder.name, @@ -68,8 +73,10 @@ data class DownloadItemPart( uri = downloadUri, destinationUri = destinationUri, finalDestinationUri = finalDestinationUri, + completedDestinationUri = null, finalDestinationSubfolder = subfolder, downloadId = null, + lastUpdateTime = null, progress = 0, bytesDownloaded = 0 ) @@ -82,13 +89,4 @@ data class DownloadItemPart( @get:JsonIgnore val serverUrl get() = uri.toString() - @JsonIgnore - fun getDownloadRequest(): DownloadManager.Request { - val dlRequest = DownloadManager.Request(uri) - dlRequest.setTitle(filename) - dlRequest.setDescription("Downloading to $localFolderName with filename $filename") - dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - dlRequest.setDestinationUri(destinationUri) - return dlRequest - } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 41d7cbf7..7e036f79 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -229,7 +229,7 @@ class AbsAudioPlayer : Plugin() { return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}")) } } - if (!it.hasTracks(episode)) { + if (!it.hasTracks(mainActivity, episode)) { return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}")) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index 473e69ce..0964d470 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -40,7 +40,7 @@ class AbsDatabase : Plugin() { secureStorage = SecureStorage(mainActivity) DeviceManager.dbManager.cleanLocalMediaProgress() - DeviceManager.dbManager.cleanLocalLibraryItems() + DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity) DeviceManager.dbManager.cleanLogs() } 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 d3e42029..3dde53d2 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,7 +1,5 @@ package com.audiobookshelf.app.plugins -import android.app.DownloadManager -import android.content.Context import android.os.Environment import android.util.Log import com.audiobookshelf.app.MainActivity @@ -27,7 +25,6 @@ class AbsDownloader : Plugin() { private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity - lateinit var downloadManager: DownloadManager lateinit var apiHandler: ApiHandler lateinit var folderScanner: FolderScanner lateinit var downloadItemManager: DownloadItemManager @@ -46,10 +43,14 @@ class AbsDownloader : Plugin() { override fun load() { mainActivity = (activity as MainActivity) - downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager folderScanner = FolderScanner(mainActivity) apiHandler = ApiHandler(mainActivity) - downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter) + downloadItemManager = DownloadItemManager(folderScanner, mainActivity, clientEventEmitter) + } + + override fun handleOnDestroy() { + if (::downloadItemManager.isInitialized) downloadItemManager.destroy() + super.handleOnDestroy() } @PluginMethod @@ -132,7 +133,15 @@ class AbsDownloader : Plugin() { private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { val isInternal = localFolder.id.startsWith("internal-") - val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}" + // Keep internal staging on the same filesystem as its final file so finalization is a rename. + // External-folder downloads can use app external storage and are moved through SAF afterwards. + val tempFolderPath = + if (isInternal) { + "${mainActivity.filesDir}/download-staging/${libraryItem.id}" + } else { + "${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}" + } Log.d(tag, "downloadCacheDirectory=$tempFolderPath") @@ -143,7 +152,7 @@ class AbsDownloader : Plugin() { val tracks = libraryItem.media.getAudioTracks() Log.d(tag, "Starting library item download with ${tracks.size} tracks") val itemSubfolder = "$bookAuthor/$bookTitle" - val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder" + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder" val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) val book = libraryItem.media as Book @@ -152,12 +161,7 @@ class AbsDownloader : Plugin() { val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download" val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") - val destinationFile = File("$tempFolderPath/$destinationFilename") - - if (destinationFile.exists()) { - Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}") - destinationFile.delete() - } + val destinationFile = File("$tempFolderPath/$destinationFilename.part") if (finalDestinationFile.exists()) { Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}") @@ -181,12 +185,7 @@ class AbsDownloader : Plugin() { Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") - val destinationFile = File("$tempFolderPath/$destinationFilename") - - if (destinationFile.exists()) { - Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}") - destinationFile.delete() - } + val destinationFile = File("$tempFolderPath/$destinationFilename.part") if (finalDestinationFile.exists()) { Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") @@ -205,14 +204,9 @@ class AbsDownloader : Plugin() { val serverPath = "/api/items/${libraryItem.id}/cover" val destinationFilename = "cover-${libraryItem.id}.jpg" - val destinationFile = File("$tempFolderPath/$destinationFilename") + val destinationFile = File("$tempFolderPath/$destinationFilename.part") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") - if (destinationFile.exists()) { - Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}") - destinationFile.delete() - } - if (finalDestinationFile.exists()) { Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}") finalDestinationFile.delete() @@ -233,7 +227,7 @@ class AbsDownloader : Plugin() { val fileSize = audioTrack?.metadata?.size ?: 0 Log.d(tag, "Starting podcast episode download") - val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle" + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle" val downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) @@ -241,7 +235,7 @@ class AbsDownloader : Plugin() { var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") - var destinationFile = File("$tempFolderPath/$destinationFilename") + var destinationFile = File("$tempFolderPath/$destinationFilename.part") var finalDestinationFile = File("$itemFolderPath/$destinationFilename") if (finalDestinationFile.exists()) { Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") @@ -258,7 +252,7 @@ class AbsDownloader : Plugin() { serverPath = "/api/items/${libraryItem.id}/cover" destinationFilename = "cover.jpg" - destinationFile = File("$tempFolderPath/$destinationFilename") + destinationFile = File("$tempFolderPath/$destinationFilename.part") finalDestinationFile = File("$itemFolderPath/$destinationFilename") if (finalDestinationFile.exists()) { From 6aeb31b59116cf08772c0549421f6fb77fda7f77 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 18 Jul 2026 22:56:12 -0700 Subject: [PATCH 02/17] Add support for background download and resumption --- android/app/src/main/AndroidManifest.xml | 7 + .../app/managers/DownloadItemManager.kt | 435 +++++++++++++----- .../app/managers/InternalDownloadManager.kt | 27 +- .../audiobookshelf/app/models/DownloadItem.kt | 2 +- .../app/models/DownloadItemPart.kt | 17 +- .../app/plugins/AbsDownloader.kt | 53 +-- .../app/services/DownloadService.kt | 88 ++++ .../app/services/DownloadServiceHost.kt | 104 +++++ 8 files changed, 562 insertions(+), 171 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 67a6f3c5..1ecfd2bc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + + + () + /** DocumentsProvider does not make concurrent createDirectory/findFile calls atomic. */ + private val safFolderLocks = ConcurrentHashMap() + private val reservations = mutableMapOf() + private val lastPersistTime = mutableMapOf() private var watcherRunning = false private val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var downloadItemQueue: MutableList = mutableListOf() + private set var currentDownloadItemParts: MutableList = mutableListOf() + private set interface DownloadEventEmitter { fun onDownloadItem(downloadItem: DownloadItem) fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart) fun onDownloadItemComplete(jsobj: JSObject) + fun onQueueChanged(hasWork: Boolean) } interface InternalProgressCallback { @@ -55,49 +66,129 @@ class DownloadItemManager( } @Synchronized - fun addDownloadItem(downloadItem: DownloadItem) { - DeviceManager.dbManager.saveDownloadItem(downloadItem) - downloadItemQueue.add(downloadItem) - clientEventEmitter.onDownloadItem(downloadItem) - checkUpdateDownloadQueue() + fun setEventEmitter(eventEmitter: DownloadEventEmitter) { + clientEventEmitter = eventEmitter + downloadItemQueue.forEach(clientEventEmitter::onDownloadItem) + notifyQueueChanged() } @Synchronized - private fun checkUpdateDownloadQueue() { - for (downloadItem in downloadItemQueue.toList()) { - val availableSlots = maxSimultaneousDownloads - currentDownloadItemParts.size - if (availableSlots <= 0) break - downloadItem.getNextDownloadItemParts(availableSlots).forEach(::startDownload) + fun restoreQueue() { + if (downloadItemQueue.isNotEmpty()) return + DeviceManager.dbManager.getDownloadItems().forEach { item -> + if (item.isDownloadFinished) { + downloadItemQueue.add(item) + checkDownloadItemFinished(item) + return@forEach + } + item.downloadItemParts.forEach { part -> + if (part.moved) return@forEach + part.downloadId = null + part.isMoving = false + part.failed = false + part.completed = false + part.waitingForSpace = false + part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L + } + downloadItemQueue.add(item) + clientEventEmitter.onDownloadItem(item) } - if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads() + checkUpdateDownloadQueue() + notifyQueueChanged() } - private fun startDownload(part: DownloadItemPart) { + @Synchronized + fun addDownloadItem(downloadItem: DownloadItem) { + if (downloadItemQueue.any { it.id == downloadItem.id }) return + persist(downloadItem, force = true) + downloadItemQueue.add(downloadItem) + clientEventEmitter.onDownloadItem(downloadItem) + checkUpdateDownloadQueue() + notifyQueueChanged() + } + + @Synchronized + fun retryAll() { + downloadItemQueue.forEach { item -> + item.downloadItemParts.filter { it.failed }.forEach { part -> + part.failed = false + part.completed = false + part.isMoving = false + part.downloadId = null + part.retryCount = 0 + } + persist(item, force = true) + } + checkUpdateDownloadQueue() + notifyQueueChanged() + } + + @Synchronized + fun cancelAll() { + activeCalls.values.forEach(Call::cancel) + activeCalls.clear() + downloadItemQueue.forEach { item -> + item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() } + DeviceManager.dbManager.removeDownloadItem(item.id) + } + currentDownloadItemParts.clear() + reservations.clear() + downloadItemQueue.clear() + notifyQueueChanged() + } + + @Synchronized + fun hasWork(): Boolean = downloadItemQueue.isNotEmpty() + + @Synchronized + private fun checkUpdateDownloadQueue() { + downloadItemQueue.toList().forEach { item -> + val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size + if (slots <= 0) return@forEach + item.getNextDownloadItemParts(slots).forEach { part -> + if (tryReserve(part)) startDownload(item, part) + else { + part.waitingForSpace = true + part.lastUpdateTime = System.currentTimeMillis() + persist(item) + clientEventEmitter.onDownloadItemPartUpdate(part) + } + } + } + startWatchingDownloads() + } + + private fun startDownload(item: DownloadItem, part: DownloadItemPart) { val stagingFile = File(part.destinationPath) stagingFile.parentFile?.mkdirs() part.downloadId = APP_MANAGED_DOWNLOAD_ID + part.waitingForSpace = false part.lastUpdateTime = System.currentTimeMillis() currentDownloadItemParts.add(part) - val callback = - object : InternalProgressCallback { + persist(item, force = true) + activeCalls[part.id] = + InternalDownloadManager(stagingFile, part.fileSize, object : InternalProgressCallback { override fun onProgress(totalBytesWritten: Long, progress: Long) { synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return part.bytesDownloaded = totalBytesWritten part.progress = progress part.lastUpdateTime = System.currentTimeMillis() + persist(item) } } override fun onComplete(failed: Boolean) { synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return part.failed = failed - part.completed = true + part.completed = !failed part.lastUpdateTime = System.currentTimeMillis() activeCalls.remove(part.id) + persist(item, force = true) } } - } - activeCalls[part.id] = InternalDownloadManager(stagingFile, part.fileSize, callback).download(part.serverUrl) + }, { hasAvailableSpace(part) }).download(serverUrl(item, part)) } @Synchronized @@ -107,101 +198,223 @@ class DownloadItemManager( scope.launch { while (true) { val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } - if (activeParts.isEmpty()) break activeParts.forEach(::handlePartUpdate) + synchronized(this@DownloadItemManager) { + checkUpdateDownloadQueue() + if (downloadItemQueue.isEmpty()) { + watcherRunning = false + notifyQueueChanged() + return@launch + } + } delay(WATCH_INTERVAL_MS) - synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() } } - synchronized(this@DownloadItemManager) { watcherRunning = false } } } private fun handlePartUpdate(part: DownloadItemPart) { clientEventEmitter.onDownloadItemPartUpdate(part) - if (!part.completed) { + val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } ?: run { + removeActivePart(part) + return + } + if (!part.completed && !part.failed) { val lastUpdate = part.lastUpdateTime ?: return if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { - Log.e(tag, "Download stalled: ${part.filename}") + Log.w(tag, "Download stalled: ${part.filename}") activeCalls.remove(part.id)?.cancel() - synchronized(this) { - part.failed = true - part.completed = true - } + failOrRetry(item, part, "Download stalled") } return } - - val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } - if (item == null) { - removeActivePart(part) - return - } if (part.failed) { - removeActivePart(part) + failOrRetry(item, part, "Transfer failed") return } if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part) } + private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) { + removeActivePart(part) + part.retryCount += 1 + releaseReservation(part) + if (part.retryCount > MAX_RETRIES) { + Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}") + part.failed = true + part.completed = false + part.downloadId = null + persist(item, force = true) + notifyQueueChanged() + return + } + part.failed = false + part.completed = false + part.downloadId = null + part.isMoving = false + persist(item, force = true) + scope.launch { + delay(RETRY_BASE_DELAY_MS * (1L shl (part.retryCount - 1))) + synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() } + } + } + private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) { if (part.moved || part.isMoving) return part.isMoving = true val stagingFile = File(part.destinationPath) val finalFile = File(part.finalDestinationPath) finalFile.parentFile?.mkdirs() - if (finalFile.exists() && !finalFile.delete()) { - failFinalization(item, part, "Could not replace existing internal file") - return + val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup") + try { + 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 (!stagingFile.renameTo(finalFile)) { + if (backup.exists()) backup.renameTo(finalFile) + throw IllegalStateException("Could not finalize internal staging file") + } + backup.delete() + completePart(item, part) + } catch (e: Exception) { + part.isMoving = false + part.failed = true + failOrRetry(item, part, e.message ?: "Internal finalization failed") } - if (!stagingFile.renameTo(finalFile)) { - failFinalization(item, part, "Could not finalize internal staging file") - return - } - part.moved = true - part.isMoving = false - removeActivePart(part) - checkDownloadItemFinished(item) } private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) { if (part.moved || part.isMoving) return - val destinationRoot = DocumentFile.fromTreeUri(mainActivity, Uri.parse(part.localFolderUrl)) - if (destinationRoot == null) { - failFinalization(item, part, "Could not resolve SAF destination") - return - } + val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) + ?: return failFinalization(item, part, "Could not resolve SAF destination") part.isMoving = true + persist(item, force = true) scope.launch { try { - val destinationFolder = getOrCreateFolder(destinationRoot, part.finalDestinationSubfolder) - ?: throw IllegalStateException("Could not create SAF destination folder") - destinationFolder.findFile(part.filename)?.let { existing -> - if (!existing.delete()) throw IllegalStateException("Could not replace ${part.filename}") + if (!hasAvailableSpace(part)) throw IllegalStateException("Insufficient storage for SAF copy") + val folderKey = "${root.uri}/${part.finalDestinationSubfolder}" + val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() } + val folder = synchronized(folderLock) { + getOrCreateFolder(root, part.finalDestinationSubfolder) + } ?: throw IllegalStateException("Could not create SAF destination folder") + val temporaryName = ".${part.filename}.${part.id.hashCode()}.part" + folder.findFile(temporaryName)?.delete() + val temporary = folder.createFile(mimeTypeFor(part), temporaryName) + ?: throw IllegalStateException("Could not create SAF temporary file") + val staging = File(part.destinationPath) + FileInputStream(staging).use { input -> + context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) } + ?: throw IllegalStateException("Could not open SAF output stream") } - val destinationFile = destinationFolder.createFile(mimeTypeFor(part), part.filename) - ?: throw IllegalStateException("Could not create ${part.filename}") - val stagingFile = File(part.destinationPath) - FileInputStream(stagingFile).use { input -> - mainActivity.contentResolver.openOutputStream(destinationFile.uri, "w")?.use { output -> - input.copyTo(output) - } ?: throw IllegalStateException("Could not open SAF output stream") - } - if (destinationFile.length() != stagingFile.length()) { - destinationFile.delete() - throw IllegalStateException("SAF copy size mismatch for ${part.filename}") - } - stagingFile.delete() - part.completedDestinationUri = destinationFile.uri.toString() - part.moved = true - part.isMoving = false - removeActivePart(part) - checkDownloadItemFinished(item) + if (temporary.length() != staging.length()) throw IllegalStateException("SAF copy size mismatch") + val existing = folder.findFile(part.filename) + if (existing != null && !existing.delete()) throw IllegalStateException("Could not replace existing file") + if (!temporary.renameTo(part.filename)) throw IllegalStateException("Could not finalize SAF temporary file") + 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}") + part.completedDestinationUri = destination.uri.toString() + completePart(item, part) } catch (e: Exception) { failFinalization(item, part, "SAF copy failed: ${e.message}") } } } + private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { + Log.e(tag, message) + part.isMoving = false + part.failed = true + failOrRetry(item, part, message) + } + + private fun completePart(item: DownloadItem, part: DownloadItemPart) { + part.moved = true + part.completed = true + part.failed = false + part.isMoving = false + releaseReservation(part) + removeActivePart(part) + persist(item, force = true) + checkDownloadItemFinished(item) + } + + private fun checkDownloadItemFinished(item: DownloadItem) { + if (!item.isDownloadFinished) return + scope.launch { + folderScanner.scanDownloadItem(item) { scanResult -> + val event = JSObject().apply { + put("libraryItemId", item.id) + put("localFolderId", item.localFolder.id) + scanResult?.localLibraryItem?.let { put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) } + scanResult?.localMediaProgress?.let { put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) } + } + clientEventEmitter.onDownloadItemComplete(event) + synchronized(this@DownloadItemManager) { + downloadItemQueue.remove(item) + DeviceManager.dbManager.removeDownloadItem(item.id) + notifyQueueChanged() + } + } + } + } + + private fun tryReserve(part: DownloadItemPart): Boolean { + // Covers from older servers often omit a size. Keep unknown-length work serial and use the + // runtime low-space guard rather than leaving those queue items permanently deferred. + if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false + val staging = File(part.destinationPath) + staging.parentFile?.mkdirs() + 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 required = if (part.isInternalStorage) remaining else remaining + expectedSize + val key = storageKey(staging) + val fs = statFsFor(staging) + val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L) + val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum() + if (fs.availableBytes - alreadyReserved < required + headroom) return false + reservations[part.destinationPath] = required + return true + } + + private fun hasAvailableSpace(part: DownloadItemPart): Boolean { + val staging = File(part.destinationPath) + val fs = statFsFor(staging) + return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L) + } + + private fun statFsFor(staging: File): StatFs { + var directory = staging.parentFile ?: context.filesDir + directory.mkdirs() + while (!directory.exists()) directory = directory.parentFile ?: context.filesDir + return StatFs(directory.absolutePath) + } + + private fun storageKey(file: File): String = + if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal" else "external" + + private fun releaseReservation(part: DownloadItemPart) { reservations.remove(part.destinationPath) } + + @Synchronized + private fun removeActivePart(part: DownloadItemPart) { + activeCalls.remove(part.id) + currentDownloadItemParts.remove(part) + } + + private fun persist(item: DownloadItem, force: Boolean = false) { + val now = System.currentTimeMillis() + if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return + lastPersistTime[item.id] = now + DeviceManager.dbManager.saveDownloadItem(item) + } + + private fun notifyQueueChanged() { clientEventEmitter.onQueueChanged(downloadItemQueue.isNotEmpty()) } + + fun destroy() { + activeCalls.values.forEach(Call::cancel) + activeCalls.clear() + scope.cancel() + } + private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? { var current = root relativePath.split('/').filter { it.isNotBlank() }.forEach { segment -> @@ -211,62 +424,30 @@ class DownloadItemManager( return current } - private fun mimeTypeFor(part: DownloadItemPart): String { - return part.audioTrack?.mimeType - ?: when (part.ebookFile?.ebookFormat?.lowercase()) { - "epub" -> "application/epub+zip" - "pdf" -> "application/pdf" - else -> "image/jpeg" - } - } + private fun mimeTypeFor(part: DownloadItemPart): String = + part.audioTrack?.mimeType ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" + } - private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { - Log.e(tag, message) - part.failed = true - part.isMoving = false - part.completed = true - removeActivePart(part) - } - - @Synchronized - private fun removeActivePart(part: DownloadItemPart) { - activeCalls.remove(part.id) - currentDownloadItemParts.remove(part) - } - - private fun checkDownloadItemFinished(downloadItem: DownloadItem) { - if (!downloadItem.isDownloadFinished) return - scope.launch { - folderScanner.scanDownloadItem(downloadItem) { scanResult -> - val event = - JSObject().apply { - put("libraryItemId", downloadItem.id) - put("localFolderId", downloadItem.localFolder.id) - scanResult?.localLibraryItem?.let { - put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) - } - scanResult?.localMediaProgress?.let { - put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) - } - } - clientEventEmitter.onDownloadItemComplete(event) - synchronized(this@DownloadItemManager) { - downloadItemQueue.remove(downloadItem) - DeviceManager.dbManager.removeDownloadItem(downloadItem.id) - } - } - } - } - - fun destroy() { - activeCalls.values.forEach(Call::cancel) - activeCalls.clear() - scope.cancel() + private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String { + val token = DeviceManager.deviceData.serverConnectionConfigs + .find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token + var url = "${item.serverAddress}${part.serverPath}?token=$token" + if (part.serverPath.endsWith("/cover")) url += "&raw=1" + return url } private companion object { const val APP_MANAGED_DOWNLOAD_ID = -1L - const val WATCH_INTERVAL_MS = 500L + const val MAX_SIMULTANEOUS_DOWNLOADS = 3 + const val WATCH_INTERVAL_MS = 1_000L const val STALL_TIMEOUT_MS = 60_000L + const val RETRY_BASE_DELAY_MS = 5_000L + const val MAX_RETRIES = 5 + const val PERSIST_INTERVAL_MS = 2_000L + const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L + const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index 128d41a6..2854fc2a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -15,16 +15,10 @@ import okhttp3.Response class InternalDownloadManager( private val destinationFile: File, private val expectedSize: Long, - private val progressCallback: DownloadItemManager.InternalProgressCallback + private val progressCallback: DownloadItemManager.InternalProgressCallback, + private val hasAvailableSpace: () -> Boolean ) { private val tag = "InternalDownloadManager" - private val client = - OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() - /** * Returns the active call so the queue can cancel a stalled transfer. A partial staging file is * retained only when the server proves that it honoured a subsequent range request. @@ -51,6 +45,11 @@ class InternalDownloadManager( override fun onResponse(call: Call, response: Response) { response.use { try { + if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize) { + progressCallback.onProgress(existingBytes, 100L) + progressCallback.onComplete(false) + return + } val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes) if (existingBytes > 0L && !append && response.code != 200) { Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes") @@ -77,6 +76,7 @@ class InternalDownloadManager( while (true) { val read = input.read(buffer) if (read < 0) break + if (!hasAvailableSpace()) throw IOException("Download paused to preserve free storage") output.write(buffer, 0, read) totalBytes += read val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L @@ -104,10 +104,19 @@ class InternalDownloadManager( private fun hasExpectedRange(response: Response, offset: Long): Boolean { val range = response.header("Content-Range") ?: return false - return range.startsWith("bytes $offset-") + val match = CONTENT_RANGE.matchEntire(range) ?: return false + return match.groupValues[1].toLongOrNull() == offset && + match.groupValues[2].toLongOrNull()?.let { it >= offset } == true } private companion object { const val CHUNK_SIZE = 8 * 1024 + val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)") + val client = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt index 95ec320b..e23ca2d9 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -33,7 +33,7 @@ data class DownloadItem( if (limit == 0) return itemParts for (it in downloadItemParts) { - if (!it.completed && it.downloadId == null) { + if (!it.completed && !it.failed && it.downloadId == null) { itemParts.add(it) if (itemParts.size >= limit) break } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index a60a24cd..0a6081ae 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -16,7 +16,7 @@ data class DownloadItemPart( val filename: String, val fileSize: Long, /** App-owned staging location. This is intentionally a String so it survives process storage. */ - val destinationPath: String, + @JsonIgnore val destinationPath: String, val finalDestinationPath:String, val serverPath: String, val localFolderName: String, @@ -33,12 +33,16 @@ data class DownloadItemPart( @JsonIgnore val destinationUri: Uri, @JsonIgnore val finalDestinationUri: Uri, /** Final SAF document returned by the provider after a successful move. */ + /** Persisted Android-only SAF URI used to reopen a completed document after process recovery. */ @JsonIgnore var completedDestinationUri: String?, val finalDestinationSubfolder: String, var downloadId: Long?, - var lastUpdateTime: Long?, + @JsonIgnore var lastUpdateTime: Long?, var progress: Long, - var bytesDownloaded: Long + var bytesDownloaded: Long, + /** Android queue state; hidden from the shared Capacitor download-part payload. */ + @JsonIgnore var retryCount: Int = 0, + @JsonIgnore var waitingForSpace: Boolean = false ) { companion object { fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { @@ -87,6 +91,11 @@ data class DownloadItemPart( val isInternalStorage get() = localFolderId.startsWith("internal-") @get:JsonIgnore - val serverUrl get() = uri.toString() + val serverUrl: String + get() { + var url = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}" + if (serverPath.endsWith("/cover")) url += "&raw=1" + return url + } } 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 3dde53d2..94f65bdd 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 @@ -10,6 +10,7 @@ import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItemPart import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.services.DownloadServiceHost import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject @@ -39,20 +40,36 @@ class AbsDownloader : Plugin() { override fun onDownloadItemComplete(jsobj:JSObject) { notifyListeners("onItemDownloadComplete", jsobj) } + override fun onQueueChanged(hasWork: Boolean) = Unit }) override fun load() { mainActivity = (activity as MainActivity) folderScanner = FolderScanner(mainActivity) apiHandler = ApiHandler(mainActivity) - downloadItemManager = DownloadItemManager(folderScanner, mainActivity, clientEventEmitter) + downloadItemManager = DownloadServiceHost.ensure(mainActivity) + DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter) } override fun handleOnDestroy() { - if (::downloadItemManager.isInitialized) downloadItemManager.destroy() + DownloadServiceHost.detachBridge() super.handleOnDestroy() } + /** + * Queue restoration happens before the WebView mounts. Replay its parent items when Vue registers + * the listener so subsequent part updates always have a matching store entry. + */ + @PluginMethod(returnType = PluginMethod.RETURN_NONE) + override fun addListener(call: PluginCall) { + super.addListener(call) + if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) { + downloadItemManager.downloadItemQueue.forEach { item -> + notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item))) + } + } + } + @PluginMethod fun downloadLibraryItem(call: PluginCall) { val libraryItemId = call.data.getString("libraryItemId").toString() @@ -163,11 +180,6 @@ class AbsDownloader : Plugin() { val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename.part") - if (finalDestinationFile.exists()) { - Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } - val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null) downloadItem.downloadItemParts.add(downloadItemPart) } @@ -187,11 +199,6 @@ class AbsDownloader : Plugin() { val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename.part") - if (finalDestinationFile.exists()) { - Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } - val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null) downloadItem.downloadItemParts.add(downloadItemPart) } @@ -207,16 +214,11 @@ class AbsDownloader : Plugin() { val destinationFile = File("$tempFolderPath/$destinationFilename.part") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") - if (finalDestinationFile.exists()) { - Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } - val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null) downloadItem.downloadItemParts.add(downloadItemPart) } - downloadItemManager.addDownloadItem(downloadItem) + DownloadServiceHost.enqueue(mainActivity, downloadItem) } } else { // Podcast episode download @@ -237,11 +239,6 @@ class AbsDownloader : Plugin() { var destinationFile = File("$tempFolderPath/$destinationFilename.part") var finalDestinationFile = File("$itemFolderPath/$destinationFilename") - if (finalDestinationFile.exists()) { - Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } - var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode) downloadItem.downloadItemParts.add(downloadItemPart) @@ -255,15 +252,11 @@ class AbsDownloader : Plugin() { destinationFile = File("$tempFolderPath/$destinationFilename.part") finalDestinationFile = File("$itemFolderPath/$destinationFilename") - if (finalDestinationFile.exists()) { - Log.d(tag, "Podcast cover already exists - not downloading cover again") - } else { - downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null) - downloadItem.downloadItemParts.add(downloadItemPart) - } + downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) } - downloadItemManager.addDownloadItem(downloadItem) + DownloadServiceHost.enqueue(mainActivity, downloadItem) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt new file mode 100644 index 00000000..ebd938d0 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -0,0 +1,88 @@ +package com.audiobookshelf.app.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.audiobookshelf.app.R +import com.audiobookshelf.app.models.DownloadItemPart + +/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */ +class DownloadService : Service() { + private var lastPart: DownloadItemPart? = null + + override fun onCreate() { + super.onCreate() + createChannel() + startForeground(NOTIFICATION_ID, notification("Preparing downloads")) + DownloadServiceHost.attachService(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) + ACTION_RETRY -> DownloadServiceHost.retryAll(this) + else -> DownloadServiceHost.ensure(this) + } + return START_STICKY + } + + override fun onDestroy() { + DownloadServiceHost.detachService(this) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + fun onPartUpdate(part: DownloadItemPart) { + lastPart = part + val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}" + val progress = part.progress.coerceIn(0L, 100L).toInt() + val notification = notification(text, progress, part.fileSize > 0L) + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification) + } + + fun onQueueChanged(hasWork: Boolean) { + if (!hasWork) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification { + val cancelIntent = PendingIntent.getService( + 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) + .setSmallIcon(R.drawable.icon) + .setContentTitle("Audiobookshelf downloads") + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setProgress(100, progress, !determinate) + .addAction(0, "Cancel", cancelIntent) + .addAction(0, "Retry", retryIntent) + .build() + } + + private fun createChannel() { + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_LOW)) + } + + private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + companion object { + private const val CHANNEL_ID = "downloads" + private const val NOTIFICATION_ID = 4102 + 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) + } +} 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 new file mode 100644 index 00000000..ce4882f7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -0,0 +1,104 @@ +package com.audiobookshelf.app.services + +import android.content.Context +import androidx.core.content.ContextCompat +import com.audiobookshelf.app.device.FolderScanner +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.models.DownloadItem +import com.getcapacitor.JSObject +import java.util.Collections + +/** Shared process owner used by the foreground service and the Capacitor bridge. */ +object DownloadServiceHost { + private var manager: DownloadItemManager? = null + private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter + private var service: DownloadService? = null + @Volatile private var bridgeReady = false + private val deferredCompletions = Collections.synchronizedList(mutableListOf()) + + @Synchronized + fun ensure(context: Context): DownloadItemManager { + if (manager == null) { + val appContext = context.applicationContext + DbManager.initialize(appContext) + manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter) + manager!!.restoreQueue() + } + return manager!! + } + + @Synchronized + fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) { + // Rehydrate the frontend's parent items before allowing part-progress events through. + // Otherwise a running restored queue can emit a part before Vue knows its DownloadItem. + bridgeReady = false + bridgeEmitter = emitter + val queue = ensure(context) + queue.setEventEmitter(ForwardingEmitter) + bridgeReady = true + val completions = synchronized(deferredCompletions) { + deferredCompletions.toList().also { deferredCompletions.clear() } + } + completions.forEach(bridgeEmitter::onDownloadItemComplete) + if (queue.hasWork()) startService(context) + } + + @Synchronized + fun detachBridge() { + bridgeReady = false + bridgeEmitter = NoopEmitter + } + + @Synchronized + fun enqueue(context: Context, item: DownloadItem) { + ensure(context).addDownloadItem(item) + startService(context) + } + + @Synchronized + fun retryAll(context: Context) { + startService(context) + ensure(context).retryAll() + } + + @Synchronized + fun cancelAll(context: Context) { ensure(context).cancelAll() } + + @Synchronized + fun attachService(downloadService: DownloadService) { + service = downloadService + service?.onQueueChanged(ensure(downloadService).hasWork()) + } + + @Synchronized + fun detachService(downloadService: DownloadService) { + if (service === downloadService) service = null + } + + private fun startService(context: Context) { + ContextCompat.startForegroundService(context, DownloadService.intent(context)) + } + + private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) } + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) { + if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart) + service?.onPartUpdate(downloadItemPart) + } + override fun onDownloadItemComplete(jsobj: JSObject) { + if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj) + } + override fun onQueueChanged(hasWork: Boolean) { + bridgeEmitter.onQueueChanged(hasWork) + service?.onQueueChanged(hasWork) + } + } + + private object NoopEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) = Unit + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit + override fun onDownloadItemComplete(jsobj: JSObject) = Unit + override fun onQueueChanged(hasWork: Boolean) = Unit + } +} From 0df0a206e7850eb82dd63cbb6f41d093195dda5f Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 19 Jul 2026 09:16:35 -0700 Subject: [PATCH 03/17] Delete partial files after 24 hours --- android/app/build.gradle | 1 + .../app/managers/DownloadItemManager.kt | 7 ++ .../app/managers/IncompleteDownloadCleanup.kt | 98 +++++++++++++++++++ .../audiobookshelf/app/models/DownloadItem.kt | 37 +++---- 4 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index c9f7acf4..979655cc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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" diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 88ce1737..4698a936 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -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 } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt new file mode 100644 index 00000000..7d517a8b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -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() + .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 { + 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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt index e23ca2d9..af1657b7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -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 + 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, + @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 { + fun getNextDownloadItemParts(limit: Int): MutableList { val itemParts = mutableListOf() if (limit == 0) return itemParts From d27f431f6f3488e6bbb9891f0ca1674211e53211 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 19 Jul 2026 09:23:31 -0700 Subject: [PATCH 04/17] Comment cleanup --- .../app/data/LocalLibraryItem.kt | 1 - .../app/device/FolderScanner.kt | 30 ++++++++++++------- .../audiobookshelf/app/managers/DbManager.kt | 6 ++-- .../app/managers/DownloadItemManager.kt | 8 +---- .../app/managers/IncompleteDownloadCleanup.kt | 6 ++-- .../app/managers/InternalDownloadManager.kt | 6 ++-- .../app/models/DownloadItemPart.kt | 4 --- .../app/plugins/AbsDownloader.kt | 10 +------ .../app/services/DownloadService.kt | 3 -- .../app/services/DownloadServiceHost.kt | 3 +- 10 files changed, 32 insertions(+), 45 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index 9251e7d9..8fbe9ac3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -98,7 +98,6 @@ class LocalLibraryItem( return true } - /** App-private files have paths; SAF files must be validated through their persisted URI. */ @JsonIgnore private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean { return LocalFile("", null, contentUrl ?: "", "", path, "", null, 0).exists(ctx) diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt index c53b9b8b..69d45512 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -10,7 +10,7 @@ import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItemPart import java.io.File -/** Creates local-library records from the completed download manifest, not a recursive rescan. */ +/** Creates local-library records from a completed download manifest. */ class FolderScanner(private val ctx: Context) { private val tag = "FolderScanner" @@ -19,9 +19,13 @@ class FolderScanner(private val ctx: Context) { var localMediaProgress: LocalMediaProgress? ) - private fun localLibraryItemId(mediaItemId: String) = "local_${DeviceManager.getBase64Id(mediaItemId)}" + private fun localLibraryItemId(mediaItemId: String) = + "local_${DeviceManager.getBase64Id(mediaItemId)}" - private fun createLocalFile(part: DownloadItemPart, externalFile: DocumentFile? = null): LocalFile? { + private fun createLocalFile( + part: DownloadItemPart, + externalFile: DocumentFile? = null + ): LocalFile? { if (part.isInternalStorage) { val file = File(part.finalDestinationPath) if (!file.exists()) return null @@ -43,7 +47,8 @@ class FolderScanner(private val ctx: Context) { try { ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> descriptor.statSize.coerceAtLeast(0L) - } ?: 0L + } + ?: 0L } catch (e: Exception) { Log.e(tag, "Could not open completed SAF file: $contentUrl", e) return null @@ -123,8 +128,7 @@ class FolderScanner(private val ctx: Context) { if (part.isInternalStorage) { null } else { - part.completedDestinationUri - ?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) } + part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) } ?: resolveExternalFile(externalFolder, part) } Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}") @@ -203,7 +207,9 @@ class FolderScanner(private val ctx: Context) { val result = DownloadItemScanResult(localItem, null) item.userMediaProgress?.let { progress -> - val progressId = if (item.episodeId.isNullOrEmpty()) localItem.id else "${localItem.id}-$localEpisodeId" + val progressId = + if (item.episodeId.isNullOrEmpty()) localItem.id + else "${localItem.id}-$localEpisodeId" result.localMediaProgress = LocalMediaProgress( progressId, @@ -247,14 +253,18 @@ class FolderScanner(private val ctx: Context) { */ private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? { if (folder == null) return null - folder.findFile(part.filename)?.let { return it } + folder.findFile(part.filename)?.let { + return it + } val expectedBaseName = part.filename.substringBeforeLast('.') return folder.listFiles().firstOrNull { document -> document.name == part.filename || document.fullName == part.filename || - (part.audioTrack != null && document.isFile && + (part.audioTrack != null && + document.isFile && (document.name ?: "").substringBeforeLast('.') == expectedBaseName) || - (part.audioTrack != null && document.isFile && + (part.audioTrack != null && + document.isFile && document.fullName.substringBeforeLast('.') == expectedBaseName) } } 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 d02f2d3c..2f4c4572 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 @@ -123,9 +123,9 @@ class DbManager { } /** - * The downloader now persists app-owned staging paths instead of DownloadManager state. Old - * queue entries cannot be resumed safely, but completed local media lives in other books and is - * deliberately left alone. + * Removes DownloadManager queue entries that cannot be resumed by the app-managed downloader. + * + * This migration preserves completed local media, which is stored in separate books. */ fun clearLegacyDownloadQueueOnce() { val metadata = Paper.book("downloadQueueMetadata") diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 4698a936..532c3bd8 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -24,10 +24,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import okhttp3.Call -/** - * Process-owned Android download queue. Every network write goes through app-owned staging and is - * admitted only after reserving enough space for the complete operation. - */ +/** Manages the process-owned queue for app-managed downloads. */ class DownloadItemManager( private val folderScanner: FolderScanner, private val context: Context, @@ -36,7 +33,6 @@ class DownloadItemManager( private val tag = "DownloadItemManager" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val activeCalls = ConcurrentHashMap() - /** DocumentsProvider does not make concurrent createDirectory/findFile calls atomic. */ private val safFolderLocks = ConcurrentHashMap() private val reservations = mutableMapOf() private val lastPersistTime = mutableMapOf() @@ -366,8 +362,6 @@ class DownloadItemManager( } private fun tryReserve(part: DownloadItemPart): Boolean { - // Covers from older servers often omit a size. Keep unknown-length work serial and use the - // runtime low-space guard rather than leaving those queue items permanently deferred. if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false val staging = File(part.destinationPath) staging.parentFile?.mkdirs() diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt index 7d517a8b..82e1716a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -14,7 +14,7 @@ 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. */ +/** Removes terminally failed downloads after their retention window elapses. */ object IncompleteDownloadCleanup { private const val tag = "IncompleteDownloadCleanup" private const val RETENTION_MS = 24L * 60L * 60L * 1000L @@ -34,7 +34,7 @@ object IncompleteDownloadCleanup { WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId) } - /** Called on app/service startup as a catch-up for work delayed by Android or force-stop. */ + /** Removes failures retained longer than 24 hours when scheduled work did not run. */ fun cleanupExpired(context: Context): Set { val now = System.currentTimeMillis() return DeviceManager.dbManager.getDownloadItems() @@ -49,7 +49,6 @@ object IncompleteDownloadCleanup { 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) } @@ -61,7 +60,6 @@ object IncompleteDownloadCleanup { 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() diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index 2854fc2a..63e33828 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -20,8 +20,10 @@ class InternalDownloadManager( ) { private val tag = "InternalDownloadManager" /** - * Returns the active call so the queue can cancel a stalled transfer. A partial staging file is - * retained only when the server proves that it honoured a subsequent range request. + * Starts or resumes a download. + * + * @param url authenticated download URL + * @return active call, used to cancel a stalled transfer */ fun download(url: String): Call { destinationFile.parentFile?.mkdirs() diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index 0a6081ae..31d887c2 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -15,7 +15,6 @@ data class DownloadItemPart( val downloadItemId: String, val filename: String, val fileSize: Long, - /** App-owned staging location. This is intentionally a String so it survives process storage. */ @JsonIgnore val destinationPath: String, val finalDestinationPath:String, val serverPath: String, @@ -32,15 +31,12 @@ data class DownloadItemPart( @JsonIgnore val uri: Uri, @JsonIgnore val destinationUri: Uri, @JsonIgnore val finalDestinationUri: Uri, - /** Final SAF document returned by the provider after a successful move. */ - /** Persisted Android-only SAF URI used to reopen a completed document after process recovery. */ @JsonIgnore var completedDestinationUri: String?, val finalDestinationSubfolder: String, var downloadId: Long?, @JsonIgnore var lastUpdateTime: Long?, var progress: Long, var bytesDownloaded: Long, - /** Android queue state; hidden from the shared Capacitor download-part payload. */ @JsonIgnore var retryCount: Int = 0, @JsonIgnore var waitingForSpace: Boolean = false ) { 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 94f65bdd..68cfdd67 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 @@ -5,7 +5,6 @@ import android.util.Log import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager -import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItemPart import com.audiobookshelf.app.server.ApiHandler @@ -27,7 +26,6 @@ class AbsDownloader : Plugin() { lateinit var mainActivity: MainActivity lateinit var apiHandler: ApiHandler - lateinit var folderScanner: FolderScanner lateinit var downloadItemManager: DownloadItemManager private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter { @@ -45,7 +43,6 @@ class AbsDownloader : Plugin() { override fun load() { mainActivity = (activity as MainActivity) - folderScanner = FolderScanner(mainActivity) apiHandler = ApiHandler(mainActivity) downloadItemManager = DownloadServiceHost.ensure(mainActivity) DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter) @@ -56,10 +53,7 @@ class AbsDownloader : Plugin() { super.handleOnDestroy() } - /** - * Queue restoration happens before the WebView mounts. Replay its parent items when Vue registers - * the listener so subsequent part updates always have a matching store entry. - */ + /** Replays restored queue items when the frontend subscribes to download events. */ @PluginMethod(returnType = PluginMethod.RETURN_NONE) override fun addListener(call: PluginCall) { super.addListener(call) @@ -151,8 +145,6 @@ class AbsDownloader : Plugin() { val isInternal = localFolder.id.startsWith("internal-") val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}" - // Keep internal staging on the same filesystem as its final file so finalization is a rename. - // External-folder downloads can use app external storage and are moved through SAF afterwards. val tempFolderPath = if (isInternal) { "${mainActivity.filesDir}/download-staging/${libraryItem.id}" diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt index ebd938d0..04363bee 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -14,8 +14,6 @@ import com.audiobookshelf.app.models.DownloadItemPart /** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */ class DownloadService : Service() { - private var lastPart: DownloadItemPart? = null - override fun onCreate() { super.onCreate() createChannel() @@ -40,7 +38,6 @@ class DownloadService : Service() { override fun onBind(intent: Intent?): IBinder? = null fun onPartUpdate(part: DownloadItemPart) { - lastPart = part val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}" val progress = part.progress.coerceIn(0L, 100L).toInt() val notification = notification(text, progress, part.fileSize > 0L) 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 ce4882f7..ef2839dd 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 @@ -28,10 +28,9 @@ object DownloadServiceHost { return manager!! } + /** Attaches the frontend after restored queue items have been emitted. */ @Synchronized fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) { - // Rehydrate the frontend's parent items before allowing part-progress events through. - // Otherwise a running restored queue can emit a part before Vue knows its DownloadItem. bridgeReady = false bridgeEmitter = emitter val queue = ensure(context) From 8b20730b6fb1a3a969d553c37a3d7cbb10beb5bd Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 19 Jul 2026 10:24:01 -0700 Subject: [PATCH 05/17] Cleaning up retry logic and unused return types --- .../app/managers/DownloadItemManager.kt | 63 +++++++++++-------- .../app/managers/IncompleteDownloadCleanup.kt | 8 +-- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 532c3bd8..58ba8269 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -98,7 +98,15 @@ class DownloadItemManager( @Synchronized fun addDownloadItem(downloadItem: DownloadItem) { - if (downloadItemQueue.any { it.id == downloadItem.id }) return + val existingItem = downloadItemQueue.find { it.id == downloadItem.id } + if (existingItem != null) { + if (existingItem.terminalFailureAt != null) { + retryDownloadItem(existingItem) + checkUpdateDownloadQueue() + notifyQueueChanged() + } + return + } persist(downloadItem, force = true) downloadItemQueue.add(downloadItem) clientEventEmitter.onDownloadItem(downloadItem) @@ -108,22 +116,24 @@ 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 - part.isMoving = false - part.downloadId = null - part.retryCount = 0 - } - persist(item, force = true) - } + downloadItemQueue.forEach(::retryDownloadItem) checkUpdateDownloadQueue() notifyQueueChanged() } + private fun retryDownloadItem(item: DownloadItem) { + item.terminalFailureAt = null + IncompleteDownloadCleanup.cancel(context, item.id) + item.downloadItemParts.filter { it.failed }.forEach { part -> + part.failed = false + part.completed = false + part.isMoving = false + part.downloadId = null + part.retryCount = 0 + } + persist(item, force = true) + } + @Synchronized fun cancelAll() { activeCalls.values.forEach(Call::cancel) @@ -139,7 +149,12 @@ class DownloadItemManager( } @Synchronized - fun hasWork(): Boolean = downloadItemQueue.isNotEmpty() + fun hasWork(): Boolean = + downloadItemQueue.any { item -> + item.downloadItemParts.any { part -> + (!part.completed && !part.failed) || part.isMoving + } + } @Synchronized private fun checkUpdateDownloadQueue() { @@ -156,7 +171,7 @@ class DownloadItemManager( } } } - startWatchingDownloads() + if (hasWork()) startWatchingDownloads() else notifyQueueChanged() } private fun startDownload(item: DownloadItem, part: DownloadItemPart) { @@ -202,7 +217,7 @@ class DownloadItemManager( activeParts.forEach(::handlePartUpdate) synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() - if (downloadItemQueue.isEmpty()) { + if (!hasWork()) { watcherRunning = false notifyQueueChanged() return@launch @@ -235,10 +250,11 @@ class DownloadItemManager( if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part) } + @Synchronized private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) { removeActivePart(part) part.retryCount += 1 - releaseReservation(part) + reservations.remove(part.destinationPath) if (part.retryCount > MAX_RETRIES) { Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}") part.failed = true @@ -255,10 +271,6 @@ class DownloadItemManager( part.downloadId = null part.isMoving = false persist(item, force = true) - scope.launch { - delay(RETRY_BASE_DELAY_MS * (1L shl (part.retryCount - 1))) - synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() } - } } private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) { @@ -323,6 +335,7 @@ class DownloadItemManager( } } + @Synchronized private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { Log.e(tag, message) part.isMoving = false @@ -330,12 +343,13 @@ class DownloadItemManager( failOrRetry(item, part, message) } + @Synchronized private fun completePart(item: DownloadItem, part: DownloadItemPart) { part.moved = true part.completed = true part.failed = false part.isMoving = false - releaseReservation(part) + reservations.remove(part.destinationPath) removeActivePart(part) persist(item, force = true) checkDownloadItemFinished(item) @@ -393,8 +407,6 @@ class DownloadItemManager( private fun storageKey(file: File): String = if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal" else "external" - private fun releaseReservation(part: DownloadItemPart) { reservations.remove(part.destinationPath) } - @Synchronized private fun removeActivePart(part: DownloadItemPart) { activeCalls.remove(part.id) @@ -408,7 +420,7 @@ class DownloadItemManager( DeviceManager.dbManager.saveDownloadItem(item) } - private fun notifyQueueChanged() { clientEventEmitter.onQueueChanged(downloadItemQueue.isNotEmpty()) } + private fun notifyQueueChanged() { clientEventEmitter.onQueueChanged(hasWork()) } fun destroy() { activeCalls.values.forEach(Call::cancel) @@ -445,7 +457,6 @@ class DownloadItemManager( const val MAX_SIMULTANEOUS_DOWNLOADS = 3 const val WATCH_INTERVAL_MS = 1_000L const val STALL_TIMEOUT_MS = 60_000L - const val RETRY_BASE_DELAY_MS = 5_000L const val MAX_RETRIES = 5 const val PERSIST_INTERVAL_MS = 2_000L const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt index 82e1716a..4fd89085 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -35,15 +35,13 @@ object IncompleteDownloadCleanup { } /** Removes failures retained longer than 24 hours when scheduled work did not run. */ - fun cleanupExpired(context: Context): Set { + fun cleanupExpired(context: Context) { val now = System.currentTimeMillis() - return DeviceManager.dbManager.getDownloadItems() + DeviceManager.dbManager.getDownloadItems() .filter { item -> isEligible(item, now) } - .map { item -> + .forEach { item -> deleteItem(context, item) - item.id } - .toSet() } private fun isEligible(item: DownloadItem, now: Long): Boolean { From 8142d626c065b289fb900f404f17c225466be16e Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 19 Jul 2026 10:24:12 -0700 Subject: [PATCH 06/17] Ensure token is not part of download URL --- .../app/managers/DownloadItemManager.kt | 14 ++++++++------ .../app/managers/InternalDownloadManager.kt | 6 ++++-- .../app/models/DownloadItemPart.kt | 16 ++-------------- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 58ba8269..258b9d62 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -182,6 +182,9 @@ class DownloadItemManager( part.lastUpdateTime = System.currentTimeMillis() currentDownloadItemParts.add(part) persist(item, force = true) + val token = + DeviceManager.deviceData.serverConnectionConfigs + .find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token activeCalls[part.id] = InternalDownloadManager(stagingFile, part.fileSize, object : InternalProgressCallback { override fun onProgress(totalBytesWritten: Long, progress: Long) { @@ -204,7 +207,9 @@ class DownloadItemManager( persist(item, force = true) } } - }, { hasAvailableSpace(part) }).download(serverUrl(item, part)) + }, { hasAvailableSpace(part) }).download( + serverUrl(item, part), + token) } @Synchronized @@ -445,11 +450,8 @@ class DownloadItemManager( } private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String { - val token = DeviceManager.deviceData.serverConnectionConfigs - .find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token - var url = "${item.serverAddress}${part.serverPath}?token=$token" - if (part.serverPath.endsWith("/cover")) url += "&raw=1" - return url + val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else "" + return "${item.serverAddress}${part.serverPath}$rawCover" } private companion object { diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index 63e33828..7c23e6cc 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -22,16 +22,18 @@ class InternalDownloadManager( /** * Starts or resumes a download. * - * @param url authenticated download URL + * @param url download URL + * @param token access token sent in the Authorization header * @return active call, used to cancel a stalled transfer */ - fun download(url: String): Call { + fun download(url: String, token: String): Call { destinationFile.parentFile?.mkdirs() val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L val request = Request.Builder() .url(url) .addHeader("Accept-Encoding", "identity") + .addHeader("Authorization", "Bearer $token") .apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index 31d887c2..7dadbca8 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -44,13 +44,9 @@ data class DownloadItemPart( fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { val destinationUri = Uri.fromFile(destinationFile) val finalDestinationUri = Uri.fromFile(finalDestinationFile) + val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else "" + val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover") - var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}" - if (serverPath.endsWith("/cover")) { - downloadUrl += "&raw=1" // Download raw cover image - } - - val downloadUri = Uri.parse(downloadUrl) Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath") return DownloadItemPart( id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath), @@ -86,12 +82,4 @@ data class DownloadItemPart( @get:JsonIgnore val isInternalStorage get() = localFolderId.startsWith("internal-") - @get:JsonIgnore - val serverUrl: String - get() { - var url = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}" - if (serverPath.endsWith("/cover")) url += "&raw=1" - return url - } - } From 034a134319bbe12ee40152971adab1bfb7bb0827 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 19 Jul 2026 17:54:46 -0700 Subject: [PATCH 07/17] Remove retry stubs --- .../app/managers/DownloadItemManager.kt | 157 ++++++++++-------- .../app/services/DownloadService.kt | 5 - .../app/services/DownloadServiceHost.kt | 6 - 3 files changed, 90 insertions(+), 78 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 258b9d62..56035611 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -38,7 +38,8 @@ class DownloadItemManager( private val lastPersistTime = mutableMapOf() private var watcherRunning = false private val jacksonMapper = - jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) var downloadItemQueue: MutableList = mutableListOf() private set @@ -114,13 +115,6 @@ class DownloadItemManager( notifyQueueChanged() } - @Synchronized - fun retryAll() { - downloadItemQueue.forEach(::retryDownloadItem) - checkUpdateDownloadQueue() - notifyQueueChanged() - } - private fun retryDownloadItem(item: DownloadItem) { item.terminalFailureAt = null IncompleteDownloadCleanup.cancel(context, item.id) @@ -182,34 +176,41 @@ class DownloadItemManager( part.lastUpdateTime = System.currentTimeMillis() currentDownloadItemParts.add(part) persist(item, force = true) + val activeConfig = DeviceManager.serverConnectionConfig val token = - DeviceManager.deviceData.serverConnectionConfigs - .find { it.id == item.serverConnectionConfigId }?.token ?: DeviceManager.token + if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token + else + DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token + ?: DeviceManager.token activeCalls[part.id] = - InternalDownloadManager(stagingFile, part.fileSize, object : InternalProgressCallback { - override fun onProgress(totalBytesWritten: Long, progress: Long) { - synchronized(this@DownloadItemManager) { - if (part !in currentDownloadItemParts) return - part.bytesDownloaded = totalBytesWritten - part.progress = progress - part.lastUpdateTime = System.currentTimeMillis() - persist(item) - } - } + InternalDownloadManager( + stagingFile, + part.fileSize, + object : InternalProgressCallback { + override fun onProgress(totalBytesWritten: Long, progress: Long) { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + part.bytesDownloaded = totalBytesWritten + part.progress = progress + part.lastUpdateTime = System.currentTimeMillis() + persist(item) + } + } - override fun onComplete(failed: Boolean) { - synchronized(this@DownloadItemManager) { - if (part !in currentDownloadItemParts) return - part.failed = failed - part.completed = !failed - part.lastUpdateTime = System.currentTimeMillis() - activeCalls.remove(part.id) - persist(item, force = true) - } - } - }, { hasAvailableSpace(part) }).download( - serverUrl(item, part), - token) + override fun onComplete(failed: Boolean) { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + part.failed = failed + part.completed = !failed + part.lastUpdateTime = System.currentTimeMillis() + activeCalls.remove(part.id) + persist(item, force = true) + } + } + }, + { hasAvailableSpace(part) } + ) + .download(serverUrl(item, part), token) } @Synchronized @@ -218,7 +219,8 @@ class DownloadItemManager( watcherRunning = true scope.launch { while (true) { - val activeParts = synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } + val activeParts = + synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } activeParts.forEach(::handlePartUpdate) synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() @@ -235,10 +237,12 @@ class DownloadItemManager( private fun handlePartUpdate(part: DownloadItemPart) { clientEventEmitter.onDownloadItemPartUpdate(part) - val item = synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } ?: run { - removeActivePart(part) - return - } + val item = + synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } + ?: run { + removeActivePart(part) + return + } if (!part.completed && !part.failed) { val lastUpdate = part.lastUpdateTime ?: return if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { @@ -287,7 +291,8 @@ class DownloadItemManager( val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup") try { 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 (backup.exists()) backup.renameTo(finalFile) throw IllegalStateException("Could not finalize internal staging file") @@ -303,34 +308,42 @@ class DownloadItemManager( private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) { if (part.moved || part.isMoving) return - val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) - ?: return failFinalization(item, part, "Could not resolve SAF destination") + val root = + DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) + ?: return failFinalization(item, part, "Could not resolve SAF destination") part.isMoving = true persist(item, force = true) scope.launch { 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 folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() } - val folder = synchronized(folderLock) { - getOrCreateFolder(root, part.finalDestinationSubfolder) - } ?: throw IllegalStateException("Could not create SAF destination folder") + val folder = + synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) } + ?: throw IllegalStateException("Could not create SAF destination folder") val temporaryName = ".${part.filename}.${part.id.hashCode()}.part" folder.findFile(temporaryName)?.delete() - val temporary = folder.createFile(mimeTypeFor(part), temporaryName) - ?: throw IllegalStateException("Could not create SAF temporary file") + val temporary = + folder.createFile(mimeTypeFor(part), temporaryName) + ?: throw IllegalStateException("Could not create SAF temporary file") val staging = File(part.destinationPath) FileInputStream(staging).use { input -> context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) } ?: 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) - if (existing != null && !existing.delete()) throw IllegalStateException("Could not replace existing file") - if (!temporary.renameTo(part.filename)) throw IllegalStateException("Could not finalize SAF temporary file") - 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 (existing != null && !existing.delete()) + throw IllegalStateException("Could not replace existing file") + if (!temporary.renameTo(part.filename)) + throw IllegalStateException("Could not finalize SAF temporary file") + 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}") part.completedDestinationUri = destination.uri.toString() completePart(item, part) @@ -364,12 +377,17 @@ class DownloadItemManager( if (!item.isDownloadFinished) return scope.launch { folderScanner.scanDownloadItem(item) { scanResult -> - val event = JSObject().apply { - put("libraryItemId", item.id) - put("localFolderId", item.localFolder.id) - scanResult?.localLibraryItem?.let { put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) } - scanResult?.localMediaProgress?.let { put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) } - } + val event = + JSObject().apply { + put("libraryItemId", item.id) + put("localFolderId", item.localFolder.id) + scanResult?.localLibraryItem?.let { + put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) + } + scanResult?.localMediaProgress?.let { + put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) + } + } clientEventEmitter.onDownloadItemComplete(event) synchronized(this@DownloadItemManager) { downloadItemQueue.remove(item) @@ -385,7 +403,8 @@ class DownloadItemManager( val staging = File(part.destinationPath) staging.parentFile?.mkdirs() 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 key = storageKey(staging) val fs = statFsFor(staging) @@ -410,7 +429,8 @@ class DownloadItemManager( } 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 private fun removeActivePart(part: DownloadItemPart) { @@ -425,7 +445,9 @@ class DownloadItemManager( DeviceManager.dbManager.saveDownloadItem(item) } - private fun notifyQueueChanged() { clientEventEmitter.onQueueChanged(hasWork()) } + private fun notifyQueueChanged() { + clientEventEmitter.onQueueChanged(hasWork()) + } fun destroy() { activeCalls.values.forEach(Call::cancel) @@ -443,11 +465,12 @@ class DownloadItemManager( } private fun mimeTypeFor(part: DownloadItemPart): String = - part.audioTrack?.mimeType ?: when (part.ebookFile?.ebookFormat?.lowercase()) { - "epub" -> "application/epub+zip" - "pdf" -> "application/pdf" - else -> "image/jpeg" - } + part.audioTrack?.mimeType + ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" + } private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String { val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else "" diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt index 04363bee..734041ce 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -24,7 +24,6 @@ class DownloadService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) - ACTION_RETRY -> DownloadServiceHost.retryAll(this) else -> DownloadServiceHost.ensure(this) } return START_STICKY @@ -54,8 +53,6 @@ class DownloadService : Service() { private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification { val cancelIntent = PendingIntent.getService( 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) .setSmallIcon(R.drawable.icon) .setContentTitle("Audiobookshelf downloads") @@ -64,7 +61,6 @@ class DownloadService : Service() { .setOngoing(true) .setProgress(100, progress, !determinate) .addAction(0, "Cancel", cancelIntent) - .addAction(0, "Retry", retryIntent) .build() } @@ -79,7 +75,6 @@ class DownloadService : Service() { private const val CHANNEL_ID = "downloads" private const val NOTIFICATION_ID = 4102 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) } } 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 ef2839dd..d37400fe 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 @@ -55,12 +55,6 @@ object DownloadServiceHost { startService(context) } - @Synchronized - fun retryAll(context: Context) { - startService(context) - ensure(context).retryAll() - } - @Synchronized fun cancelAll(context: Context) { ensure(context).cancelAll() } From b3c3950c55e2f51ef32df0fb9d7eeda0120c3865 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 19 Jul 2026 22:31:32 -0700 Subject: [PATCH 08/17] Check if completed file exists in shared storage before downloading again --- .../app/managers/DownloadItemManager.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 56035611..59bdc7c0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -156,7 +156,15 @@ class DownloadItemManager( val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size if (slots <= 0) return@forEach item.getNextDownloadItemParts(slots).forEach { part -> - if (tryReserve(part)) startDownload(item, part) + val existingFile = findSharedStorageFile(part) + if (existingFile != null) { + part.bytesDownloaded = existingFile.length() + part.progress = 100L + part.completedDestinationUri = existingFile.uri.toString() + File(part.destinationPath).delete() + completePart(item, part) + clientEventEmitter.onDownloadItemPartUpdate(part) + } else if (tryReserve(part)) startDownload(item, part) else { part.waitingForSpace = true part.lastUpdateTime = System.currentTimeMillis() @@ -464,6 +472,21 @@ class DownloadItemManager( return current } + private fun findSharedStorageFile(part: DownloadItemPart): DocumentFile? { + if (part.isInternalStorage) return null + val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) ?: return null + var folder = root + part.finalDestinationSubfolder.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + folder = folder.findFile(segment) ?: return null + } + val file = folder.findFile(part.filename) ?: return null + if (!file.isFile) return null + if (part.fileSize > 0L && file.length() != part.fileSize) return null + if (part.fileSize <= 0L && file.length() <= 0L) return null + return file + } + private fun mimeTypeFor(part: DownloadItemPart): String = part.audioTrack?.mimeType ?: when (part.ebookFile?.ebookFormat?.lowercase()) { From dc6184f41d41dfedf5363bd718d791144c50a46b Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 20 Jul 2026 18:59:19 -0700 Subject: [PATCH 09/17] Remove old and slow simplePath from SAF, also autoformatted --- .../audiobookshelf/app/data/DeviceClasses.kt | 243 ++++++++++-------- .../app/data/LocalLibraryItem.kt | 2 +- .../audiobookshelf/app/data/LocalMediaItem.kt | 1 - .../app/device/FolderScanner.kt | 3 - .../app/plugins/AbsFileSystem.kt | 201 ++++++++------- plugins/capacitor/AbsDatabase.js | 2 - 6 files changed, 247 insertions(+), 205 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 9c242b42..fc9beb58 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -11,53 +11,66 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo import java.io.File enum class LockOrientationSetting { - NONE, PORTRAIT, LANDSCAPE + NONE, + PORTRAIT, + LANDSCAPE } enum class HapticFeedbackSetting { - OFF, LIGHT, MEDIUM, HEAVY + OFF, + LIGHT, + MEDIUM, + HEAVY } enum class ShakeSensitivitySetting { - VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH + VERY_LOW, + LOW, + MEDIUM, + HIGH, + VERY_HIGH } enum class DownloadUsingCellularSetting { - ASK, ALWAYS, NEVER + ASK, + ALWAYS, + NEVER } enum class StreamingUsingCellularSetting { - ASK, ALWAYS, NEVER + ASK, + ALWAYS, + NEVER } enum class AndroidAutoBrowseSeriesSequenceOrderSetting { - ASC, DESC + ASC, + DESC } @JsonIgnoreProperties(ignoreUnknown = true) data class ServerConnectionConfig( - var id:String, - var index:Int, - var name:String, - var address:String, - // version added after 0.9.81-beta - var version:String?, - var userId:String, - var username:String, - var token:String, - var customHeaders:Map? + var id: String, + var index: Int, + var name: String, + var address: String, + // version added after 0.9.81-beta + var version: String?, + var userId: String, + var username: String, + var token: String, + var customHeaders: Map? ) @JsonIgnoreProperties(ignoreUnknown = true) data class LocalFile( - var id:String, - var filename:String?, - var contentUrl:String, - var basePath:String, - var absolutePath:String, - var simplePath:String, - var mimeType:String?, - var size:Long + var id: String, + var filename: String?, + var contentUrl: String, + var basePath: String, + var absolutePath: String, + var mimeType: String?, + var size: Long ) { @JsonIgnore fun exists(ctx: Context): Boolean { @@ -73,17 +86,17 @@ data class LocalFile( } @JsonIgnore - fun isAudioFile():Boolean { + fun isAudioFile(): Boolean { if (mimeType == "application/octet-stream") return true if (mimeType == "video/mp4") return true return mimeType?.startsWith("audio") == true } @JsonIgnore - fun isEBookFile():Boolean { + fun isEBookFile(): Boolean { return getEBookFormat() != null } @JsonIgnore - fun getEBookFormat():String? { + fun getEBookFormat(): String? { if (mimeType == "application/epub+zip") return "epub" if (mimeType == "application/pdf") return "pdf" if (mimeType == "application/x-mobipocket-ebook") return "mobi" @@ -96,118 +109,124 @@ data class LocalFile( @JsonIgnoreProperties(ignoreUnknown = true) data class LocalFolder( - var id:String, - var name:String, - var contentUrl:String, - var basePath:String, - var absolutePath:String, - var simplePath:String, - var storageType:String, - var mediaType:String + var id: String, + var name: String, + var contentUrl: String, + var basePath: String, + var absolutePath: String, + var storageType: String, + var mediaType: String ) -@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION) -@JsonSubTypes( - JsonSubTypes.Type(LibraryItem::class), - JsonSubTypes.Type(LocalLibraryItem::class) -) -open class LibraryItemWrapper(var id:String) { +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class)) +open class LibraryItemWrapper(var id: String) { @JsonIgnore - open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() } + open fun getMediaDescription( + progress: MediaProgressWrapper?, + ctx: Context + ): MediaDescriptionCompat { + return MediaDescriptionCompat.Builder().build() + } } @JsonIgnoreProperties(ignoreUnknown = true) data class DeviceInfo( - var deviceId:String, - var manufacturer:String, - var model:String, - var sdkVersion:Int, - var clientVersion: String + var deviceId: String, + var manufacturer: String, + var model: String, + var sdkVersion: Int, + var clientVersion: String ) @JsonIgnoreProperties(ignoreUnknown = true) data class PlayItemRequestPayload( - var mediaPlayer:String, - var forceDirectPlay:Boolean, - var forceTranscode:Boolean, - var deviceInfo:DeviceInfo + var mediaPlayer: String, + var forceDirectPlay: Boolean, + var forceTranscode: Boolean, + var deviceInfo: DeviceInfo ) @JsonIgnoreProperties(ignoreUnknown = true) data class DeviceSettings( - var disableAutoRewind:Boolean, - var enableAltView:Boolean, - var allowSeekingOnMediaControls:Boolean, - var jumpBackwardsTime:Int, - var jumpForwardTime:Int, - var enableMp3IndexSeeking:Boolean, - var disableShakeToResetSleepTimer:Boolean, - var shakeSensitivity: ShakeSensitivitySetting, - var lockOrientation: LockOrientationSetting, - var hapticFeedback: HapticFeedbackSetting, - var autoSleepTimer: Boolean, - var autoSleepTimerStartTime: String, - var autoSleepTimerEndTime: String, - var autoSleepTimerAutoRewind: Boolean, - var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds - var sleepTimerLength: Long, // Time in milliseconds - var disableSleepTimerFadeOut: Boolean, - var disableSleepTimerResetFeedback: Boolean, - var enableSleepTimerAlmostDoneChime: Boolean, - var languageCode: String, - var downloadUsingCellular: DownloadUsingCellularSetting, - var streamingUsingCellular: StreamingUsingCellularSetting, - var androidAutoBrowseLimitForGrouping: Int, - var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting + var disableAutoRewind: Boolean, + var enableAltView: Boolean, + var allowSeekingOnMediaControls: Boolean, + var jumpBackwardsTime: Int, + var jumpForwardTime: Int, + var enableMp3IndexSeeking: Boolean, + var disableShakeToResetSleepTimer: Boolean, + var shakeSensitivity: ShakeSensitivitySetting, + var lockOrientation: LockOrientationSetting, + var hapticFeedback: HapticFeedbackSetting, + var autoSleepTimer: Boolean, + var autoSleepTimerStartTime: String, + var autoSleepTimerEndTime: String, + var autoSleepTimerAutoRewind: Boolean, + var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds + var sleepTimerLength: Long, // Time in milliseconds + var disableSleepTimerFadeOut: Boolean, + var disableSleepTimerResetFeedback: Boolean, + var enableSleepTimerAlmostDoneChime: Boolean, + var languageCode: String, + var downloadUsingCellular: DownloadUsingCellularSetting, + var streamingUsingCellular: StreamingUsingCellularSetting, + var androidAutoBrowseLimitForGrouping: Int, + var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting ) { companion object { // Static method to get default device settings - fun default():DeviceSettings { + fun default(): DeviceSettings { return DeviceSettings( - disableAutoRewind = false, - enableAltView = true, - allowSeekingOnMediaControls = false, - jumpBackwardsTime = 10, - jumpForwardTime = 10, - enableMp3IndexSeeking = false, - disableShakeToResetSleepTimer = false, - shakeSensitivity = ShakeSensitivitySetting.MEDIUM, - lockOrientation = LockOrientationSetting.NONE, - hapticFeedback = HapticFeedbackSetting.LIGHT, - autoSleepTimer = false, - autoSleepTimerStartTime = "22:00", - autoSleepTimerEndTime = "06:00", - sleepTimerLength = 900000L, // 15 minutes - autoSleepTimerAutoRewind = false, - autoSleepTimerAutoRewindTime = 300000L, // 5 minutes - disableSleepTimerFadeOut = false, - disableSleepTimerResetFeedback = false, - enableSleepTimerAlmostDoneChime = false, - languageCode = "en-us", - downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, - streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, - androidAutoBrowseLimitForGrouping = 100, - androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC + disableAutoRewind = false, + enableAltView = true, + allowSeekingOnMediaControls = false, + jumpBackwardsTime = 10, + jumpForwardTime = 10, + enableMp3IndexSeeking = false, + disableShakeToResetSleepTimer = false, + shakeSensitivity = ShakeSensitivitySetting.MEDIUM, + lockOrientation = LockOrientationSetting.NONE, + hapticFeedback = HapticFeedbackSetting.LIGHT, + autoSleepTimer = false, + autoSleepTimerStartTime = "22:00", + autoSleepTimerEndTime = "06:00", + sleepTimerLength = 900000L, // 15 minutes + autoSleepTimerAutoRewind = false, + autoSleepTimerAutoRewindTime = 300000L, // 5 minutes + disableSleepTimerFadeOut = false, + disableSleepTimerResetFeedback = false, + enableSleepTimerAlmostDoneChime = false, + languageCode = "en-us", + downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, + streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, + androidAutoBrowseLimitForGrouping = 100, + androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC ) } } @get:JsonIgnore - val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L + val jumpBackwardsTimeMs + get() = jumpBackwardsTime * 1000L @get:JsonIgnore - val jumpForwardTimeMs get() = jumpForwardTime * 1000L + val jumpForwardTimeMs + get() = jumpForwardTime * 1000L @get:JsonIgnore - val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt() + val autoSleepTimerStartHour + get() = autoSleepTimerStartTime.split(":")[0].toInt() @get:JsonIgnore - val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt() + val autoSleepTimerStartMinute + get() = autoSleepTimerStartTime.split(":")[1].toInt() @get:JsonIgnore - val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt() + val autoSleepTimerEndHour + get() = autoSleepTimerEndTime.split(":")[0].toInt() @get:JsonIgnore - val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt() - + val autoSleepTimerEndMinute + get() = autoSleepTimerEndTime.split(":")[1].toInt() @JsonIgnore - fun getShakeThresholdGravity() : Float { // Used in ShakeDetector + fun getShakeThresholdGravity(): Float { // Used in ShakeDetector return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f @@ -221,10 +240,10 @@ data class DeviceSettings( } data class DeviceData( - var serverConnectionConfigs:MutableList, - var lastServerConnectionConfigId:String?, - var deviceSettings: DeviceSettings?, - var lastPlaybackSession: PlaybackSession? + var serverConnectionConfigs: MutableList, + var lastServerConnectionConfigId: String?, + var deviceSettings: DeviceSettings?, + var lastPlaybackSession: PlaybackSession? ) { @JsonIgnore fun getLastServerConnectionConfig(): ServerConnectionConfig? { diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index 8fbe9ac3..1236bc5c 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -100,7 +100,7 @@ class LocalLibraryItem( @JsonIgnore private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean { - return LocalFile("", null, contentUrl ?: "", "", path, "", null, 0).exists(ctx) + return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx) } @JsonIgnore diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt index 987e5c88..11673e89 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt @@ -14,7 +14,6 @@ data class LocalMediaItem( var mediaType: String, var folderId: String, var contentUrl: String, - var simplePath: String, var basePath: String, var absolutePath: String, var audioTracks: MutableList, diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt index 69d45512..9051149f 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -35,7 +35,6 @@ class FolderScanner(private val ctx: Context) { Uri.fromFile(file).toString(), file.getBasePath(ctx), file.absolutePath, - file.getSimplePath(ctx), file.mimeType, file.length() ) @@ -61,7 +60,6 @@ class FolderScanner(private val ctx: Context) { contentUrl, part.localFolderName, part.finalDestinationPath, - part.finalDestinationPath, mimeTypeFor(part), size ) @@ -81,7 +79,6 @@ class FolderScanner(private val ctx: Context) { document.uri.toString(), document.getBasePath(ctx), document.getAbsolutePath(ctx), - document.getSimplePath(ctx), document.mimeType, document.length() ) diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt index 35828180..47d6304f 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -23,40 +23,49 @@ import java.io.File class AbsFileSystem : Plugin() { private val TAG = "AbsFileSystem" private val tag = "AbsFileSystem" - private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private var jacksonMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity override fun load() { mainActivity = (activity as MainActivity) - mainActivity.storage.storageAccessCallback = object : StorageAccessCallback { - override fun onRootPathNotSelected( - requestCode: Int, - rootPath: String, - uri: Uri, - selectedStorageType: StorageType, - expectedStorageType: StorageType - ) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + mainActivity.storage.storageAccessCallback = + object : StorageAccessCallback { + override fun onRootPathNotSelected( + requestCode: Int, + rootPath: String, + uri: Uri, + selectedStorageType: StorageType, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onCanceledByUser(requestCode: Int) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + override fun onCanceledByUser(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + override fun onExpectedStorageNotSelected( + requestCode: Int, + selectedFolder: DocumentFile, + selectedStorageType: StorageType, + expectedBasePath: String, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onStoragePermissionDenied(requestCode: Int) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } - } + override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + } } @PluginMethod @@ -65,60 +74,77 @@ class AbsFileSystem : Plugin() { val REQUEST_CODE_SELECT_FOLDER = 6 val REQUEST_CODE_SDCARD_ACCESS = 7 - mainActivity.storage.folderPickerCallback = object : FolderPickerCallback { - override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { - Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") - val absolutePath = folder.getAbsolutePath(activity) - val storageType = folder.getStorageType(activity) - val simplePath = folder.getSimplePath(activity) - val basePath = folder.getBasePath(activity) - val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) + mainActivity.storage.folderPickerCallback = + object : FolderPickerCallback { + override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { + Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") + val absolutePath = folder.getAbsolutePath(activity) + val storageType = folder.getStorageType(activity) + val basePath = folder.getBasePath(activity) + val folderId = + android.util.Base64.encodeToString( + folder.id.toByteArray(), + android.util.Base64.DEFAULT + ) - val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) + val localFolder = + LocalFolder( + folderId, + folder.name ?: "", + folder.uri.toString(), + basePath, + absolutePath, + storageType.toString(), + mediaType + ) - DeviceManager.dbManager.saveLocalFolder(localFolder) - call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) - } + DeviceManager.dbManager.saveLocalFolder(localFolder) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) + } - override fun onStorageAccessDenied( - requestCode: Int, - folder: DocumentFile?, - storageType: StorageType, - storageId: String - ) { - Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}") + override fun onStorageAccessDenied( + requestCode: Int, + folder: DocumentFile?, + storageType: StorageType, + storageId: String + ) { + Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}") - val jsobj = JSObject() - if (requestCode == REQUEST_CODE_SELECT_FOLDER) { + val jsobj = JSObject() + if (requestCode == REQUEST_CODE_SELECT_FOLDER) { - val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) - builder.setMessage( - "You have no write access to this storage, thus selecting this folder is useless." + - "\nWould you like to grant access to this folder?") - builder.setNegativeButton("Dont Allow") { _, _ -> - run { - jsobj.put("error", "User Canceled, Access Denied") - call.resolve(jsobj) + val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) + builder.setMessage( + "You have no write access to this storage, thus selecting this folder is useless." + + "\nWould you like to grant access to this folder?" + ) + builder.setNegativeButton("Dont Allow") { _, _ -> + run { + jsobj.put("error", "User Canceled, Access Denied") + call.resolve(jsobj) + } + } + builder.setPositiveButton("Allow.") { _, _ -> + mainActivity.storageHelper.requestStorageAccess( + REQUEST_CODE_SDCARD_ACCESS, + initialPath = FileFullPath(mainActivity, storageId, "") + ) + } + builder.show() + } else { + Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") + jsobj.put("error", "Access Denied") + call.resolve(jsobj) + } + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") + val jsobj = JSObject() + jsobj.put("error", "Permission Denied") + call.resolve(jsobj) + } } - } - builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) } - builder.show() - } else { - Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") - jsobj.put("error", "Access Denied") - call.resolve(jsobj) - } - } - - - override fun onStoragePermissionDenied(requestCode: Int) { - Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") - val jsobj = JSObject() - jsobj.put("error", "Permission Denied") - call.resolve(jsobj) - } - - } mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER) } @@ -152,7 +178,7 @@ class AbsFileSystem : Plugin() { val folderUrl = call.data.getString("folderUrl", "").toString() Log.d(TAG, "Check Folder Permissions for $folderUrl") - val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true) + val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true) val jsobj = JSObject() jsobj.put("value", hasAccess) @@ -196,26 +222,29 @@ class AbsFileSystem : Plugin() { if (localLibraryItem?.folderId?.startsWith("internal-") == true) { Log.d(tag, "Deleting internal library item at absolutePath $absolutePath") val file = File(absolutePath) - success = if (file.exists()) { - file.deleteRecursively() - } else { - true - } + success = + if (file.exists()) { + file.deleteRecursively() + } else { + true + } } else { var subfolderPathToDelete = "" localLibraryItem?.folderId?.let { folderId -> val folder = DeviceManager.dbManager.getLocalFolder(folderId) folder?.absolutePath?.let { folderPath -> val splitAbsolutePath = absolutePath.split("/") - val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") + val fullSubDir = + splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") if (fullSubDir != folderPath) { - val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> - if (_localLibraryItem.id == localLibraryItemId) { - false - } else { - _localLibraryItem.absolutePath.startsWith(fullSubDir) - } - } + val subdirHasAnItem = + DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> + if (_localLibraryItem.id == localLibraryItemId) { + false + } else { + _localLibraryItem.absolutePath.startsWith(fullSubDir) + } + } subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir } } diff --git a/plugins/capacitor/AbsDatabase.js b/plugins/capacitor/AbsDatabase.js index 3676e97c..58a2e30d 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -119,7 +119,6 @@ class AbsDatabaseWeb extends WebPlugin { name: 'Audiobooks', contentUrl: 'test', absolutePath: '/audiobooks', - simplePath: 'audiobooks', storageType: 'primary', mediaType: 'book' } @@ -196,7 +195,6 @@ class AbsDatabaseWeb extends WebPlugin { filename: 'lf1.mp3', contentUrl: 'test', absolutePath: 'test', - simplePath: 'test', mimeType: 'audio/mpeg', size: 39048290 } From 245df5d9349b656b27fef373364e244b67920a39 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 20 Jul 2026 19:06:35 -0700 Subject: [PATCH 10/17] Remove unnecessary migration, autoformat --- .../audiobookshelf/app/managers/DbManager.kt | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 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 2f4c4572..63c97a7c 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 @@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.plugins.AbsLog import com.audiobookshelf.app.plugins.AbsLogger import io.paperdb.Paper -import java.io.File class DbManager { val tag = "DbManager" @@ -122,21 +121,6 @@ class DbManager { return downloadItems } - /** - * Removes DownloadManager queue entries that cannot be resumed by the app-managed downloader. - * - * This migration preserves completed local media, which is stored in separate books. - */ - fun clearLegacyDownloadQueueOnce() { - val metadata = Paper.book("downloadQueueMetadata") - val architectureVersion = metadata.read("architectureVersion") ?: 0 - if (architectureVersion >= 2) return - - Paper.book("downloadItems").destroy() - metadata.write("architectureVersion", 2) - Log.i(tag, "Cleared legacy persisted download queue for architecture v2") - } - fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) { Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress) } @@ -218,9 +202,10 @@ class DbManager { // Check cover still there lli.coverAbsolutePath?.let { - val coverExists = lli.localFiles.any { localFile -> - localFile.absolutePath == it && localFile.exists(context) - } + val coverExists = + lli.localFiles.any { localFile -> + localFile.absolutePath == it && localFile.exists(context) + } if (!coverExists) { Log.d( tag, @@ -306,15 +291,13 @@ class DbManager { return sessions } - fun saveLog(log:AbsLog) { + fun saveLog(log: AbsLog) { Paper.book("log").write(log.id, log) } - fun getAllLogs() : List { - val logs:MutableList = mutableListOf() + fun getAllLogs(): List { + val logs: MutableList = mutableListOf() Paper.book("log").allKeys.forEach { logId -> - Paper.book("log").read(logId)?.let { - logs.add(it) - } + Paper.book("log").read(logId)?.let { logs.add(it) } } return logs.sortedBy { it.timestamp } } @@ -333,7 +316,10 @@ class DbManager { } } if (logsRemoved > 0) { - AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours") + AbsLogger.info( + "DbManager", + "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours" + ) } } } From 6282fd1f95d85482d52d5ad06352f8714185f622 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 20 Jul 2026 19:54:47 -0700 Subject: [PATCH 11/17] Remove deleted migration call --- .../java/com/audiobookshelf/app/managers/DownloadItemManager.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 59bdc7c0..b83ade16 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -59,7 +59,6 @@ class DownloadItemManager( } init { - DeviceManager.dbManager.clearLegacyDownloadQueueOnce() IncompleteDownloadCleanup.cleanupExpired(context) } From 58fbfca64534afca4721804d36289dedd0ae954f Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 20 Jul 2026 20:01:02 -0700 Subject: [PATCH 12/17] Add translation strings for download dialogs and notification --- .../app/plugins/AbsDownloader.kt | 14 +++++- .../app/plugins/AbsFileSystem.kt | 48 ++++++++++++++---- .../app/services/DownloadService.kt | 17 +++++-- .../app/services/DownloadServiceHost.kt | 50 +++++++++++++++++++ plugins/i18n.js | 23 ++++++++- strings/en-us.json | 11 +++- 6 files changed, 145 insertions(+), 18 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 68cfdd67..57b9aa51 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 @@ -53,6 +53,18 @@ class AbsDownloader : Plugin() { super.handleOnDestroy() } + @PluginMethod + fun setDownloadNotificationStrings(call: PluginCall) { + DownloadServiceHost.setNotificationStrings( + mainActivity, + call.getString("preparing") ?: "Preparing downloads", + call.getString("downloadingFile") ?: "Downloading {0}", + call.getString("waitingForStorage") ?: "Waiting for available storage", + call.getString("downloads") ?: "Downloads", + call.getString("cancel") ?: "Cancel") + call.resolve() + } + /** Replays restored queue items when the frontend subscribes to download events. */ @PluginMethod(returnType = PluginMethod.RETURN_NONE) override fun addListener(call: PluginCall) { @@ -91,7 +103,7 @@ class AbsDownloader : Plugin() { if (localFolder == null && localFolderId.startsWith("internal-")) { Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId") - localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType) + localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt index 47d6304f..307c6c09 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -1,6 +1,7 @@ package com.audiobookshelf.app.plugins import android.app.AlertDialog +import android.content.Context import android.net.Uri import android.os.Build import android.util.Log @@ -68,6 +69,19 @@ class AbsFileSystem : Plugin() { } } + @PluginMethod + fun setFolderPickerStrings(call: PluginCall) { + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired")) + .putString(KEY_ALLOW, call.getString("allow")) + .putString(KEY_CANCEL, call.getString("cancel")) + .putString(KEY_ACCESS_DENIED, call.getString("accessDenied")) + .putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied")) + .apply() + call.resolve() + } + @PluginMethod fun selectFolder(call: PluginCall) { val mediaType = call.data.getString("mediaType", "book").toString() @@ -114,17 +128,14 @@ class AbsFileSystem : Plugin() { if (requestCode == REQUEST_CODE_SELECT_FOLDER) { val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) - builder.setMessage( - "You have no write access to this storage, thus selecting this folder is useless." + - "\nWould you like to grant access to this folder?" - ) - builder.setNegativeButton("Dont Allow") { _, _ -> + builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED)) + builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ -> run { - jsobj.put("error", "User Canceled, Access Denied") + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) call.resolve(jsobj) } } - builder.setPositiveButton("Allow.") { _, _ -> + builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ -> mainActivity.storageHelper.requestStorageAccess( REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "") @@ -133,7 +144,7 @@ class AbsFileSystem : Plugin() { builder.show() } else { Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") - jsobj.put("error", "Access Denied") + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) call.resolve(jsobj) } } @@ -141,7 +152,7 @@ class AbsFileSystem : Plugin() { override fun onStoragePermissionDenied(requestCode: Int) { Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") val jsobj = JSObject() - jsobj.put("error", "Permission Denied") + jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED)) call.resolve(jsobj) } } @@ -295,4 +306,23 @@ class AbsFileSystem : Plugin() { call.resolve(JSObject("{\"success\":false}")) } } + + private fun folderPickerString(key: String, defaultValue: String): String = + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .getString(key, defaultValue) ?: defaultValue + + private companion object { + const val FOLDER_PICKER_PREFERENCES = "folder_picker" + const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required" + const val KEY_ALLOW = "allow" + const val KEY_CANCEL = "cancel" + const val KEY_ACCESS_DENIED = "access_denied" + const val KEY_PERMISSION_DENIED = "permission_denied" + const val DEFAULT_WRITE_ACCESS_REQUIRED = + "You do not have write access to this folder. Would you like to grant access?" + const val DEFAULT_ALLOW = "Allow" + const val DEFAULT_CANCEL = "Cancel" + const val DEFAULT_ACCESS_DENIED = "Access denied" + const val DEFAULT_PERMISSION_DENIED = "Permission denied" + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt index 734041ce..453f6412 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -17,7 +17,7 @@ class DownloadService : Service() { override fun onCreate() { super.onCreate() createChannel() - startForeground(NOTIFICATION_ID, notification("Preparing downloads")) + startForeground(NOTIFICATION_ID, notification(DownloadServiceHost.notificationStrings(this).preparing)) DownloadServiceHost.attachService(this) } @@ -37,7 +37,10 @@ class DownloadService : Service() { override fun onBind(intent: Intent?): IBinder? = null fun onPartUpdate(part: DownloadItemPart) { - val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}" + val strings = DownloadServiceHost.notificationStrings(this) + val text = + if (part.waitingForSpace) strings.waitingForStorage + else strings.downloadingFile.replace("{0}", part.filename) val progress = part.progress.coerceIn(0L, 100L).toInt() val notification = notification(text, progress, part.fileSize > 0L) (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification) @@ -55,18 +58,22 @@ class DownloadService : Service() { this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags()) return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.icon) - .setContentTitle("Audiobookshelf downloads") + .setContentTitle(DownloadServiceHost.notificationStrings(this).downloads) .setContentText(text) .setOnlyAlertOnce(true) .setOngoing(true) .setProgress(100, progress, !determinate) - .addAction(0, "Cancel", cancelIntent) + .addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent) .build() } private fun createChannel() { val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_LOW)) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + DownloadServiceHost.notificationStrings(this).downloads, + NotificationManager.IMPORTANCE_LOW)) } private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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 d37400fe..38f64a74 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 @@ -11,6 +11,14 @@ import java.util.Collections /** Shared process owner used by the foreground service and the Capacitor bridge. */ object DownloadServiceHost { + data class NotificationStrings( + val preparing: String, + val downloadingFile: String, + val waitingForStorage: String, + val downloads: String, + val cancel: String + ) + private var manager: DownloadItemManager? = null private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter private var service: DownloadService? = null @@ -58,6 +66,36 @@ object DownloadServiceHost { @Synchronized fun cancelAll(context: Context) { ensure(context).cancelAll() } + fun setNotificationStrings( + context: Context, + preparing: String, + downloadingFile: String, + waitingForStorage: String, + downloads: String, + cancel: String + ) { + context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PREPARING, preparing) + .putString(KEY_DOWNLOADING_FILE, downloadingFile) + .putString(KEY_WAITING_FOR_STORAGE, waitingForStorage) + .putString(KEY_DOWNLOADS, downloads) + .putString(KEY_CANCEL, cancel) + .apply() + } + + fun notificationStrings(context: Context): NotificationStrings { + val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + return NotificationStrings( + preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING, + preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE) + ?: DEFAULT_DOWNLOADING_FILE, + preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE) + ?: DEFAULT_WAITING_FOR_STORAGE, + preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS, + preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL) + } + @Synchronized fun attachService(downloadService: DownloadService) { service = downloadService @@ -94,4 +132,16 @@ object DownloadServiceHost { override fun onDownloadItemComplete(jsobj: JSObject) = Unit override fun onQueueChanged(hasWork: Boolean) = Unit } + + private const val NOTIFICATION_PREFERENCES = "download_notifications" + private const val KEY_PREPARING = "preparing" + private const val KEY_DOWNLOADING_FILE = "downloading_file" + private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage" + private const val KEY_DOWNLOADS = "downloads" + private const val KEY_CANCEL = "cancel" + private const val DEFAULT_PREPARING = "Preparing downloads" + private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}" + private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage" + private const val DEFAULT_DOWNLOADS = "Downloads" + private const val DEFAULT_CANCEL = "Cancel" } diff --git a/plugins/i18n.js b/plugins/i18n.js index 1dd6a238..b690f912 100644 --- a/plugins/i18n.js +++ b/plugins/i18n.js @@ -1,4 +1,6 @@ import Vue from 'vue' +import { Capacitor } from '@capacitor/core' +import { AbsDownloader, AbsFileSystem } from '@/plugins/capacitor' import enUsStrings from '../strings/en-us.json' const defaultCode = 'en-us' @@ -41,6 +43,24 @@ function supplant(str, subs) { }) } +function syncDownloadNotificationStrings() { + if (Capacitor.getPlatform() !== 'android') return + AbsDownloader.setDownloadNotificationStrings({ + preparing: Vue.prototype.$strings.MessagePreparingDownloads, + downloadingFile: Vue.prototype.$strings.MessageDownloadingFile, + waitingForStorage: Vue.prototype.$strings.MessageWaitingForAvailableStorage, + downloads: Vue.prototype.$strings.HeaderDownloads, + cancel: Vue.prototype.$strings.ButtonCancel + }).catch((error) => console.warn('Failed to update download notification strings', error)) + AbsFileSystem.setFolderPickerStrings({ + writeAccessRequired: Vue.prototype.$strings.MessageStorageWriteAccessRequired, + allow: Vue.prototype.$strings.ButtonAllow, + cancel: Vue.prototype.$strings.ButtonCancel, + accessDenied: Vue.prototype.$strings.MessageStorageAccessDenied, + permissionDenied: Vue.prototype.$strings.MessageStoragePermissionDenied + }).catch((error) => console.warn('Failed to update folder picker strings', error)) +} + Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) => { return { text: languageCodeMap[code].label, @@ -108,6 +128,7 @@ async function loadi18n(code) { } Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale) + syncDownloadNotificationStrings() this.$eventBus.$emit('change-lang', code) return true @@ -145,5 +166,5 @@ async function initialize() { export default ({ app, store }, inject) => { $localStore = app.$localStore - initialize() + initialize().finally(syncDownloadNotificationStrings) } diff --git a/strings/en-us.json b/strings/en-us.json index 2f39d0dc..12ac5b8b 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -1,6 +1,7 @@ { "ButtonAdd": "Add", "ButtonAddNewServer": "Add New Server", + "ButtonAllow": "Allow", "ButtonAuthors": "Authors", "ButtonBack": "Back", "ButtonCancel": "Cancel", @@ -295,7 +296,7 @@ "MessageAudiobookshelfServerNotConnected": "Audiobookshelf server not connected", "MessageAudiobookshelfServerRequired": "Important! This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.", "MessageBookshelfEmpty": "Bookshelf empty", - "MessageConfirmAppExit":"Did you want to exit the app?", + "MessageConfirmAppExit": "Did you want to exit the app?", "MessageConfirmDeleteEpisodeDownloadQueue": "Are you sure you want to clear episode download queue?", "MessageConfirmDeleteLocalEpisode": "Remove local episode \"{0}\" from your device? The file on the server will be unaffected.", "MessageConfirmDeleteLocalFiles": "Remove local files of this item from your device? The files on the server and your progress will be unaffected.", @@ -305,13 +306,14 @@ "MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?", "MessageConfirmDownloadUsingCellular": "You are about to download using cellular data. This may include carrier data charges. Do you wish to continue?", "MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?", - "MessageConfirmPlaybackTime":"Start playback for \"{0}\" at {1}?", + "MessageConfirmPlaybackTime": "Start playback for \"{0}\" at {1}?", "MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?", "MessageConfirmStreamingUsingCellular": "You are about to stream using cellular data. This may include carrier data charges. Do you wish to continue?", "MessageDiscardProgress": "Discard Progress", "MessageDownloadCompleteProcessing": "Download complete. Processing...", "MessageDownloading": "Downloading...", "MessageDownloadingEpisode": "Downloading episode", + "MessageDownloadingFile": "Downloading {0}", "MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download", "MessageFailedToRefreshToken": "Failed to refresh token, re-login required", "MessageFeedURLWillBe": "Feed URL will be {0}", @@ -347,6 +349,7 @@ "MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.", "MessageOldServerConnectionWarningHelp": "You originally set up the connection to this server prior to the database migration in 2.3.0, released June 2023. A future server update will remove the ability to sign in with this old connection. Please delete the existing server connection and connect again (using the same server address and credentials). If you have any downloaded media on this device, the media will need to be downloaded again to sync with the server.", "MessagePodcastSearchField": "Enter search term or RSS feed URL", + "MessagePreparingDownloads": "Preparing downloads", "MessageProgressSyncFailed": "The most recent attempt to report your listening progress to the server has failed. Progress sync requests will continue to be attempted every 15 seconds to 1 minute while media is playing.", "MessageReportBugsAndContribute": "Report bugs, request features, and contribute on", "MessageSeriesAlreadyDownloaded": "You have already downloaded all books in this series.", @@ -357,6 +360,10 @@ "MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular", "MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi", "MessageSocketNotConnected": "Socket not connected", + "MessageStorageAccessDenied": "Access denied", + "MessageStoragePermissionDenied": "Permission denied", + "MessageStorageWriteAccessRequired": "You do not have write access to this folder. Would you like to grant access?", + "MessageWaitingForAvailableStorage": "Waiting for available storage", "NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS", "NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.", "ToastBookmarkCreateFailed": "Failed to create bookmark", From f8424c03fd732bf4a4a0fa772947f9e3406628a0 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 20 Jul 2026 20:05:18 -0700 Subject: [PATCH 13/17] Increase download chunk size to 512 KB --- .../app/managers/InternalDownloadManager.kt | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index 7c23e6cc..59dc6026 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -34,9 +34,7 @@ class InternalDownloadManager( .url(url) .addHeader("Accept-Encoding", "identity") .addHeader("Authorization", "Bearer $token") - .apply { - if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") - } + .apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") } .build() val call = client.newCall(request) call.enqueue( @@ -49,14 +47,21 @@ class InternalDownloadManager( override fun onResponse(call: Call, response: Response) { response.use { try { - if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize) { + if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize + ) { progressCallback.onProgress(existingBytes, 100L) progressCallback.onComplete(false) return } - val append = existingBytes > 0L && response.code == 206 && hasExpectedRange(response, existingBytes) + val append = + existingBytes > 0L && + response.code == 206 && + hasExpectedRange(response, existingBytes) if (existingBytes > 0L && !append && response.code != 200) { - Log.e(tag, "Invalid resume response ${response.code} for offset $existingBytes") + Log.e( + tag, + "Invalid resume response ${response.code} for offset $existingBytes" + ) progressCallback.onComplete(true) return } @@ -70,8 +75,7 @@ class InternalDownloadManager( val responseLength = response.body!!.contentLength() val totalLength = if (expectedSize > 0L) expectedSize - else if (responseLength >= 0L) startingBytes + responseLength - else 0L + else if (responseLength >= 0L) startingBytes + responseLength else 0L FileOutputStream(destinationFile, append).use { output -> response.body!!.byteStream().use { input -> @@ -80,17 +84,22 @@ class InternalDownloadManager( while (true) { val read = input.read(buffer) if (read < 0) break - if (!hasAvailableSpace()) throw IOException("Download paused to preserve free storage") + if (!hasAvailableSpace()) + throw IOException("Download paused to preserve free storage") output.write(buffer, 0, read) totalBytes += read - val progress = if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L + val progress = + if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L)) } } } if (expectedSize > 0L && destinationFile.length() != expectedSize) { - Log.e(tag, "Downloaded size ${destinationFile.length()} did not match $expectedSize") + Log.e( + tag, + "Downloaded size ${destinationFile.length()} did not match $expectedSize" + ) progressCallback.onComplete(true) } else { progressCallback.onComplete(false) @@ -114,7 +123,7 @@ class InternalDownloadManager( } private companion object { - const val CHUNK_SIZE = 8 * 1024 + const val CHUNK_SIZE = 512 * 1024 // 512 KB val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)") val client = OkHttpClient.Builder() From 2cc9ff5a41478bc05d3e93624e7f1bc386e489c8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 29 Jul 2026 17:07:27 -0500 Subject: [PATCH 14/17] Add POST_NOTIFICATION to manifest and start foreground service with type --- android/app/src/main/AndroidManifest.xml | 1 + .../com/audiobookshelf/app/MainActivity.kt | 22 ++++++++++++------- .../app/services/DownloadService.kt | 18 +++++++++++++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1ecfd2bc..3c7cadc6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + () + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + needed.add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + needed.add(Manifest.permission.POST_NOTIFICATIONS) + } + if (needed.isNotEmpty()) { + ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt index 453f6412..13a2b60b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -7,6 +7,8 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import com.audiobookshelf.app.R @@ -17,14 +19,17 @@ class DownloadService : Service() { override fun onCreate() { super.onCreate() createChannel() - startForeground(NOTIFICATION_ID, notification(DownloadServiceHost.notificationStrings(this).preparing)) + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) DownloadServiceHost.attachService(this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) - else -> DownloadServiceHost.ensure(this) + else -> { + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) + DownloadServiceHost.ensure(this) + } } return START_STICKY } @@ -53,6 +58,15 @@ class DownloadService : Service() { } } + private fun startForegroundWithType(text: String) { + val notification = notification(text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification { val cancelIntent = PendingIntent.getService( this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags()) From d3e55cabf42a6507f07520957e771022d2c3ce00 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Jul 2026 16:51:48 -0500 Subject: [PATCH 15/17] Version guard download notification channel API 26+ --- .../main/java/com/audiobookshelf/app/services/DownloadService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt index 13a2b60b..6838be64 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -82,6 +82,7 @@ class DownloadService : Service() { } private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager manager.createNotificationChannel( NotificationChannel( From b084e54dcc5e55f5312e9acca28a640836c3f0b2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Jul 2026 17:08:24 -0500 Subject: [PATCH 16/17] Update download notification id to 11 --- .../java/com/audiobookshelf/app/services/DownloadService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt index 6838be64..51e9ae31 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -95,7 +95,7 @@ class DownloadService : Service() { companion object { private const val CHANNEL_ID = "downloads" - private const val NOTIFICATION_ID = 4102 + private const val NOTIFICATION_ID = 11 private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL" fun intent(context: Context) = Intent(context, DownloadService::class.java) } From e5ef38295d7ea3cf827738d3d91a98ad1a03bcd3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Jul 2026 17:31:23 -0500 Subject: [PATCH 17/17] Update download notification cancel to clear download queue in UI --- .../java/com/audiobookshelf/app/plugins/AbsDownloader.kt | 4 +++- components/widgets/DownloadProgressIndicator.vue | 8 +++++++- store/globals.js | 3 +++ 3 files changed, 13 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 57b9aa51..d9dde269 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 @@ -38,7 +38,9 @@ class AbsDownloader : Plugin() { override fun onDownloadItemComplete(jsobj:JSObject) { notifyListeners("onItemDownloadComplete", jsobj) } - override fun onQueueChanged(hasWork: Boolean) = Unit + override fun onQueueChanged(hasWork: Boolean) { + notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork)) + } }) override fun load() { diff --git a/components/widgets/DownloadProgressIndicator.vue b/components/widgets/DownloadProgressIndicator.vue index 8e389c7d..be1d502c 100644 --- a/components/widgets/DownloadProgressIndicator.vue +++ b/components/widgets/DownloadProgressIndicator.vue @@ -12,7 +12,8 @@ export default { return { downloadItemListener: null, completeListener: null, - itemPartUpdateListener: null + itemPartUpdateListener: null, + queueChangedListener: null } }, computed: { @@ -76,17 +77,22 @@ export default { }, onDownloadItemPartUpdate(itemPart) { this.$store.commit('globals/updateDownloadItemPart', itemPart) + }, + onQueueChanged(data) { + if (!data.hasWork) this.$store.commit('globals/clearItemDownloads') } }, async mounted() { this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data)) this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data)) + this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data)) this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) }, beforeDestroy() { this.downloadItemListener?.remove() this.completeListener?.remove() this.itemPartUpdateListener?.remove() + this.queueChangedListener?.remove() } } \ No newline at end of file diff --git a/store/globals.js b/store/globals.js index 7e80824b..6edad56e 100644 --- a/store/globals.js +++ b/store/globals.js @@ -135,6 +135,9 @@ export const mutations = { removeItemDownload(state, id) { state.itemDownloads = state.itemDownloads.filter((i) => i.id != id) }, + clearItemDownloads(state) { + state.itemDownloads = [] + }, setBookshelfListView(state, val) { state.bookshelfListView = val },