mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-09-08 19:01:50 +02:00
Initial download service startup fixes and range request fix
This commit is contained in:
@@ -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+)")
|
||||
}
|
||||
+11
-16
@@ -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) {
|
||||
|
||||
+66
-8
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user