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