mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-09-09 19:31:51 +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'
|
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+)")
|
||||||
|
}
|
||||||
+11
-16
@@ -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) {
|
||||||
|
|||||||
+64
-6
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user