Initial download service startup fixes and range request fix

This commit is contained in:
Nicholas Wallace
2026-08-27 13:08:46 -07:00
parent 12025ab59a
commit 61c314a531
9 changed files with 372 additions and 126 deletions
+4
View File
@@ -60,6 +60,9 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
testOptions {
unitTests.returnDefaultValues = true
}
}
repositories {
@@ -81,6 +84,7 @@ configurations.configureEach {
}
dependencies {
testImplementation "junit:junit:$junit_version"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
@@ -5,6 +5,7 @@ import android.net.Uri
import android.os.StatFs
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.fullName
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
@@ -22,7 +23,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.Call
/** Manages the process-owned queue for app-managed downloads. */
class DownloadItemManager(
@@ -32,10 +32,11 @@ class DownloadItemManager(
) {
private val tag = "DownloadItemManager"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeCalls = ConcurrentHashMap<String, Call>()
private val activeCalls = ConcurrentHashMap<String, InternalDownloadManager.DownloadHandle>()
private val safFolderLocks = ConcurrentHashMap<String, Any>()
private val reservations = mutableMapOf<String, Long>()
private val lastPersistTime = mutableMapOf<String, Long>()
private val finalizingItems = mutableSetOf<String>()
private var watcherRunning = false
private val jacksonMapper =
jacksonObjectMapper()
@@ -58,10 +59,6 @@ class DownloadItemManager(
fun onComplete(failed: Boolean)
}
init {
IncompleteDownloadCleanup.cleanupExpired(context)
}
@Synchronized
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
clientEventEmitter = eventEmitter
@@ -73,6 +70,15 @@ class DownloadItemManager(
fun restoreQueue() {
if (downloadItemQueue.isNotEmpty()) return
DeviceManager.dbManager.getDownloadItems().forEach { item ->
item.downloadItemParts.filter { it.moved }.forEach { part ->
if (!finalizedFileExists(part)) {
Log.w(tag, "Finalized file is missing; resetting ${part.filename}")
part.moved = false
part.completed = false
part.completedDestinationUri = null
part.downloadId = null
}
}
if (item.isDownloadFinished) {
downloadItemQueue.add(item)
checkDownloadItemFinished(item)
@@ -80,19 +86,26 @@ class DownloadItemManager(
}
item.downloadItemParts.forEach { part ->
if (part.moved) return@forEach
if (item.terminalFailureAt != null && part.failed) return@forEach
if (item.terminalFailureAt != null) {
part.downloadId = null
part.isMoving = false
part.failed = true
part.waitingForSpace = false
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
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
val stagingLength = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
part.bytesDownloaded = stagingLength
if (part.completed && stagingLength <= 0L) part.completed = false
}
downloadItemQueue.add(item)
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
clientEventEmitter.onDownloadItem(item)
}
checkUpdateDownloadQueue()
notifyQueueChanged()
}
@@ -100,39 +113,66 @@ class DownloadItemManager(
fun addDownloadItem(downloadItem: DownloadItem) {
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)
notifyQueueChanged()
}
@Synchronized
fun retryDownloadItem(downloadItemId: String): Boolean {
val item = downloadItemQueue.find { it.id == downloadItemId } ?: return false
if (item.downloadItemParts.any { it in currentDownloadItemParts }) return false
if (item.isDownloadFinished) return false
synchronized(IncompleteDownloadCleanup) {
item.terminalFailureAt = null
item.stagingCleanupAt = null
IncompleteDownloadCleanup.cancel(context, item.id)
item.downloadItemParts.filter { !it.moved }.forEach { part ->
part.failed = false
part.isMoving = false
part.downloadId = null
part.retryCount = 0
part.waitingForSpace = false
val stagingLength = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
part.bytesDownloaded = stagingLength
part.completed = part.completed && stagingLength > 0L
}
persist(item, force = true)
}
clientEventEmitter.onDownloadItem(item)
notifyQueueChanged()
return true
}
@Synchronized
fun resumeWork() {
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)
activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
activeCalls.clear()
downloadItemQueue.forEach { item ->
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() }
item.downloadItemParts.forEach { part ->
File(part.destinationPath).delete()
if (part.moved && part.isInternalStorage) {
File(part.finalDestinationPath).delete()
} else if (part.moved) {
part.completedDestinationUri?.let { uri ->
try {
DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete()
} catch (e: Exception) {
Log.w(tag, "Could not delete cancelled SAF file ${part.filename}", e)
}
}
}
}
IncompleteDownloadCleanup.cancel(context, item.id)
DeviceManager.dbManager.removeDownloadItem(item.id)
}
currentDownloadItemParts.clear()
@@ -145,14 +185,26 @@ class DownloadItemManager(
fun hasWork(): Boolean =
downloadItemQueue.any { item ->
item.downloadItemParts.any { part ->
(!part.completed && !part.failed) || part.isMoving
(!part.moved && !part.failed) || part.isMoving
}
}
@Synchronized
private fun checkUpdateDownloadQueue() {
downloadItemQueue.toList().forEach { item ->
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
var slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
if (slots <= 0) return@forEach
item.downloadItemParts
.filter { part ->
part.completed && !part.moved && !part.failed && !part.isMoving &&
part !in currentDownloadItemParts && File(part.destinationPath).exists()
}
.take(slots)
.forEach { part ->
currentDownloadItemParts.add(part)
part.downloadId = APP_MANAGED_DOWNLOAD_ID
}
slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
if (slots <= 0) return@forEach
item.getNextDownloadItemParts(slots).forEach { part ->
val existingFile = findSharedStorageFile(part)
@@ -189,7 +241,7 @@ class DownloadItemManager(
else
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
?: DeviceManager.token
activeCalls[part.id] =
val handle =
InternalDownloadManager(
stagingFile,
part.fileSize,
@@ -216,8 +268,10 @@ class DownloadItemManager(
}
},
{ hasAvailableSpace(part) }
)
.download(serverUrl(item, part), token)
).download(serverUrl(item, part), token)
if (part in currentDownloadItemParts && !part.completed && !part.failed) {
activeCalls[part.id] = handle
}
}
@Synchronized
@@ -277,6 +331,7 @@ class DownloadItemManager(
part.completed = false
part.downloadId = null
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
item.stagingCleanupAt = null
persist(item, force = true)
IncompleteDownloadCleanup.schedule(context, item)
notifyQueueChanged()
@@ -341,13 +396,13 @@ class DownloadItemManager(
}
if (temporary.length() != staging.length())
throw IllegalStateException("SAF copy size mismatch")
val existing = folder.findFile(part.filename)
val existing = findDocumentByFilename(folder, part)
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)
findDocumentByFilename(folder, part)
?: throw IllegalStateException("Could not reopen finalized SAF file")
if (destination.length() != staging.length())
throw IllegalStateException("SAF final size mismatch")
@@ -380,8 +435,10 @@ class DownloadItemManager(
checkDownloadItemFinished(item)
}
@Synchronized
private fun checkDownloadItemFinished(item: DownloadItem) {
if (!item.isDownloadFinished) return
if (!item.isDownloadFinished || !finalizingItems.add(item.id)) return
IncompleteDownloadCleanup.cancel(context, item.id)
scope.launch {
folderScanner.scanDownloadItem(item) { scanResult ->
val event =
@@ -397,6 +454,7 @@ class DownloadItemManager(
}
clientEventEmitter.onDownloadItemComplete(event)
synchronized(this@DownloadItemManager) {
finalizingItems.remove(item.id)
downloadItemQueue.remove(item)
DeviceManager.dbManager.removeDownloadItem(item.id)
notifyQueueChanged()
@@ -457,7 +515,7 @@ class DownloadItemManager(
}
fun destroy() {
activeCalls.values.forEach(Call::cancel)
activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
activeCalls.clear()
scope.cancel()
}
@@ -479,13 +537,44 @@ class DownloadItemManager(
if (segment == "." || segment == "..") return null
folder = folder.findFile(segment) ?: return null
}
val file = folder.findFile(part.filename) ?: return null
val file = findDocumentByFilename(folder, part) ?: 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 finalizedFileExists(part: DownloadItemPart): Boolean {
if (part.isInternalStorage) {
val file = File(part.finalDestinationPath)
return file.isFile &&
if (part.fileSize > 0L) file.length() == part.fileSize else file.length() > 0L
}
part.completedDestinationUri?.let { uri ->
try {
val file = DocumentFile.fromSingleUri(context, Uri.parse(uri))
if (file?.isFile == true &&
(part.fileSize <= 0L || file.length() == part.fileSize)) return true
} catch (e: Exception) {
Log.w(tag, "Could not validate SAF file ${part.filename}", e)
}
}
return findSharedStorageFile(part) != null
}
private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? {
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 =
part.audioTrack?.mimeType
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
@@ -0,0 +1,23 @@
package com.audiobookshelf.app.managers
internal object DownloadResumePolicy {
enum class InitialAction { COMPLETE, RESTART, FULL_DOWNLOAD, RANGE_DOWNLOAD }
fun initialAction(existingBytes: Long, expectedSize: Long): InitialAction =
when {
expectedSize > 0L && existingBytes == expectedSize -> InitialAction.COMPLETE
expectedSize > 0L && existingBytes > expectedSize -> InitialAction.RESTART
existingBytes > 0L -> InitialAction.RANGE_DOWNLOAD
else -> InitialAction.FULL_DOWNLOAD
}
fun unsatisfiedRangeSize(contentRange: String?): Long? {
if (contentRange == null) return null
return UNSATISFIED_CONTENT_RANGE.matchEntire(contentRange)
?.groupValues
?.get(1)
?.toLongOrNull()
}
private val UNSATISFIED_CONTENT_RANGE = Regex("bytes \\*/(\\d+)")
}
@@ -1,9 +1,7 @@
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
@@ -14,7 +12,7 @@ import com.audiobookshelf.app.models.DownloadItem
import java.io.File
import java.util.concurrent.TimeUnit
/** Removes terminally failed downloads after their retention window elapses. */
/** Removes only staging data for terminally failed downloads after their retention window. */
object IncompleteDownloadCleanup {
private const val tag = "IncompleteDownloadCleanup"
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
@@ -22,6 +20,7 @@ object IncompleteDownloadCleanup {
fun schedule(context: Context, item: DownloadItem) {
val failedAt = item.terminalFailureAt ?: return
if (item.stagingCleanupAt != null) return
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
@@ -34,7 +33,8 @@ object IncompleteDownloadCleanup {
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
}
/** Removes failures retained longer than 24 hours when scheduled work did not run. */
/** Cleans staging data retained longer than 24 hours when scheduled work did not run. */
@Synchronized
fun cleanupExpired(context: Context) {
val now = System.currentTimeMillis()
DeviceManager.dbManager.getDownloadItems()
@@ -46,6 +46,7 @@ object IncompleteDownloadCleanup {
private fun isEligible(item: DownloadItem, now: Long): Boolean {
val failedAt = item.terminalFailureAt ?: return false
if (item.stagingCleanupAt != null) return false
if (now - failedAt < RETENTION_MS) return false
return item.downloadItemParts.all { part ->
part.moved || (part.failed && !part.isMoving)
@@ -55,21 +56,15 @@ object IncompleteDownloadCleanup {
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) {
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)
}
}
if (!part.moved) {
part.bytesDownloaded = 0L
part.completed = false
}
}
DeviceManager.dbManager.removeDownloadItem(item.id)
item.stagingCleanupAt = System.currentTimeMillis()
DeviceManager.dbManager.saveDownloadItem(item)
cancel(context, item.id)
Log.i(tag, "Deleted terminally failed download item ${item.id}")
Log.i(tag, "Deleted staging files for terminally failed download item ${item.id}")
}
private fun deleteAppOwnedFile(context: Context, file: File) {
@@ -4,6 +4,8 @@ import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.TimeUnit
import okhttp3.Call
import okhttp3.Callback
@@ -19,16 +21,62 @@ class InternalDownloadManager(
private val hasAvailableSpace: () -> Boolean
) {
private val tag = "InternalDownloadManager"
interface DownloadHandle {
fun cancel()
}
private class ActiveDownloadHandle : DownloadHandle {
private val cancelled = AtomicBoolean(false)
private val activeCall = AtomicReference<Call?>()
fun setCall(call: Call) {
activeCall.set(call)
if (cancelled.get()) call.cancel()
}
override fun cancel() {
cancelled.set(true)
activeCall.get()?.cancel()
}
}
/**
* Starts or resumes a download.
*
* @param url download URL
* @param token access token sent in the Authorization header
* @return active call, used to cancel a stalled transfer
* @return logical handle used to cancel the active request, including a restarted request
*/
fun download(url: String, token: String): Call {
fun download(url: String, token: String): DownloadHandle {
destinationFile.parentFile?.mkdirs()
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
val handle = ActiveDownloadHandle()
startRequest(url, token, handle, allowRestart = true)
return handle
}
private fun startRequest(
url: String,
token: String,
handle: ActiveDownloadHandle,
allowRestart: Boolean
) {
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
when (DownloadResumePolicy.initialAction(existingBytes, expectedSize)) {
DownloadResumePolicy.InitialAction.COMPLETE -> {
progressCallback.onProgress(existingBytes, 100L)
progressCallback.onComplete(false)
return
}
DownloadResumePolicy.InitialAction.RESTART -> {
if (!destinationFile.delete()) {
Log.e(tag, "Could not delete oversized staging file ${destinationFile.name}")
progressCallback.onComplete(true)
return
}
existingBytes = 0L
}
else -> Unit
}
val request =
Request.Builder()
.url(url)
@@ -37,6 +85,7 @@ class InternalDownloadManager(
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
.build()
val call = client.newCall(request)
handle.setCall(call)
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
@@ -47,10 +96,20 @@ 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)
if (response.code == 416) {
val serverSize =
DownloadResumePolicy.unsatisfiedRangeSize(
response.header("Content-Range"))
if (serverSize != null && serverSize > 0L && existingBytes == serverSize) {
progressCallback.onProgress(existingBytes, 100L)
progressCallback.onComplete(false)
} else if (allowRestart && destinationFile.delete()) {
Log.w(tag, "Restarting stale range from byte zero")
startRequest(url, token, handle, allowRestart = false)
} else {
Log.e(tag, "Could not recover invalid range at offset $existingBytes")
progressCallback.onComplete(true)
}
return
}
val append =
@@ -112,7 +171,6 @@ class InternalDownloadManager(
}
}
)
return call
}
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
@@ -20,7 +20,8 @@ data class DownloadItem(
val itemSubfolder: String,
val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>,
@JsonIgnore var terminalFailureAt: Long? = null
@JsonIgnore var terminalFailureAt: Long? = null,
@JsonIgnore var stagingCleanupAt: Long? = null
) {
@get:JsonIgnore
val isInternalStorage
@@ -28,7 +29,9 @@ data class DownloadItem(
@get:JsonIgnore
val isDownloadFinished
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
get() = downloadItemParts.isNotEmpty() && downloadItemParts.all {
it.completed && it.moved && !it.isMoving && !it.failed
}
@JsonIgnore
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
@@ -87,54 +87,57 @@ class AbsDownloader : Plugin() {
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) {
Log.d(tag, "Download already started for this media entity $downloadId")
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
}
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
if (localFolderId == "") {
localFolderId = "internal-${libraryItem.mediaType}"
DownloadServiceHost.retryExisting(mainActivity, downloadId) { result ->
when (result) {
DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve()
DownloadServiceHost.ExistingDownloadResult.ACTIVE -> {
Log.d(tag, "Download already started for this media entity $downloadId")
call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
}
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
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)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED ->
call.resolve(JSObject("{\"error\":\"Unable to start the Android download service\"}"))
DownloadServiceHost.ExistingDownloadResult.NOT_FOUND -> {
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
Log.d(tag, "Got library item from server ${libraryItem.id}")
if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}"
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
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)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
if (localFolder == null) {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
} else if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { it.id == episodeId }
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode) { error -> resolveDownloadCall(call, error) }
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null) { error -> resolveDownloadCall(call, error) }
}
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
}
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
}
}
}
private fun resolveDownloadCall(call: PluginCall, error: String?) {
if (error == null) call.resolve()
else call.resolve(JSObject().put("error", error))
}
// Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
@@ -155,7 +158,12 @@ class AbsDownloader : Plugin() {
return newTitle
}
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
private fun startLibraryItemDownload(
libraryItem: LibraryItem,
localFolder: LocalFolder,
episode: PodcastEpisode?,
callback: (String?) -> Unit
) {
val isInternal = localFolder.id.startsWith("internal-")
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
@@ -224,8 +232,8 @@ class AbsDownloader : Plugin() {
downloadItem.downloadItemParts.add(downloadItemPart)
}
DownloadServiceHost.enqueue(mainActivity, downloadItem)
}
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
} else callback("No downloadable files found")
} else {
// Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
@@ -262,7 +270,7 @@ class AbsDownloader : Plugin() {
downloadItem.downloadItemParts.add(downloadItemPart)
}
DownloadServiceHost.enqueue(mainActivity, downloadItem)
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
}
}
}
@@ -28,7 +28,7 @@ class DownloadService : Service() {
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
else -> {
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
DownloadServiceHost.ensure(this)
DownloadServiceHost.startWork(this)
}
}
return START_STICKY
@@ -1,16 +1,25 @@
package com.audiobookshelf.app.services
import android.content.Context
import android.util.Log
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.managers.IncompleteDownloadCleanup
import com.audiobookshelf.app.models.DownloadItem
import com.getcapacitor.JSObject
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** Shared process owner used by the foreground service and the Capacitor bridge. */
object DownloadServiceHost {
enum class ExistingDownloadResult { NOT_FOUND, ACTIVE, RETRIED, SERVICE_START_FAILED }
data class NotificationStrings(
val preparing: String,
val downloadingFile: String,
@@ -21,9 +30,11 @@ object DownloadServiceHost {
private var manager: DownloadItemManager? = null
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
private var service: DownloadService? = null
@Volatile private var service: DownloadService? = null
@Volatile private var bridgeReady = false
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var restoreJob: Job? = null
@Synchronized
fun ensure(context: Context): DownloadItemManager {
@@ -31,7 +42,11 @@ object DownloadServiceHost {
val appContext = context.applicationContext
DbManager.initialize(appContext)
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
manager!!.restoreQueue()
restoreJob = scope.launch {
IncompleteDownloadCleanup.cleanupExpired(appContext)
manager!!.restoreQueue()
onRestoreComplete(appContext)
}
}
return manager!!
}
@@ -41,14 +56,12 @@ object DownloadServiceHost {
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
bridgeReady = false
bridgeEmitter = emitter
val queue = ensure(context)
queue.setEventEmitter(ForwardingEmitter)
ensure(context).setEventEmitter(ForwardingEmitter)
bridgeReady = true
val completions = synchronized(deferredCompletions) {
deferredCompletions.toList().also { deferredCompletions.clear() }
}
completions.forEach(bridgeEmitter::onDownloadItemComplete)
if (queue.hasWork()) startService(context)
}
@Synchronized
@@ -57,14 +70,44 @@ object DownloadServiceHost {
bridgeEmitter = NoopEmitter
}
@Synchronized
fun enqueue(context: Context, item: DownloadItem) {
ensure(context).addDownloadItem(item)
startService(context)
fun enqueue(context: Context, item: DownloadItem, callback: (String?) -> Unit) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.addDownloadItem(item)
if (startService(context)) callback(null)
else callback("Unable to start the Android download service")
}
}
@Synchronized
fun cancelAll(context: Context) { ensure(context).cancelAll() }
fun retryExisting(
context: Context,
downloadItemId: String,
callback: (ExistingDownloadResult) -> Unit
) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
val existing = queue.downloadItemQueue.find { it.id == downloadItemId }
if (existing == null) {
callback(ExistingDownloadResult.NOT_FOUND)
} else if (!queue.retryDownloadItem(downloadItemId)) {
callback(ExistingDownloadResult.ACTIVE)
} else if (startService(context)) {
callback(ExistingDownloadResult.RETRIED)
} else {
callback(ExistingDownloadResult.SERVICE_START_FAILED)
}
}
}
fun cancelAll(context: Context) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.cancelAll()
}
}
fun setNotificationStrings(
context: Context,
@@ -96,10 +139,9 @@ object DownloadServiceHost {
preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL)
}
@Synchronized
fun attachService(downloadService: DownloadService) {
service = downloadService
service?.onQueueChanged(ensure(downloadService).hasWork())
synchronized(this) { service = downloadService }
startWork(downloadService)
}
@Synchronized
@@ -107,8 +149,31 @@ object DownloadServiceHost {
if (service === downloadService) service = null
}
private fun startService(context: Context) {
ContextCompat.startForegroundService(context, DownloadService.intent(context))
fun startWork(context: Context) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.resumeWork()
}
}
private fun onRestoreComplete(context: Context) {
val attachedService = synchronized(this) { service }
if (attachedService != null) {
manager?.resumeWork()
} else if (bridgeReady && manager?.hasWork() == true) {
startService(context)
}
}
private fun startService(context: Context): Boolean {
return try {
ContextCompat.startForegroundService(context, DownloadService.intent(context))
true
} catch (e: RuntimeException) {
Log.e(TAG, "Could not start download foreground service", e)
false
}
}
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
@@ -144,4 +209,5 @@ object DownloadServiceHost {
private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage"
private const val DEFAULT_DOWNLOADS = "Downloads"
private const val DEFAULT_CANCEL = "Cancel"
private const val TAG = "DownloadServiceHost"
}